feat: 50 игр на Lua + импорт Roblox для всех + поддержка Lua в плеере #39
1
.gitignore
vendored
1
.gitignore
vendored
@ -42,3 +42,4 @@ Thumbs.db
|
|||||||
|
|
||||||
# Большие png-ассеты вики (73МБ). Будут перенесены на CDN отдельной задачей.
|
# Большие png-ассеты вики (73МБ). Будут перенесены на CDN отдельной задачей.
|
||||||
/public/wiki/
|
/public/wiki/
|
||||||
|
rbxl-importer/src/__pycache__/
|
||||||
|
|||||||
124
RBXL_SOURCES.md
Normal file
124
RBXL_SOURCES.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# Реестр источников .rbxl / .rbxlx для портирования в Рублокс
|
||||||
|
|
||||||
|
Цель: легально добыть Roblox Place-файлы (.rbxl бинарный / .rbxlx XML) по жанрам
|
||||||
|
для портирования и публикации на Рублоксе.
|
||||||
|
|
||||||
|
**Форматы:** `.rbxlx` (XML — предпочтителен, читаемый, легко парсить геометрию/CFrame)
|
||||||
|
· `.rbxl` (бинарный, конвертировать) · `.rbxm`/`.rbxmx` (модели, не целые места).
|
||||||
|
|
||||||
|
> ⚠️ **Главное про публикацию:** «uncopylocked» ≠ свободная лицензия. Для ПУБЛИКАЦИИ
|
||||||
|
> порта на Рублоксе безопасны только: репо с явной **MIT/Apache/MPL/CC0/CC-BY** +
|
||||||
|
> официальные ассеты Roblox с разрешением. Архивы чужих игр — только для
|
||||||
|
> обучения/прототипа парсера, НЕ для публикации. Lua-скрипты не портируются
|
||||||
|
> автоматом — логику переписываешь сам (это и снижает юр.риски).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ИНСТРУМЕНТЫ (распаковка/парсинг)
|
||||||
|
|
||||||
|
| Инструмент | URL | Назначение | Лицензия |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Rojo | https://github.com/rojo-rbx/rojo | place ↔ файлы | MPL-2.0 |
|
||||||
|
| rbxlx-to-rojo | https://github.com/rojo-rbx/rbxlx-to-rojo | .rbxl/.rbxlx → проект | MPL/MIT (проверить) |
|
||||||
|
| rbxfile (Go) | https://github.com/robloxapi/rbxfile | парсинг rbxl/rbxlx/rbxm | MIT (проверить) |
|
||||||
|
| remodel | https://github.com/rojo-rbx/remodel | скриптовая обработка | MPL-2.0 |
|
||||||
|
| RobloxAPI/spec | https://github.com/RobloxAPI/spec/blob/master/formats/rbxl.md | спека бинарного формата | docs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## (А) ОФИЦИАЛЬНЫЕ — самые надёжные
|
||||||
|
|
||||||
|
### A1. Roblox/Old-Open-Source-Levels — классика от самой Roblox Corp ⭐
|
||||||
|
- https://github.com/Roblox/Old-Open-Source-Levels
|
||||||
|
- Каталог: https://github.com/Roblox/Old-Open-Source-Levels/blob/master/catalog.md
|
||||||
|
- ~30+ мест 2007-2013 (.rbxl). Жанры: Crossroads (арена/PvP), Castle Warfare,
|
||||||
|
ROBLOX Battle (бой), Sword Fight in the Dark (PvP), Haunted Mansion (хоррор),
|
||||||
|
Glass Houses, Pinball Wizards, Happy Home in Robloxia (песочница/мини).
|
||||||
|
- Лицензия: «free to manipulate however you wish» — **проверь файл LICENSE вручную** перед публикацией.
|
||||||
|
|
||||||
|
### A2. Встроенные шаблоны Roblox Studio
|
||||||
|
- Список: https://create.roblox.com/docs/resources/templates
|
||||||
|
- В Studio: открыть шаблон → File → Save to File → .rbxlx
|
||||||
|
- Baseplate, Castle, Suburban, Village, Racing, Classic Obby, Team Deathmatch/Combat,
|
||||||
|
Capture the Flag, Line Runner, Pirate Island, Modern City и др.
|
||||||
|
- Серая зона для публикации «как есть» — используй как базу/учёбу, геометрию делай своей.
|
||||||
|
|
||||||
|
### A3. creator-docs (документация Roblox, open)
|
||||||
|
- https://github.com/Roblox/creator-docs
|
||||||
|
|
||||||
|
### A4. Internet Archive — Crossroads (все версии 2007-2017)
|
||||||
|
- https://archive.org/details/roblox_crossroads
|
||||||
|
- https://archive.org/details/classic-crossroads_202408
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## (Б) РЕПОЗИТОРИИ С КОДОМ/МЕСТАМИ (URL из поиска)
|
||||||
|
|
||||||
|
### С подтверждённой свободной лицензией (можно публиковать)
|
||||||
|
| Репо | URL | Лицензия | Жанр |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Vigilant | https://github.com/IsoLogicGames/Vigilant | **MIT** ✅ | co-op horde-survival (шутер) |
|
||||||
|
| crossroads-rojo | https://github.com/Dekkonot/crossroads-rojo | наследует Crossroads | арена |
|
||||||
|
|
||||||
|
### Open-source игры (лицензию проверить у каждого — файл LICENSE)
|
||||||
|
| Репо | URL | Жанр |
|
||||||
|
|---|---|---|
|
||||||
|
| Miner's Haven | https://github.com/berezaa/minershaven | tycoon/симулятор |
|
||||||
|
| roblox-gym-tycoon | https://github.com/jason-lee88/roblox-gym-tycoon | tycoon |
|
||||||
|
| Racing-Kit-Roblox | https://github.com/Astrophsica/Racing-Kit-Roblox | гонки |
|
||||||
|
| RENTED_old_rbx | https://github.com/ReRand/RENTED_old_rbx | хоррор |
|
||||||
|
| roblox-rpg | https://github.com/mobyrblx/roblox-rpg | RPG/демо |
|
||||||
|
| RobloxGames (dwmk) | https://github.com/dwmk/RobloxGames | разное |
|
||||||
|
| recsObby | https://github.com/Nimblz/recsObby | obby |
|
||||||
|
| WavyRobloxObby | https://github.com/sammy0127/WavyRobloxObby | obby (.rbxlx) |
|
||||||
|
| Sight-Obby | https://github.com/TeoJJss/Sight-Obby | obby |
|
||||||
|
| fps (Anninzy) | https://github.com/Anninzy/fps | FPS |
|
||||||
|
| roblox-game-example | https://github.com/areshaistg/roblox-game-example | демо-каркас |
|
||||||
|
|
||||||
|
### Архивы чужих игр (ТОЛЬКО обучение/прототип, НЕ публикация — смешанные права)
|
||||||
|
| Репо | URL |
|
||||||
|
|---|---|
|
||||||
|
| uncopylocked-game-collection | https://github.com/Kitaske/uncopylocked-game-collection |
|
||||||
|
| robloxplacearchive | https://github.com/tropicalbananas/robloxplacearchive |
|
||||||
|
| RobloxRBXLArchive | https://github.com/LuaGunsX/RobloxRBXLArchive |
|
||||||
|
| Biggest Uncopylocked Library | https://github.com/KH0DIN/Biggest_Uncopylocked_Roblox_Games_Library |
|
||||||
|
| GitHub topics | https://github.com/topics/rbxlx · /rbxl · /rbxm · /rojo · /uncopylocked |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## (В) САЙТЫ ДЛЯ САМОСТОЯТЕЛЬНОГО СКАЧИВАНИЯ
|
||||||
|
|
||||||
|
### Прямое скачивание .rbxl/.rbxlx
|
||||||
|
- **GitHub code search** (вход обязателен): `extension:rbxlx`, `extension:rbxl`,
|
||||||
|
`filename:default.project.json` (корень Rojo-проекта рядом с местом)
|
||||||
|
https://github.com/search?q=extension%3Arbxlx&type=code
|
||||||
|
- **GitHub Topics:** https://github.com/topics/rbxlx · https://github.com/topics/rojo
|
||||||
|
- **Internet Archive:** https://archive.org/ — поиск «roblox place», «rbxl», «crossroads»
|
||||||
|
|
||||||
|
### CC0/CC-BY геометрия для воссоздания (юридически чистейший путь, не .rbxl но low-poly близко к Roblox)
|
||||||
|
- **Kenney** (CC0): https://kenney.nl/assets — Platformer/Nature/Car/Pirate/City/Prototype Kit, Blocky Characters
|
||||||
|
- **OpenGameArt** (CC0/CC-BY): https://opengameart.org/ — voxel/low-poly паки
|
||||||
|
- **itch.io** (фильтр assets+CC0): https://itch.io/game-assets/free/tag-low-poly
|
||||||
|
- **Poly Pizza** (CC0/CC-BY low-poly): https://poly.pizza/
|
||||||
|
- **Quaternius** (CC0 low-poly паки): https://quaternius.com/
|
||||||
|
|
||||||
|
### Сообщества с открытыми играми (часто прямые ссылки + лицензия)
|
||||||
|
- DevForum «free & open-sourced games»: https://devforum.roblox.com/t/lots-of-free-open-sourced-games/525670
|
||||||
|
- DevForum «Open Source Arena FPS»: https://devforum.roblox.com/t/open-source-arena-fps/1034576
|
||||||
|
- Uplift Games open source: https://www.uplift.games/open-source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ЮРИДИЧЕСКИЕ ПРАВИЛА (коротко)
|
||||||
|
|
||||||
|
- ✅ Публиковать можно: **MIT / Apache-2.0 / MPL-2.0 / CC0 / CC-BY** (CC-BY — с атрибуцией).
|
||||||
|
- ❌ Нельзя: **GPL/AGPL** (заразные), **CC-BY-NC** (некоммерч.), **без лицензии** (= all rights reserved),
|
||||||
|
чужие игры через game-savers/декомпиляторы (нарушение DMCA/ToS).
|
||||||
|
- ⚠️ «Uncopylocked» = только разрешение копировать в Studio, НЕ передача прав.
|
||||||
|
- ⚠️ Официальные шаблоны Studio — учиться ОК, публиковать «как есть» — серая зона.
|
||||||
|
|
||||||
|
**Рекомендация для наполнения Рублокса легально:**
|
||||||
|
1. Геометрия под чистую публикацию → Kenney/OpenGameArt CC0.
|
||||||
|
2. Классика Roblox-стиля → Roblox/Old-Open-Source-Levels (проверить LICENSE) + Crossroads.
|
||||||
|
3. Полная игра с кодом → Vigilant (MIT).
|
||||||
|
4. Масса .rbxl для теста парсера → архивы из (Б) + GitHub topics.
|
||||||
504
RUBLOX_LUA_API.md
Normal file
504
RUBLOX_LUA_API.md
Normal file
@ -0,0 +1,504 @@
|
|||||||
|
# Lua API Рублокса (справочник для скриптеров)
|
||||||
|
|
||||||
|
Этот документ — полный список того, что работает в Lua-скриптах Рублокса.
|
||||||
|
API максимально приближен к Roblox, чтобы можно было переносить чужие
|
||||||
|
скрипты с минимальными правками.
|
||||||
|
|
||||||
|
> **Как переключить скрипт на Lua:** в шапке вкладки редактора кода кликни
|
||||||
|
> по переключателю **JS / Lua**. Подсветка синтаксиса и автодополнение
|
||||||
|
> автоматически переключатся.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
1. [Базовые типы](#базовые-типы)
|
||||||
|
2. [DataModel: game, workspace, Players](#datamodel)
|
||||||
|
3. [Part — куб на сцене](#part)
|
||||||
|
4. [Создание и удаление](#создание-и-удаление)
|
||||||
|
5. [События: Touched, Heartbeat, RemoteEvent](#события)
|
||||||
|
6. [Таймеры: task.wait, task.delay](#таймеры)
|
||||||
|
7. [GUI: TextLabel, TextButton, Frame](#gui)
|
||||||
|
8. [Звук: Sound](#звук)
|
||||||
|
9. [Анимации: TweenService](#tweenservice)
|
||||||
|
10. [Игрок: Humanoid, LocalPlayer](#игрок)
|
||||||
|
11. [Чего пока нет](#чего-пока-нет)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Базовые типы
|
||||||
|
|
||||||
|
### `Vector3`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local v = Vector3.new(1, 2, 3)
|
||||||
|
print(v.X, v.Y, v.Z) -- 1 2 3
|
||||||
|
print(v.Magnitude) -- 3.7416... (длина)
|
||||||
|
print(v.Unit) -- нормализованный
|
||||||
|
print(v:Dot(otherVec)) -- скалярное произведение
|
||||||
|
print(v:Cross(otherVec)) -- векторное произведение
|
||||||
|
local mid = v:Lerp(otherVec, 0.5) -- линейная интерполяция
|
||||||
|
|
||||||
|
-- Константы:
|
||||||
|
Vector3.zero -- (0,0,0)
|
||||||
|
Vector3.one -- (1,1,1)
|
||||||
|
Vector3.xAxis -- (1,0,0)
|
||||||
|
Vector3.yAxis, Vector3.zAxis
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддержаны операторы: `+`, `-`, `*` (на число), `/`, унарный `-`.
|
||||||
|
|
||||||
|
### `Color3`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local c = Color3.new(0.5, 0.2, 0.8) -- 0..1 каждый
|
||||||
|
local c2 = Color3.fromRGB(255, 128, 0) -- 0..255
|
||||||
|
local c3 = Color3.fromHSV(0.1, 0.8, 1)
|
||||||
|
local c4 = Color3.fromHex("#FF8000")
|
||||||
|
local mid = c:Lerp(c2, 0.5)
|
||||||
|
print(c:ToHex()) -- "#7F33CC"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `UDim2` / `UDim` / `Vector2`
|
||||||
|
|
||||||
|
Для GUI-координат:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local pos = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана (scale/offset)
|
||||||
|
local pos2 = UDim2.fromScale(0.2, 0.1)
|
||||||
|
local pos3 = UDim2.fromOffset(100, 50) -- в пикселях
|
||||||
|
```
|
||||||
|
|
||||||
|
### `CFrame`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local cf = CFrame.new(0, 10, 0) -- позиция
|
||||||
|
local cf2 = CFrame.lookAt(eye, target) -- упрощённый
|
||||||
|
print(cf.Position) -- Vector3
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Enum`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
Enum.KeyCode.W
|
||||||
|
Enum.KeyCode.Space
|
||||||
|
Enum.Material.Plastic, Enum.Material.Neon, Enum.Material.Wood
|
||||||
|
Enum.UserInputType.MouseButton1
|
||||||
|
Enum.HumanoidStateType.Running
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DataModel
|
||||||
|
|
||||||
|
Виртуальное дерево, как в Roblox:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
game -- корневой DataModel
|
||||||
|
game.Workspace -- = workspace (короче)
|
||||||
|
game.Players -- сервис игроков
|
||||||
|
game.Players.LocalPlayer -- локальный игрок
|
||||||
|
game.ReplicatedStorage -- хранилище общих ресурсов
|
||||||
|
game.StarterGui -- стартовое GUI
|
||||||
|
game.Lighting -- свет
|
||||||
|
```
|
||||||
|
|
||||||
|
Методы:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local svc = game:GetService("RunService")
|
||||||
|
local part = workspace:FindFirstChild("Coin")
|
||||||
|
local part2 = workspace:FindFirstChildOfClass("Part")
|
||||||
|
local all = workspace:GetChildren() -- массив всех детей
|
||||||
|
local descendants = workspace:GetDescendants()
|
||||||
|
local sib = workspace.Coin:FindFirstAncestorOfClass("Workspace")
|
||||||
|
print(workspace:IsA("Workspace")) -- true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part
|
||||||
|
|
||||||
|
`Part` — куб/сфера/цилиндр на сцене. **Это обёртка над примитивом Рублокса.**
|
||||||
|
Скрипт привязанный к кубу получает его через `script.Parent`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- script.Parent — Part к которому прицеплен скрипт
|
||||||
|
print(script.Parent.Name) -- "Part_1"
|
||||||
|
|
||||||
|
-- Чтение свойств
|
||||||
|
print(script.Parent.Position) -- Vector3
|
||||||
|
print(script.Parent.Size) -- Vector3
|
||||||
|
print(script.Parent.Color) -- Color3
|
||||||
|
print(script.Parent.Anchored) -- bool
|
||||||
|
print(script.Parent.CanCollide) -- bool
|
||||||
|
print(script.Parent.Transparency) -- 0..1
|
||||||
|
|
||||||
|
-- Запись (двигает куб в реальном времени!)
|
||||||
|
script.Parent.Position = Vector3.new(0, 10, 0)
|
||||||
|
script.Parent.Size = Vector3.new(5, 1, 5)
|
||||||
|
script.Parent.Color = Color3.fromRGB(255, 0, 0)
|
||||||
|
script.Parent.Anchored = false -- куб начнёт падать (физика)
|
||||||
|
script.Parent.Transparency = 0.5 -- полупрозрачный
|
||||||
|
script.Parent.CFrame = CFrame.new(0, 20, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Создание и удаление
|
||||||
|
|
||||||
|
### `Instance.new`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Создать Part на сцене
|
||||||
|
local p = Instance.new("Part")
|
||||||
|
p.Position = Vector3.new(0, 5, 0)
|
||||||
|
p.Size = Vector3.new(2, 2, 2)
|
||||||
|
p.Color = Color3.fromRGB(255, 100, 0)
|
||||||
|
p.Anchored = true
|
||||||
|
p.Parent = workspace
|
||||||
|
|
||||||
|
-- Удалить через 3 секунды
|
||||||
|
task.delay(3, function()
|
||||||
|
p:Destroy()
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддержанные классы:
|
||||||
|
- **Сцена:** `Part`, `WedgePart`, `MeshPart`
|
||||||
|
- **События:** `RemoteEvent`, `BindableEvent`
|
||||||
|
- **GUI:** `ScreenGui`, `Frame`, `TextLabel`, `TextButton`, `ImageLabel`,
|
||||||
|
`ImageButton`, `TextBox`, `ScrollingFrame`
|
||||||
|
- **Звук:** `Sound`
|
||||||
|
- **Прочее:** `Folder`, `Humanoid`, `Configuration`, любой `ClassName`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## События
|
||||||
|
|
||||||
|
### `script.Parent.Touched` — касание игрока
|
||||||
|
|
||||||
|
```lua
|
||||||
|
script.Parent.Touched:Connect(function(hit)
|
||||||
|
print("Игрок коснулся!", hit.Name)
|
||||||
|
local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid")
|
||||||
|
if h then
|
||||||
|
h:TakeDamage(100) -- KillBrick
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `RunService.Heartbeat` — каждый кадр
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
RunService.Heartbeat:Connect(function(dt)
|
||||||
|
-- dt — время с прошлого кадра (~0.016)
|
||||||
|
script.Parent.Position = script.Parent.Position + Vector3.new(0, 0.1, 0)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `BindableEvent` / `RemoteEvent` — общение между скриптами
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Скрипт A создаёт событие в общем месте
|
||||||
|
local event = Instance.new("BindableEvent")
|
||||||
|
event.Name = "MyEvent"
|
||||||
|
event.Parent = game.ReplicatedStorage
|
||||||
|
|
||||||
|
-- Скрипт B подписывается
|
||||||
|
local event = game.ReplicatedStorage:WaitForChild("MyEvent")
|
||||||
|
event.Event:Connect(function(msg, num)
|
||||||
|
print("Получено:", msg, num)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Скрипт A триггерит
|
||||||
|
event:Fire("привет", 42)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Humanoid.Died`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid")
|
||||||
|
h.Died:Connect(function()
|
||||||
|
print("игрок умер")
|
||||||
|
end)
|
||||||
|
h.HealthChanged:Connect(function(newHp)
|
||||||
|
print("здоровье:", newHp)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Таймеры
|
||||||
|
|
||||||
|
### `task.wait(сек)` — приостановить скрипт
|
||||||
|
|
||||||
|
```lua
|
||||||
|
print("сейчас")
|
||||||
|
task.wait(1)
|
||||||
|
print("через секунду")
|
||||||
|
```
|
||||||
|
|
||||||
|
`task.wait` **не блокирует** другие скрипты — это yield через coroutines.
|
||||||
|
Можно использовать в `while true do ... task.wait(0.1) end` без проблем.
|
||||||
|
|
||||||
|
### `task.delay(сек, fn)` — выполнить через
|
||||||
|
|
||||||
|
```lua
|
||||||
|
task.delay(2, function()
|
||||||
|
print("через 2 секунды")
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `task.spawn(fn)` — асинхронно
|
||||||
|
|
||||||
|
```lua
|
||||||
|
task.spawn(function()
|
||||||
|
print("параллельно с основным потоком")
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GUI
|
||||||
|
|
||||||
|
### Базовая иерархия
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- ScreenGui — корень всех GUI
|
||||||
|
local sg = Instance.new("ScreenGui")
|
||||||
|
sg.Parent = game.Players.LocalPlayer.PlayerGui
|
||||||
|
|
||||||
|
-- TextLabel — статичный текст
|
||||||
|
local label = Instance.new("TextLabel")
|
||||||
|
label.Parent = sg
|
||||||
|
label.Text = "Привет!"
|
||||||
|
label.TextColor3 = Color3.fromRGB(255, 255, 0)
|
||||||
|
label.BackgroundColor3 = Color3.fromRGB(50, 30, 20)
|
||||||
|
label.Position = UDim2.new(0.4, 0, 0.1, 0) -- 40% от ширины, 10% от высоты
|
||||||
|
label.Size = UDim2.new(0.2, 0, 0.05, 0)
|
||||||
|
label.TextSize = 24
|
||||||
|
|
||||||
|
-- TextButton — кликабельная кнопка
|
||||||
|
local btn = Instance.new("TextButton")
|
||||||
|
btn.Parent = sg
|
||||||
|
btn.Text = "Нажми"
|
||||||
|
btn.Position = UDim2.new(0.4, 0, 0.5, 0)
|
||||||
|
btn.Size = UDim2.new(0.2, 0, 0.08, 0)
|
||||||
|
btn.MouseButton1Click:Connect(function()
|
||||||
|
print("Клик!")
|
||||||
|
label.Text = "Нажата!"
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | Описание |
|
||||||
|
|----------------------|-----------|-----------------------------------|
|
||||||
|
| `Text` | string | Видимый текст |
|
||||||
|
| `TextColor3` | Color3 | Цвет текста |
|
||||||
|
| `TextSize` | number | Размер шрифта |
|
||||||
|
| `BackgroundColor3` | Color3 | Цвет фона |
|
||||||
|
| `BackgroundTransparency` | 0..1 | 0=сплошной, 1=прозрачный |
|
||||||
|
| `Position` | UDim2 | Позиция (scale=%, offset=px/10) |
|
||||||
|
| `Size` | UDim2 | Размер |
|
||||||
|
| `Visible` | bool | Виден или нет |
|
||||||
|
|
||||||
|
### События кнопок
|
||||||
|
|
||||||
|
```lua
|
||||||
|
btn.MouseButton1Click:Connect(fn) -- ЛКМ клик
|
||||||
|
btn.MouseEnter:Connect(fn) -- наведение
|
||||||
|
btn.MouseLeave:Connect(fn) -- увод
|
||||||
|
btn.Activated:Connect(fn) -- = MouseButton1Click
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Звук
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local sound = Instance.new("Sound")
|
||||||
|
sound.SoundId = "coin" -- или "jump", "win", "lose", "hit", "click", "pickup"
|
||||||
|
sound.Volume = 1 -- 0..2
|
||||||
|
sound.PlaybackSpeed = 1 -- pitch
|
||||||
|
sound:Play()
|
||||||
|
```
|
||||||
|
|
||||||
|
Также Roblox-AssetID работает с эвристикой:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
sound.SoundId = "rbxassetid://1234567890" -- автоподбор по имени переменной
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддержанные звуки (процедурные, не из файлов):
|
||||||
|
- `jump` — прыжок
|
||||||
|
- `pickup` — подбор
|
||||||
|
- `coin` — звон монеты
|
||||||
|
- `win` — победа
|
||||||
|
- `lose` — поражение
|
||||||
|
- `click` — клик
|
||||||
|
- `hit` — удар
|
||||||
|
|
||||||
|
Зацикливание:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
sound.Looped = true
|
||||||
|
sound:Play() -- играет до sound:Stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TweenService
|
||||||
|
|
||||||
|
Плавная анимация свойств:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local TweenService = game:GetService("TweenService")
|
||||||
|
|
||||||
|
local part = script.Parent
|
||||||
|
local tween = TweenService:Create(
|
||||||
|
part,
|
||||||
|
{ Time = 2 }, -- длительность 2 сек
|
||||||
|
{ Position = Vector3.new(0, 20, 0),
|
||||||
|
Color = Color3.fromRGB(255, 0, 0) } -- цели
|
||||||
|
)
|
||||||
|
tween:Play()
|
||||||
|
|
||||||
|
tween.Completed:Connect(function()
|
||||||
|
print("Анимация завершилась!")
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Работает с `Position`, `Size`, `Color` (Vector3/Color3) и числовыми
|
||||||
|
свойствами (`Transparency`, `TextSize`, и т.д.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Игрок
|
||||||
|
|
||||||
|
### `game.Players.LocalPlayer`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local plr = game.Players.LocalPlayer
|
||||||
|
print(plr.Name, plr.UserId, plr.DisplayName)
|
||||||
|
print(plr.Character) -- Model
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Humanoid`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local char = game.Players.LocalPlayer.Character
|
||||||
|
local h = char:FindFirstChildOfClass("Humanoid")
|
||||||
|
|
||||||
|
print(h.Health, h.MaxHealth)
|
||||||
|
print(h.WalkSpeed) -- скорость ходьбы
|
||||||
|
print(h.JumpPower) -- сила прыжка
|
||||||
|
|
||||||
|
h.Health = 0 -- мгновенная смерть → респавн
|
||||||
|
h:TakeDamage(50) -- урон с учётом invulnerability
|
||||||
|
|
||||||
|
h.Died:Connect(function()
|
||||||
|
print("Помер")
|
||||||
|
end)
|
||||||
|
h.HealthChanged:Connect(function(newHp)
|
||||||
|
if newHp < 30 then
|
||||||
|
print("Здоровье низкое!")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `HumanoidRootPart`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local hrp = char:FindFirstChild("HumanoidRootPart")
|
||||||
|
print(hrp.Position)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чего пока нет
|
||||||
|
|
||||||
|
Не работает (пока):
|
||||||
|
|
||||||
|
- **Скрипты не делятся на Server/LocalScript** — все скрипты client-side.
|
||||||
|
- **DataStoreService** — методы есть, но возвращают nil/no-op.
|
||||||
|
- **`workspace:Raycast`** / **`game.Lighting.ClockTime`** — заглушки.
|
||||||
|
- **`Players.PlayerAdded`** — никогда не фейерится (только один игрок).
|
||||||
|
- **3D-анимации (`Animation` instance + `AnimationController`)** —
|
||||||
|
`LoadAnimation` возвращает заглушку.
|
||||||
|
- **`Sound` из файлов** — только встроенные процедурные.
|
||||||
|
- **`SurfaceGui` / `BillboardGui`** — нет, только `ScreenGui`.
|
||||||
|
- **`Model:MoveTo` / `:SetPrimaryPartCFrame`** — нет.
|
||||||
|
- **Networking (`RemoteFunction:InvokeServer`)** — RemoteEvent работает
|
||||||
|
только в пределах одного клиента.
|
||||||
|
|
||||||
|
Если что-то из этого критично — открой issue в репо.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример: KillBrick + монета + GUI-счётчик
|
||||||
|
|
||||||
|
Положи 1 куб и 1 шарик на сцене. К каждому привяжи скрипт:
|
||||||
|
|
||||||
|
**На кубе (KillBrick):**
|
||||||
|
```lua
|
||||||
|
script.Parent.Color = Color3.fromRGB(200, 30, 30)
|
||||||
|
script.Parent.Touched:Connect(function()
|
||||||
|
local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid")
|
||||||
|
if h then h:TakeDamage(100) end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
**На шарике (Coin):**
|
||||||
|
```lua
|
||||||
|
script.Parent.Color = Color3.fromRGB(255, 215, 0)
|
||||||
|
script.Parent.Touched:Connect(function()
|
||||||
|
-- Запускаем событие на ReplicatedStorage
|
||||||
|
local re = game.ReplicatedStorage:FindFirstChild("CoinPicked")
|
||||||
|
if not re then
|
||||||
|
re = Instance.new("BindableEvent")
|
||||||
|
re.Name = "CoinPicked"
|
||||||
|
re.Parent = game.ReplicatedStorage
|
||||||
|
end
|
||||||
|
re:Fire()
|
||||||
|
script.Parent:Destroy()
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Глобальный скрипт (GUI):**
|
||||||
|
```lua
|
||||||
|
local sg = Instance.new("ScreenGui")
|
||||||
|
sg.Parent = game.Players.LocalPlayer.PlayerGui
|
||||||
|
|
||||||
|
local label = Instance.new("TextLabel")
|
||||||
|
label.Parent = sg
|
||||||
|
label.Text = "Монет: 0"
|
||||||
|
label.Position = UDim2.new(0.05, 0, 0.05, 0)
|
||||||
|
label.Size = UDim2.new(0.1, 0, 0.05, 0)
|
||||||
|
label.TextSize = 20
|
||||||
|
label.TextColor3 = Color3.fromRGB(255, 215, 0)
|
||||||
|
|
||||||
|
local count = 0
|
||||||
|
task.spawn(function()
|
||||||
|
while not game.ReplicatedStorage:FindFirstChild("CoinPicked") do
|
||||||
|
task.wait(0.1)
|
||||||
|
end
|
||||||
|
game.ReplicatedStorage.CoinPicked.Event:Connect(function()
|
||||||
|
count = count + 1
|
||||||
|
label.Text = "Монет: " .. count
|
||||||
|
local sound = Instance.new("Sound")
|
||||||
|
sound.SoundId = "coin"
|
||||||
|
sound:Play()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Получится: красный куб убивает, золотая монета даёт +1 к счётчику со
|
||||||
|
звуком.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Версия документации:** Этап 7 (готово после реализации Этапов 1-6).
|
||||||
|
Если что-то описанное здесь не работает — это баг, репортуй.
|
||||||
431
RUBLOX_LUA_API_CHANGELOG.md
Normal file
431
RUBLOX_LUA_API_CHANGELOG.md
Normal 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
434
RUBLOX_LUA_SUPPORT_PLAN.md
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
# План: Полная поддержка Lua-скриптов в Рублоксе
|
||||||
|
|
||||||
|
**Цель:** Пользователь Рублокс-студии создаёт скрипт → выбирает язык **JS** или **Lua** → пишет код → в плеере оба языка работают параллельно. Lua-скрипты совместимы с Roblox API настолько, чтобы код из Roblox-игр работал без модификаций.
|
||||||
|
|
||||||
|
**Зачем:** В Roblox экосистеме сотни тысяч разработчиков, привыкших к Lua + Roblox API (Vector3, CFrame, Instance, RemoteEvent, etc.). Сейчас они не могут перенести свои игры. С этой фичей — могут.
|
||||||
|
|
||||||
|
**Срок:** ~6 недель полного времени. Можно делать поэтапно (после каждого этапа есть полезный результат).
|
||||||
|
|
||||||
|
**Юр.риск:** "юр риски беру на себя" (МИН, 2026-06-08).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектурное решение
|
||||||
|
|
||||||
|
### Текущая ситуация
|
||||||
|
- Скрипты хранятся как `{id, code, target, name}` в БД
|
||||||
|
- `GameRuntime` запускает каждый JS-скрипт в Web Worker `ScriptSandboxWorker.js`
|
||||||
|
- API игре доступен через `game.*` объект (события, scene, player, gui, save, etc.)
|
||||||
|
- Скриптов в игре могут быть **сотни**, каждый — отдельный Worker
|
||||||
|
|
||||||
|
### Целевая
|
||||||
|
- Скрипты получают поле `language: 'js' | 'lua'` (default `'js'`)
|
||||||
|
- В редакторе — переключатель в `ScriptEditor.jsx` в шапке: **JS / Lua**
|
||||||
|
- Monaco-editor подсвечивает Lua (есть встроенный язык в `monaco-editor`)
|
||||||
|
- В runtime: JS-скрипты идут через старый sandbox, Lua — через новый `LuaSandbox.js`
|
||||||
|
- Lua-runtime построен поверх **wasmoon** (Lua 5.4 в WebAssembly)
|
||||||
|
- Lua-скрипты делятся на **один shared VM** на игру (а не Web Worker на скрипт) — иначе OOM при 100+ скриптах
|
||||||
|
- Lua-API проксирует Roblox API → нашу game.* плюс полную **DataModel** иерархию
|
||||||
|
|
||||||
|
### Ключевая идея
|
||||||
|
**Lua-скрипты не вызывают `game.move(...)`. Они вызывают `script.Parent.Position = Vector3.new(1,2,3)`** — как в Roblox. Lua-shim под капотом переводит это в `partSet` команды для движка Рублокса. Юзер пишет идиоматичный Roblox-Lua, движок Рублокса исполняет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 1: UI и хранение языка (3 дня)
|
||||||
|
|
||||||
|
### 1.1 Миграция БД
|
||||||
|
- Добавить поле `language VARCHAR(8) DEFAULT 'js' NOT NULL` в таблицу `scripts` (есть на storys API)
|
||||||
|
- API endpoints (`POST/PATCH /scripts/...`) принимают и сохраняют `language`
|
||||||
|
- Старые скрипты без поля → `'js'` по умолчанию
|
||||||
|
|
||||||
|
### 1.2 ScriptEditor.jsx
|
||||||
|
- Переключатель в шапке редактора: `[ JS | Lua ]` (segmented control)
|
||||||
|
- При смене языка — confirm("Сменить язык? Текущий код будет очищен"), затем код сбрасывается на шаблон-заглушку нового языка
|
||||||
|
- Monaco language switch: `javascript` ↔ `lua`
|
||||||
|
- Подсветка/автодополнение Lua встроены в Monaco (`monaco-editor/esm/vs/basic-languages/lua`)
|
||||||
|
- Линтер ошибок Lua через **luaparse** (npm пакет, ~50KB, parse-only) — показываем красные подчёркивания
|
||||||
|
- Шаблон Lua для нового скрипта на target=Part:
|
||||||
|
```lua
|
||||||
|
-- Скрипт на части. script.Parent = эта часть.
|
||||||
|
local part = script.Parent
|
||||||
|
part.Touched:Connect(function(hit)
|
||||||
|
print("Касание:", hit.Name)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
- Шаблон Lua для глобального скрипта (target=nil):
|
||||||
|
```lua
|
||||||
|
local Players = game:GetService("Players")
|
||||||
|
Players.PlayerAdded:Connect(function(player)
|
||||||
|
print("Игрок зашёл:", player.Name)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Иконка языка в HierarchyPanel
|
||||||
|
- Рядом с именем скрипта — маленький бейдж `JS` или `Lua` (синий / голубой)
|
||||||
|
- Помогает не путаться при сотне скриптов
|
||||||
|
|
||||||
|
### Что готово в конце Этапа 1
|
||||||
|
- Юзер может **создать Lua-скрипт**, написать код, сохранить
|
||||||
|
- Код **не исполняется** — пока только хранится и редактируется
|
||||||
|
- В плеере Lua-скрипты молча игнорируются
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 2: Базовый Lua-runtime (5 дней)
|
||||||
|
|
||||||
|
### 2.1 LuaSharedSandbox.js — новый sandbox-класс
|
||||||
|
|
||||||
|
Полная архитектура шаринг-VM (один wasmoon-state на все Lua-скрипты игры):
|
||||||
|
|
||||||
|
```
|
||||||
|
GameRuntime
|
||||||
|
├── ScriptSandbox (JS-скрипт, Web Worker, как сейчас)
|
||||||
|
├── ScriptSandbox (JS-скрипт)
|
||||||
|
├── LuaSharedSandbox ← НОВЫЙ
|
||||||
|
│ ├── LuaSharedWorker.js
|
||||||
|
│ │ └── wasmoon VM (один на всю игру)
|
||||||
|
│ │ ├── Roblox API shim
|
||||||
|
│ │ ├── DataModel tree (game.Workspace, Players, ...)
|
||||||
|
│ │ └── все Lua-скрипты как сопрограммы (coroutines)
|
||||||
|
│ └── проксирует partSet/sceneCreate/event обратно в main thread
|
||||||
|
```
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
- `src/editor/engine/LuaSharedSandbox.js` (main thread): API совместимый с ScriptSandbox (`sendSceneSnapshot`, `sendGlobalEvent`, etc.)
|
||||||
|
- `src/editor/engine/LuaSharedWorker.js` (Web Worker): держит wasmoon, исполняет скрипты, шлёт командные сообщения
|
||||||
|
- `src/editor/engine/RobloxLuaShim.js` (worker side): объявление всех Roblox-классов и сервисов
|
||||||
|
|
||||||
|
### 2.2 Минимальный Roblox shim в первой итерации
|
||||||
|
- `Vector3.new(x,y,z)`, `+`, `-`, `*`, `:Magnitude()`, `:Dot()`, `:Cross()`, `:Lerp()`, `:Normalize()`
|
||||||
|
- `Color3.new(r,g,b)`, `Color3.fromRGB(r,g,b)`, `:Lerp()`
|
||||||
|
- `CFrame.new(x,y,z)`, `CFrame.lookAt()`, `CFrame.fromEulerAnglesXYZ()`, операторы `*` и `:Inverse()`, `:ToWorldSpace()`
|
||||||
|
- `UDim2.new(sx,ox,sy,oy)`, `UDim.new(s,o)`
|
||||||
|
- `Enum.KeyCode.W`, `Enum.UserInputType.MouseButton1`, etc. (через generated table)
|
||||||
|
- `print()` → console + onLog event в студии
|
||||||
|
- `wait(secs)` / `task.wait(secs)` — через coroutine + scheduler в main loop
|
||||||
|
- `tick()`, `os.time()`, `os.clock()`, `math.*`, `string.*`, `table.*` (стандартные Lua)
|
||||||
|
- `pcall`, `xpcall`, `error`, `assert` (стандартные)
|
||||||
|
|
||||||
|
### 2.3 GameRuntime интеграция
|
||||||
|
- При старте игры — пробежать по `scripts[]`, разделить на `jsScripts` и `luaScripts`
|
||||||
|
- Для JS — старый путь (по сэндбоксу на скрипт)
|
||||||
|
- Для Lua — один `LuaSharedSandbox`, в него `addScript(luaSource)` каждым
|
||||||
|
- LuaSharedSandbox шлёт назад те же команды что JS sandbox: `partSet`, `sceneCreate`, `chatSay`, `guiSet`, etc.
|
||||||
|
|
||||||
|
### Что готово в конце Этапа 2
|
||||||
|
- Lua-скрипты **исполняются**
|
||||||
|
- Можно использовать `Vector3`, `Color3`, `print()`, `wait()`, `math/string/table`
|
||||||
|
- Скрипт **ещё не видит** Workspace, Player, GUI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 3: DataModel — game.Workspace и иерархия (5 дней)
|
||||||
|
|
||||||
|
### 3.1 Что такое DataModel
|
||||||
|
В Roblox любая игра — **дерево объектов**. Корень = `game`. У него детки = сервисы: `Workspace`, `Players`, `ReplicatedStorage`, `Lighting`, `StarterGui`, `RunService`, etc. У каждого деток — свои детки.
|
||||||
|
|
||||||
|
У нас сейчас сцена плоская: `primitives`, `blocks`, `models`. Нужно **виртуальное дерево DataModel** поверх плоской сцены.
|
||||||
|
|
||||||
|
### 3.2 Виртуальное дерево
|
||||||
|
Файл: `src/editor/engine/datamodel/DataModelTree.js`
|
||||||
|
|
||||||
|
При старте Lua-runtime, для текущей сцены строится виртуальное дерево:
|
||||||
|
|
||||||
|
```
|
||||||
|
game (RbxGame)
|
||||||
|
├── Workspace (RbxWorkspace)
|
||||||
|
│ ├── Part_0 ← обёртка над primitive id=0
|
||||||
|
│ ├── Part_1 ← обёртка над primitive id=1
|
||||||
|
│ ├── Model_5 ← обёртка над model id=5 (с Children)
|
||||||
|
│ │ └── Part_inner
|
||||||
|
│ ├── Camera
|
||||||
|
│ └── Terrain
|
||||||
|
├── Players (RbxPlayers)
|
||||||
|
│ └── LocalPlayer (RbxPlayer)
|
||||||
|
│ ├── Character (RbxCharacter)
|
||||||
|
│ │ ├── Humanoid (RbxHumanoid)
|
||||||
|
│ │ └── HumanoidRootPart (RbxPart)
|
||||||
|
│ └── PlayerGui (RbxScreenGui-контейнер)
|
||||||
|
│ └── (Lua-скрипты могут спавнить GUI через Instance.new)
|
||||||
|
├── ReplicatedStorage (RbxFolder)
|
||||||
|
├── ServerStorage (RbxFolder)
|
||||||
|
├── Lighting (RbxLighting)
|
||||||
|
├── StarterGui (RbxFolder)
|
||||||
|
├── StarterPlayer (RbxFolder)
|
||||||
|
│ └── StarterCharacter (RbxFolder)
|
||||||
|
├── RunService (RbxRunService с :Heartbeat, :Stepped, :RenderStepped)
|
||||||
|
├── UserInputService (события InputBegan/Ended/Changed)
|
||||||
|
├── TweenService (:Create, :GetService для tweens)
|
||||||
|
├── HttpService (заглушка либо проксируем через нашего бэка)
|
||||||
|
├── DataStoreService (проксируем через game.save)
|
||||||
|
└── MarketplaceService (заглушка)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Instance-классы
|
||||||
|
`src/editor/engine/datamodel/Instance.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class RbxInstance {
|
||||||
|
Name = "Instance";
|
||||||
|
ClassName = "Instance";
|
||||||
|
Parent = null;
|
||||||
|
Children = [];
|
||||||
|
|
||||||
|
// Свойства которые юзер может ставить через __newindex (metatable)
|
||||||
|
// отслеживаются — при изменении посылается команда в main thread
|
||||||
|
// для синхронизации с Babylon-сценой
|
||||||
|
|
||||||
|
GetChildren() { return [...this.Children]; }
|
||||||
|
FindFirstChild(name, recursive) { ... }
|
||||||
|
WaitForChild(name, timeout) { ... } // через coroutine + yield
|
||||||
|
FindFirstAncestor(name) { ... }
|
||||||
|
FindFirstChildOfClass(class) { ... }
|
||||||
|
Destroy() { ... }
|
||||||
|
Clone() { ... }
|
||||||
|
IsA(class) { ... }
|
||||||
|
GetFullName() { ... }
|
||||||
|
GetAttribute(name) / SetAttribute(name, value)
|
||||||
|
GetPropertyChangedSignal(name) → RbxSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
class RbxPart extends RbxInstance {
|
||||||
|
Position // setter: партиклс в Babylon (primitiveManager.setPosition)
|
||||||
|
Size // setter
|
||||||
|
Color // setter
|
||||||
|
Material // setter (mapping Roblox materials → наши)
|
||||||
|
Anchored // setter
|
||||||
|
CanCollide // setter
|
||||||
|
Touched // RbxSignal — Fire когда BabylonScene детектит overlap
|
||||||
|
TouchEnded // RbxSignal
|
||||||
|
CFrame // computed property — Position + rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
class RbxModel extends RbxInstance {
|
||||||
|
PrimaryPart
|
||||||
|
GetPrimaryPartCFrame() / SetPrimaryPartCFrame()
|
||||||
|
PivotTo(cframe) // MoveTo + Rotate
|
||||||
|
GetBoundingBox()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RbxHumanoid extends RbxInstance {
|
||||||
|
Health = 100
|
||||||
|
MaxHealth = 100
|
||||||
|
WalkSpeed = 16
|
||||||
|
JumpPower = 50
|
||||||
|
Died, HealthChanged, Touched, StateChanged — signals
|
||||||
|
TakeDamage(amount)
|
||||||
|
MoveTo(pos) // simulates Roblox NPC pathing
|
||||||
|
LoadAnimation(anim) → RbxAnimationTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
class RbxScript extends RbxInstance {
|
||||||
|
Source // источник Lua (read-only обычно)
|
||||||
|
Disabled // bool
|
||||||
|
RunContext // Server / Client / Legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
class RbxRemoteEvent extends RbxInstance {
|
||||||
|
OnServerEvent : RbxSignal
|
||||||
|
OnClientEvent : RbxSignal
|
||||||
|
FireServer(...args)
|
||||||
|
FireClient(player, ...args)
|
||||||
|
FireAllClients(...args)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Метатаблицы Lua
|
||||||
|
Каждая JS-обёртка `RbxPart` экспортируется в Lua как **table с metatable**:
|
||||||
|
- `__index` — чтение свойства, либо метод
|
||||||
|
- `__newindex` — запись свойства, триггерит side-effects (синхронизация сцены)
|
||||||
|
- `__tostring` — для `print(part)` показывает `"Part_0"`
|
||||||
|
|
||||||
|
Это **критично** для совместимости с Roblox-скриптами.
|
||||||
|
|
||||||
|
### 3.5 script.Parent для каждого Lua-скрипта
|
||||||
|
- Если скрипт привязан к `target=42` (primitive id 42) — `script.Parent = workspace:FindFirstChild по primId(42)`
|
||||||
|
- Если глобальный — `script.Parent = nil`
|
||||||
|
- В скриптовом контексте: `script` это таблица `{Name=..., Parent=..., ClassName="Script"}`
|
||||||
|
|
||||||
|
### Что готово в конце Этапа 3
|
||||||
|
- Lua-скрипт может пройтись по `game.Workspace:GetChildren()`
|
||||||
|
- `script.Parent.Touched:Connect(...)` работает (KillBrick = реально)
|
||||||
|
- `local player = game.Players.LocalPlayer; player.Character.Humanoid.Health = 0` убивает игрока
|
||||||
|
- `Instance.new("Part", workspace)` создаёт примитив
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 4: Полный Roblox API (10 дней)
|
||||||
|
|
||||||
|
Закрываем "длинный хвост" API. Каждый день — 1-2 сервиса.
|
||||||
|
|
||||||
|
### 4.1 Services
|
||||||
|
- **RunService**: `.Heartbeat`, `.Stepped`, `.RenderStepped` — RbxSignals, фаер в main loop tick
|
||||||
|
- **UserInputService**: `.InputBegan`, `.InputChanged`, `.InputEnded` — события KeyCode/MouseButton/Touch
|
||||||
|
- **TweenService**: `:Create(instance, TweenInfo, propertyTable)` → возвращает RbxTween; `Tween:Play()/Pause()/Cancel()`; интерполируется в main loop
|
||||||
|
- **DataStoreService**: проксируем через наш `game.save` (sync version)
|
||||||
|
- `:GetDataStore(name)` → объект с `:GetAsync(key)`, `:SetAsync(key, value)`, `:UpdateAsync(key, fn)`
|
||||||
|
- Async-методы через coroutine.yield + наш save API
|
||||||
|
- **MarketplaceService**: заглушки `PromptPurchase`, `GetProductInfo` (бизнес-логика через наш интерфейс)
|
||||||
|
- **HttpService**: `:JSONEncode`, `:JSONDecode`, `:GenerateGUID` — простая встроенная реализация; `:GetAsync/PostAsync` проксируем через ограниченный список разрешённых доменов
|
||||||
|
- **Players**: `LocalPlayer`, `:GetPlayers()`, `PlayerAdded`/`PlayerRemoving` сигналы
|
||||||
|
- **Lighting**: read-only сейчас (через `Lighting.Ambient`, `Lighting.OutdoorAmbient` ставить значения нашему envManager)
|
||||||
|
- **Workspace**: `:Raycast(origin, direction, params)` → реальный raycast через PhysicsAABB; `:GetServerTimeNow()`; `CurrentCamera`
|
||||||
|
|
||||||
|
### 4.2 GUI (важно!)
|
||||||
|
- `Instance.new("ScreenGui")` → если `Parent = playerGui`, регистрируется в нашем GuiManager
|
||||||
|
- `TextLabel`, `TextButton`, `ImageLabel`, `ImageButton`, `Frame` — все мапятся на наш GuiOverlay
|
||||||
|
- `MouseButton1Click`, `MouseEnter`, `MouseLeave`, `Activated` — сигналы
|
||||||
|
- `UDim2`, `Vector2` для позиций/размеров
|
||||||
|
- При установке `gui.Position = UDim2.new(0.5, 0, 0.5, 0)` — пересылается в GuiManager и обновляется DOM
|
||||||
|
|
||||||
|
### 4.3 Sound
|
||||||
|
- `Instance.new("Sound", part)` с `SoundId = "rbxassetid://12345"` или с нашим URL
|
||||||
|
- `:Play()`, `:Stop()`, `:Pause()`, `Volume`, `Pitch`, `Looped`
|
||||||
|
- Под капотом — наш SoundManager
|
||||||
|
|
||||||
|
### 4.4 Animation
|
||||||
|
- `Instance.new("Animation")` с `AnimationId` (наши собственные ID анимаций R15)
|
||||||
|
- `humanoid:LoadAnimation(anim) → AnimationTrack`
|
||||||
|
- `:Play()`, `:Stop()`, `:AdjustSpeed()`, `:GetMarkerReachedSignal()`
|
||||||
|
- Связь с нашим R15Animator
|
||||||
|
|
||||||
|
### 4.5 Tools / Backpack
|
||||||
|
- `Tool` Instance: `Activated`, `Equipped`, `Unequipped` сигналы
|
||||||
|
- `player.Backpack:GetChildren()` — Lua видит инвентарь
|
||||||
|
- Реализация через наш HotbarManager + InventoryService
|
||||||
|
|
||||||
|
### 4.6 ProximityPrompt, ClickDetector
|
||||||
|
- ProximityPrompt: `Triggered` сигнал, `:Show/:Hide`, ActionText, ObjectText
|
||||||
|
- ClickDetector: `MouseClick`, `MouseHoverEnter/Leave` сигналы
|
||||||
|
|
||||||
|
### 4.7 Networking-эмуляция (single-player)
|
||||||
|
- RemoteEvent / RemoteFunction работают **локально** (поскольку у нас singleplayer на client-only)
|
||||||
|
- `FireServer/InvokeServer` запускают handlers в том же VM, но в other-side контексте
|
||||||
|
- Это позволяет копировать многопользовательские скрипты Roblox без изменений (хоть мультиплеера и нет)
|
||||||
|
- Когда у Рублокса появится мультиплеер — `RemoteEvent` уже будет работать «по-настоящему» без изменений в скриптах юзера
|
||||||
|
|
||||||
|
### Что готово в конце Этапа 4
|
||||||
|
- **~90% типовых Roblox-скриптов работают** без модификаций
|
||||||
|
- DataStore сохраняет прогресс
|
||||||
|
- TweenService плавно двигает объекты
|
||||||
|
- GUI создаётся скриптом
|
||||||
|
- Анимации играются
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 5: Импорт .rbxl + конвертер юзер-кода (5 дней)
|
||||||
|
|
||||||
|
### 5.1 Изменение импортера
|
||||||
|
Сейчас импортер сохраняет Lua-исходник в JS-комментарии и пытается завернуть в JS-обёртку. **Это устаревает.**
|
||||||
|
|
||||||
|
Новый путь:
|
||||||
|
- Импортер сохраняет Lua-source **как есть** (без обёрток)
|
||||||
|
- В записи скрипта `language = 'lua'`
|
||||||
|
- target = primitiveId или null
|
||||||
|
- В GameRuntime Lua-скрипт идёт сразу в LuaSharedSandbox
|
||||||
|
|
||||||
|
### 5.2 Конвертация ассетов
|
||||||
|
- Roblox MeshId/TextureId через наш ImageProxy → ассеты сохраняются в minio + на CDN
|
||||||
|
- `rbxassetid://12345` → resolve в наш asset_id
|
||||||
|
- Сохранение в БД ссылок на ассеты
|
||||||
|
|
||||||
|
### 5.3 Поведение при импорте
|
||||||
|
- При импорте .rbxl карты — все Lua-скрипты сохраняются как Lua-скрипты Рублокса (не пытаемся конвертить в JS)
|
||||||
|
- Юзер открывает игру → редактирует → видит в Hierarchy `Script (Lua)` рядом с `Script (JS)` — может писать на любом
|
||||||
|
|
||||||
|
### Что готово в конце Этапа 5
|
||||||
|
- Импорт Roblox-карты работает **бесшовно**
|
||||||
|
- Юзер может править Lua-код в редакторе и видеть результат
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 6: Производительность и стабильность (5 дней)
|
||||||
|
|
||||||
|
### 6.1 Profiling
|
||||||
|
- Замерить FPS на картах с 100/500/1000 Lua-скриптов
|
||||||
|
- Если падает — переезд на **fengari** (pure-JS Lua interp, в 5-10× медленнее wasmoon но без WASM overhead на старте) либо на **собственный Lua-bytecode runtime** для горячих скриптов
|
||||||
|
|
||||||
|
### 6.2 Memory
|
||||||
|
- Каждый wasmoon VM ~10-15MB. Один на игру = ОК. Если придётся разделять на части (server/client), нужно бенчить.
|
||||||
|
|
||||||
|
### 6.3 Песочница (security)
|
||||||
|
- Lua не должен дёргать `io.*`, `os.execute`, `loadstring(внешний код)`, etc.
|
||||||
|
- Whitelist стандартной библиотеки. Запрещаем всё что может выйти из браузера.
|
||||||
|
|
||||||
|
### 6.4 Ошибки
|
||||||
|
- Lua-ошибки в скрипте — показываются в Output-панели студии (как JS-ошибки)
|
||||||
|
- Stack trace с правильными номерами строк (не из обёртки)
|
||||||
|
- Если скрипт зациклился — kill через `debug.sethook` после N инструкций без yield
|
||||||
|
|
||||||
|
### 6.5 Тесты
|
||||||
|
- 50 unit-тестов на Roblox API (Vector3 операции, CFrame, Instance.new, Touched, RunService, TweenService)
|
||||||
|
- 10 интеграционных: импортировать тест-rbxl, запустить, проверить что нужное случилось
|
||||||
|
- CI: тесты прогоняются в Gitea Actions при PR
|
||||||
|
|
||||||
|
### Что готово в конце Этапа 6
|
||||||
|
- Lua-runtime production-ready
|
||||||
|
- Можно объявить публично «теперь в Рублоксе пишут на Lua с Roblox-совместимостью»
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 7: Документация (3 дня)
|
||||||
|
|
||||||
|
### 7.1 Раздел вики
|
||||||
|
- `wiki/lua-intro` — введение в Lua для Рублокса (для пользователей, которые приходят с Roblox — короткое)
|
||||||
|
- `wiki/lua-vs-js` — таблица: «то же самое на JS и на Lua» для типичных задач
|
||||||
|
- `wiki/roblox-api-supported` — список того что работает / не работает / отличается
|
||||||
|
- `wiki/lua-examples` — 20 готовых сниппетов (KillBrick, TeleportPad, Checkpoint, Coin, NPCFollower, etc.)
|
||||||
|
|
||||||
|
### 7.2 Migration guide
|
||||||
|
- «Как перенести свою Roblox-игру в Рублокс» — пошаговое
|
||||||
|
- Список известных несовместимостей и их обходов
|
||||||
|
|
||||||
|
### 7.3 PR-материал
|
||||||
|
- Пост в /developer на team.rublox.pro: «Lua-поддержка теперь GA»
|
||||||
|
- Если есть бюджет — короткий ролик на YouTube/TikTok
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этапы целиком
|
||||||
|
|
||||||
|
| Этап | Длительность | Содержание |
|
||||||
|
|------|--------------|------------|
|
||||||
|
| 1 | 3 дня | UI и хранение языка |
|
||||||
|
| 2 | 5 дней | Базовый Lua-runtime + минимальный shim |
|
||||||
|
| 3 | 5 дней | DataModel (game.Workspace и иерархия) |
|
||||||
|
| 4 | 10 дней | Полный Roblox API (services, GUI, Sound, Animation) |
|
||||||
|
| 5 | 5 дней | Импорт .rbxl и асетов |
|
||||||
|
| 6 | 5 дней | Производительность, безопасность, тесты |
|
||||||
|
| 7 | 3 дня | Документация и публикация |
|
||||||
|
| **Итого** | **~36 рабочих дней** | **~6 недель полного времени** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что-то можно делать раньше, чтобы получить пользу
|
||||||
|
|
||||||
|
- **MVP (Этапы 1+2):** через **8 дней** — юзер может писать Lua-скрипты с минимальным API (Vector3, Color3, print, wait). Уже видит что фича есть.
|
||||||
|
- **Beta (Этапы 1-3):** через **13 дней** — KillBrick'и работают, можно делать простые игры на Lua.
|
||||||
|
- **GA (все этапы):** через **6 недель** — продакшен.
|
||||||
|
|
||||||
|
После каждого этапа можно делать релиз и собирать фидбек.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решения которые нужны от тебя перед стартом
|
||||||
|
|
||||||
|
1. **wasmoon vs fengari** — wasmoon быстрее но WASM-heavy, fengari проще но медленнее. Предлагаю wasmoon (уже используем для импорта).
|
||||||
|
2. **Один shared VM на игру** — согласен или разделять server/client? Предлагаю один в singleplayer-фазе, разделение — позже когда будет мультиплеер.
|
||||||
|
3. **Бэкенд изменения** — нужна миграция БД (поле `language`). У нас сейчас S2 + S1 + auto-backup, ничего страшного, но согласовать момент апдейта.
|
||||||
|
4. **Roblox API trademark/copyright** — мы делаем API-compatible runtime. Названия классов `Workspace`, `Humanoid`, etc. это API names. Юр.риск есть. Ты сказал берёшь — фиксируем.
|
||||||
|
5. **Приоритет** — этот план делать **вместо** других задач (тогда параллельные фичи стопаются) или **после** текущего бэклога?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Связанные документы
|
||||||
|
|
||||||
|
- `RUBLOX_PROJECT.md` — общий план Рублокса
|
||||||
|
- `RUBLOX_EDITOR_ROADMAP.md` — куда движется редактор
|
||||||
|
- `INFO_PROCESS.md` — лог реализации (будет апдейтиться по ходу)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Создано:** 2026-06-08, Claude (Opus 4.7) совместно с МИНом.
|
||||||
|
**Статус:** план готов, ждём решения по 5 вопросам перед стартом.
|
||||||
BIN
rbxl-importer/src/__pycache__/converter.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/converter.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc
Normal file
Binary file not shown.
@ -122,12 +122,23 @@ def analyze():
|
|||||||
blob = upload.read()
|
blob = upload.read()
|
||||||
if len(blob) > MAX_RBXL_SIZE:
|
if len(blob) > MAX_RBXL_SIZE:
|
||||||
return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413
|
return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413
|
||||||
if not blob.startswith(b'<roblox!'):
|
# Авто-детект XML vs Binary формата.
|
||||||
return jsonify({'error': 'not a .rbxl binary file (missing <roblox! magic)'}), 400
|
# Бинарный: <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:
|
try:
|
||||||
model = parse(blob)
|
if is_xml:
|
||||||
|
from rbxl_xml_parser import parse_xml
|
||||||
|
model = parse_xml(blob)
|
||||||
|
else:
|
||||||
|
model = parse(blob)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': f'parse failed: {e}'}), 422
|
return jsonify({'error': f'parse failed: {e}'}), 422
|
||||||
|
|
||||||
@ -199,6 +210,16 @@ def create():
|
|||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
preview_hash = data.get('preview_hash')
|
preview_hash = data.get('preview_hash')
|
||||||
title = (data.get('title') or '').strip() or 'Импортировано из Roblox'
|
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:
|
if not preview_hash:
|
||||||
return jsonify({'error': 'preview_hash required'}), 400
|
return jsonify({'error': 'preview_hash required'}), 400
|
||||||
@ -263,6 +284,14 @@ def create():
|
|||||||
# Подставляем URLs в project_data
|
# Подставляем URLs в project_data
|
||||||
_resolve_asset_urls(project_data, asset_url_map)
|
_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
|
# Создаём проект в kubikon3d_projects
|
||||||
# Используем bridge на user-service / storys API, ИЛИ напрямую в storys_db.
|
# Используем bridge на user-service / storys API, ИЛИ напрямую в storys_db.
|
||||||
# Прямой INSERT — проще для MVP. id автогенерируется.
|
# Прямой INSERT — проще для MVP. id автогенерируется.
|
||||||
@ -324,5 +353,66 @@ def _resolve_asset_urls(project_data: dict, asset_map: dict) -> None:
|
|||||||
snd['url'] = asset_map[rid]
|
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__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', port=8690, debug=False)
|
app.run(host='0.0.0.0', port=8690, debug=False)
|
||||||
|
|||||||
@ -103,19 +103,42 @@ SHAPE_TO_PRIMITIVE = {
|
|||||||
# ────── BrickColor таблица (упрощённая) ──────
|
# ────── BrickColor таблица (упрощённая) ──────
|
||||||
# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые:
|
# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые:
|
||||||
BRICKCOLOR_TO_HEX = {
|
BRICKCOLOR_TO_HEX = {
|
||||||
1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea',
|
# Базовые тона
|
||||||
21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e',
|
1: '#f2f3f3', 2: '#a1a5a2', 3: '#f9e999', 5: '#d9e4f7',
|
||||||
28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32',
|
9: '#9c9e9c', 11: '#e8eaea', 18: '#cc8e69', 21: '#c4281c',
|
||||||
101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a',
|
23: '#0d69ac', 24: '#f5cd30', 26: '#1b2a35', 28: '#293f1a',
|
||||||
105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50',
|
29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', 101: '#dab8a3',
|
||||||
111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76',
|
102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', 105: '#cf8b3e',
|
||||||
141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91',
|
106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', 111: '#a7a6a6',
|
||||||
199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8',
|
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',
|
1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000',
|
||||||
1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00',
|
1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00',
|
||||||
1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff',
|
1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff',
|
||||||
1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0',
|
1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0',
|
||||||
1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80',
|
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': [],
|
'sounds': [],
|
||||||
'glbModels': [],
|
'glbModels': [],
|
||||||
'scripts': [],
|
'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 и конвертим
|
# Обходим все instances и конвертим
|
||||||
for inst in self.model.instances:
|
for inst in self.model.instances:
|
||||||
self._convert_one(inst, scene)
|
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]:
|
for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]:
|
||||||
self.stats.warnings.append(f"skipped {n}× {cls}")
|
self.stats.warnings.append(f"skipped {n}× {cls}")
|
||||||
@ -308,8 +368,12 @@ class Converter:
|
|||||||
elif cls == 'Workspace':
|
elif cls == 'Workspace':
|
||||||
# Workspace = root, его свойства мапим на scene.worldSize и т.п.
|
# Workspace = root, его свойства мапим на scene.worldSize и т.п.
|
||||||
pass
|
pass
|
||||||
|
elif cls == 'Team':
|
||||||
|
# PvP-команда: имя + цвет в scene.teams[].
|
||||||
|
self._convert_team(inst, scene)
|
||||||
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
|
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
|
||||||
'StarterPack', 'StarterCharacterScripts', 'Players',
|
'StarterPack', 'StarterCharacterScripts', 'Players',
|
||||||
|
'Teams',
|
||||||
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
|
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
|
||||||
'SoundService', 'TweenService', 'RunService',
|
'SoundService', 'TweenService', 'RunService',
|
||||||
'UserInputService', 'HttpService', 'DataStoreService',
|
'UserInputService', 'HttpService', 'DataStoreService',
|
||||||
@ -374,7 +438,9 @@ class Converter:
|
|||||||
'canCollide': bool(props.get('CanCollide', True)),
|
'canCollide': bool(props.get('CanCollide', True)),
|
||||||
'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else 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)),
|
'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,
|
'mass': 1.0,
|
||||||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||||||
}
|
}
|
||||||
@ -405,7 +471,9 @@ class Converter:
|
|||||||
'material': material_to_string(props.get('Material')),
|
'material': material_to_string(props.get('Material')),
|
||||||
'canCollide': bool(props.get('CanCollide', True)),
|
'canCollide': bool(props.get('CanCollide', True)),
|
||||||
'visible': True,
|
'visible': True,
|
||||||
'anchored': bool(props.get('Anchored', False)),
|
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
|
||||||
|
# физика 700+ unanchored Part'ов = карта рассыпается.
|
||||||
|
'anchored': True,
|
||||||
'mass': 1.0,
|
'mass': 1.0,
|
||||||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||||||
})
|
})
|
||||||
@ -434,7 +502,9 @@ class Converter:
|
|||||||
'material': material_to_string(props.get('Material')),
|
'material': material_to_string(props.get('Material')),
|
||||||
'canCollide': bool(props.get('CanCollide', True)),
|
'canCollide': bool(props.get('CanCollide', True)),
|
||||||
'visible': True,
|
'visible': True,
|
||||||
'anchored': bool(props.get('Anchored', False)),
|
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
|
||||||
|
# физика 700+ unanchored Part'ов = карта рассыпается.
|
||||||
|
'anchored': True,
|
||||||
'mass': 1.0,
|
'mass': 1.0,
|
||||||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||||||
})
|
})
|
||||||
@ -506,7 +576,9 @@ class Converter:
|
|||||||
'material': material_to_string(props.get('Material')),
|
'material': material_to_string(props.get('Material')),
|
||||||
'canCollide': bool(props.get('CanCollide', True)),
|
'canCollide': bool(props.get('CanCollide', True)),
|
||||||
'visible': True,
|
'visible': True,
|
||||||
'anchored': bool(props.get('Anchored', False)),
|
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
|
||||||
|
# физика 700+ unanchored Part'ов = карта рассыпается.
|
||||||
|
'anchored': True,
|
||||||
'mass': 1.0,
|
'mass': 1.0,
|
||||||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||||||
'note': f'MeshPart (no GLB) rbxid={rbx_id}',
|
'note': f'MeshPart (no GLB) rbxid={rbx_id}',
|
||||||
@ -527,7 +599,9 @@ class Converter:
|
|||||||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||||||
'color': get_part_color(props),
|
'color': get_part_color(props),
|
||||||
'canCollide': bool(props.get('CanCollide', True)),
|
'canCollide': bool(props.get('CanCollide', True)),
|
||||||
'anchored': bool(props.get('Anchored', False)),
|
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
|
||||||
|
# физика 700+ unanchored Part'ов = карта рассыпается.
|
||||||
|
'anchored': True,
|
||||||
'origin': 'roblox-meshpart',
|
'origin': 'roblox-meshpart',
|
||||||
'rbxAssetId': rbx_id,
|
'rbxAssetId': rbx_id,
|
||||||
})
|
})
|
||||||
@ -567,7 +641,9 @@ class Converter:
|
|||||||
'material': material_to_string(props.get('Material')),
|
'material': material_to_string(props.get('Material')),
|
||||||
'canCollide': bool(props.get('CanCollide', True)),
|
'canCollide': bool(props.get('CanCollide', True)),
|
||||||
'visible': True,
|
'visible': True,
|
||||||
'anchored': bool(props.get('Anchored', False)),
|
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
|
||||||
|
# физика 700+ unanchored Part'ов = карта рассыпается.
|
||||||
|
'anchored': True,
|
||||||
'mass': 1.0,
|
'mass': 1.0,
|
||||||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||||||
'note': f'Union (no CSG GLB) rbxid={rbx_id}',
|
'note': f'Union (no CSG GLB) rbxid={rbx_id}',
|
||||||
@ -586,7 +662,9 @@ class Converter:
|
|||||||
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
|
||||||
'color': get_part_color(props),
|
'color': get_part_color(props),
|
||||||
'canCollide': bool(props.get('CanCollide', True)),
|
'canCollide': bool(props.get('CanCollide', True)),
|
||||||
'anchored': bool(props.get('Anchored', False)),
|
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
|
||||||
|
# физика 700+ unanchored Part'ов = карта рассыпается.
|
||||||
|
'anchored': True,
|
||||||
'origin': 'roblox-union',
|
'origin': 'roblox-union',
|
||||||
'rbxAssetId': rbx_id,
|
'rbxAssetId': rbx_id,
|
||||||
})
|
})
|
||||||
@ -594,15 +672,43 @@ class Converter:
|
|||||||
|
|
||||||
# ─── Spawn ───
|
# ─── 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:
|
def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
|
||||||
props = inst.properties
|
props = inst.properties
|
||||||
cf = props.get('CFrame')
|
cf = props.get('CFrame')
|
||||||
pos, _ = cframe_to_pos_rot(cf, self.scale)
|
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'] = {
|
scene['spawnPoint'] = {
|
||||||
'x': pos['x'],
|
'x': pos['x'],
|
||||||
'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите
|
'y': pos['y'] + 5,
|
||||||
'z': pos['z'],
|
'z': pos['z'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -719,9 +825,13 @@ class Converter:
|
|||||||
if not hasattr(self, '_screen_gui_refs'):
|
if not hasattr(self, '_screen_gui_refs'):
|
||||||
self._screen_gui_refs = set()
|
self._screen_gui_refs = set()
|
||||||
self._screen_gui_enabled = {}
|
self._screen_gui_enabled = {}
|
||||||
|
self._screen_gui_kind = {} # ref → 'screen' | 'billboard' | 'surface'
|
||||||
self._screen_gui_refs.add(inst.referent)
|
self._screen_gui_refs.add(inst.referent)
|
||||||
enabled = inst.properties.get('Enabled', True)
|
enabled = inst.properties.get('Enabled', True)
|
||||||
self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else 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]:
|
def _gui_parent_id(self, parent_ref) -> Optional[str]:
|
||||||
if parent_ref is None:
|
if parent_ref is None:
|
||||||
@ -815,12 +925,14 @@ class Converter:
|
|||||||
# элемент тоже невидим.
|
# элемент тоже невидим.
|
||||||
parent_ref = inst.parent_referent
|
parent_ref = inst.parent_referent
|
||||||
screen_enabled = True
|
screen_enabled = True
|
||||||
|
container_kind = 'screen' # default
|
||||||
if hasattr(self, '_screen_gui_refs'):
|
if hasattr(self, '_screen_gui_refs'):
|
||||||
cur = parent_ref
|
cur = parent_ref
|
||||||
depth = 0
|
depth = 0
|
||||||
while cur is not None and depth < 50:
|
while cur is not None and depth < 50:
|
||||||
if cur in self._screen_gui_refs:
|
if cur in self._screen_gui_refs:
|
||||||
screen_enabled = self._screen_gui_enabled.get(cur, True)
|
screen_enabled = self._screen_gui_enabled.get(cur, True)
|
||||||
|
container_kind = self._screen_gui_kind.get(cur, 'screen')
|
||||||
break
|
break
|
||||||
# Поиск родителя cur в instances (если есть)
|
# Поиск родителя cur в instances (если есть)
|
||||||
cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None
|
cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None
|
||||||
@ -873,6 +985,10 @@ class Converter:
|
|||||||
'imageAsset': None,
|
'imageAsset': None,
|
||||||
'zIndex': int(props.get('ZIndex', 1) or 1),
|
'zIndex': int(props.get('ZIndex', 1) or 1),
|
||||||
'origin': 'roblox-' + cls.lower(),
|
'origin': 'roblox-' + cls.lower(),
|
||||||
|
# 'screen' — обычный HUD; 'billboard' — 3D-табличка над частью;
|
||||||
|
# 'surface' — на грани Part. Last 2 рендерятся в 3D-сцене и
|
||||||
|
# сильно тормозят если их сотни.
|
||||||
|
'gui_container_kind': container_kind,
|
||||||
}
|
}
|
||||||
scene['gui'].append(element)
|
scene['gui'].append(element)
|
||||||
|
|
||||||
|
|||||||
@ -113,18 +113,28 @@ class CFrame:
|
|||||||
matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22)
|
matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22)
|
||||||
|
|
||||||
def to_euler_xyz(self) -> tuple:
|
def to_euler_xyz(self) -> tuple:
|
||||||
"""Конверт 3x3 rotation matrix в Euler XYZ (radians).
|
"""Конверт 3x3 rotation matrix в Euler YXZ (Babylon convention).
|
||||||
|
|
||||||
Использует стандартную intrinsic XYZ rotation extraction:
|
Babylon mesh.rotation = Vector3(rx, ry, rz) применяется в порядке YXZ
|
||||||
Rx = atan2(r21, r22)
|
(rotate Y first, then X, then Z). Чтобы извлечь Euler из матрицы под
|
||||||
Ry = atan2(-r20, sqrt(r21² + r22²))
|
этот convention, используем формулу YXZ-extraction:
|
||||||
Rz = atan2(r10, r00)
|
Rx = asin(-r12)
|
||||||
|
Ry = atan2(r02, r22)
|
||||||
|
Rz = atan2(r10, r11)
|
||||||
|
(имя метода to_euler_xyz сохраняем для совместимости вызовов.)
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix
|
r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix
|
||||||
rx = math.atan2(r21, r22)
|
# Edge case: r12 близко к ±1 (gimbal lock на X = ±90°)
|
||||||
ry = math.atan2(-r20, math.sqrt(r21*r21 + r22*r22))
|
clamped = max(-1.0, min(1.0, -r12))
|
||||||
rz = math.atan2(r10, r00)
|
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)
|
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
|
Источник: https://dom.rojo.space/binary#cframe-orientation-ids
|
||||||
|
|
||||||
Это полная таблица 24-х валидных orientation id для cube symmetries.
|
Формула из rbx-dom:
|
||||||
Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22).
|
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) где
|
# Правильный порядок axes (rbx-dom):
|
||||||
# значения в {0,1,2,3,4,5} = +X, -X, +Y, -Y, +Z, -Z
|
# 0=+X, 1=+Y, 2=+Z, 3=-X, 4=-Y, 5=-Z
|
||||||
AXES = [
|
AXES = [
|
||||||
(1, 0, 0), (-1, 0, 0),
|
(1, 0, 0), # +X
|
||||||
(0, 1, 0), (0, -1, 0),
|
(0, 1, 0), # +Y
|
||||||
(0, 0, 1), (0, 0, -1),
|
(0, 0, 1), # +Z
|
||||||
|
(-1, 0, 0), # -X
|
||||||
|
(0, -1, 0), # -Y
|
||||||
|
(0, 0, -1), # -Z
|
||||||
]
|
]
|
||||||
# orientation_id = 1..24 (1-based)
|
# orientation_id = 1..36 (некоторые комбинации rx==ry невалидны, в файлах
|
||||||
if not (1 <= orientation_id <= 24):
|
# не встречаются — но id может доходить до 6*6 = 36, не 24).
|
||||||
# Неверный id — возвращаем identity
|
if not (1 <= orientation_id <= 36):
|
||||||
return (1, 0, 0, 0, 1, 0, 0, 0, 1)
|
return (1, 0, 0, 0, 1, 0, 0, 0, 1)
|
||||||
|
|
||||||
idx = orientation_id - 1
|
idx = orientation_id - 1
|
||||||
@ -571,16 +592,14 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple:
|
|||||||
ry_idx = idx % 6
|
ry_idx = idx % 6
|
||||||
rx = AXES[rx_idx]
|
rx = AXES[rx_idx]
|
||||||
ry = AXES[ry_idx]
|
ry = AXES[ry_idx]
|
||||||
# rz = rx × ry (cross product)
|
# rz = rx × ry (cross product) — третий столбец
|
||||||
rz = (
|
rz = (
|
||||||
rx[1] * ry[2] - rx[2] * ry[1],
|
rx[1] * ry[2] - rx[2] * ry[1],
|
||||||
rx[2] * ry[0] - rx[0] * ry[2],
|
rx[2] * ry[0] - rx[0] * ry[2],
|
||||||
rx[0] * ry[1] - rx[1] * ry[0],
|
rx[0] * ry[1] - rx[1] * ry[0],
|
||||||
)
|
)
|
||||||
# Матрица: первые 3 — first row (R_xx, R_yx, R_zx)
|
# rx, ry, rz — это СТОЛБЦЫ матрицы.
|
||||||
# Сложновато; берём из rbx-dom convention: первые три — основа R*XAxis,
|
# row-major: [r00=rx[0], r01=ry[0], r02=rz[0], r10=rx[1], r11=ry[1], r12=rz[1], ...]
|
||||||
# затем R*YAxis, затем R*ZAxis. Расширяем в row-major form.
|
|
||||||
# На практике: orientation вектора (rx, ry, rz) — это **столбцы** матрицы.
|
|
||||||
r00, r10, r20 = rx
|
r00, r10, r20 = rx
|
||||||
r01, r11, r21 = ry
|
r01, r11, r21 = ry
|
||||||
r02, r12, r22 = rz
|
r02, r12, r22 = rz
|
||||||
|
|||||||
342
rbxl-importer/src/rbxl_xml_parser.py
Normal file
342
rbxl-importer/src/rbxl_xml_parser.py
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
"""
|
||||||
|
rbxl_xml_parser.py — парсер XML-формата .rbxl (старые карты до 2010 года).
|
||||||
|
|
||||||
|
Roblox-XML формат — текстовый предок бинарного .rbxl. Файл начинается с
|
||||||
|
<roblox version="4"> и содержит дерево <Item class="...">...</Item>.
|
||||||
|
|
||||||
|
Возвращает тот же `RobloxModel` что и rbxl_parser.parse — чтобы converter.py
|
||||||
|
работал без изменений.
|
||||||
|
|
||||||
|
Пример входного файла:
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="Workspace">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Workspace</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="Part">
|
||||||
|
<Properties>
|
||||||
|
<CoordinateFrame name="CFrame">
|
||||||
|
<X>0</X><Y>10</Y><Z>0</Z>
|
||||||
|
<R00>1</R00>...<R22>1</R22>
|
||||||
|
</CoordinateFrame>
|
||||||
|
<Vector3 name="size"><X>4</X><Y>1</Y><Z>2</Z></Vector3>
|
||||||
|
<Color3uint8 name="Color3uint8">4286611584</Color3uint8>
|
||||||
|
<token name="BrickColor">21</token>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
|
|
||||||
|
Поддерживает все типичные property-теги: string, bool, int, float, double,
|
||||||
|
token, Vector3, Vector2, CoordinateFrame, Color3, Color3uint8, BrickColor,
|
||||||
|
Content, ProtectedString, Ref, BinaryString, UDim, UDim2, Rect2D.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from rbxl_parser import Instance, RobloxModel
|
||||||
|
from rbxl_types import (
|
||||||
|
Vector3, Vector2, Color3, CFrame, BrickColor,
|
||||||
|
EnumValue, PhysicalProperties, OptionalCFrame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Magic для XML-формата
|
||||||
|
XML_MAGIC = b'<roblox'
|
||||||
|
|
||||||
|
|
||||||
|
def is_xml_rbxl(blob: bytes) -> bool:
|
||||||
|
"""Проверяет XML это или нет. Бинарный начинается с <roblox!..."""
|
||||||
|
stripped = blob.lstrip()
|
||||||
|
if stripped.startswith(b'<roblox') and not stripped.startswith(b'<roblox!'):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _text(el: ET.Element, default: str = '') -> str:
|
||||||
|
"""Текст элемента (None → default)."""
|
||||||
|
return (el.text if el.text is not None else default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _f(el: ET.Element, default: float = 0.0) -> float:
|
||||||
|
"""Float из text."""
|
||||||
|
try:
|
||||||
|
return float(_text(el, '0'))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _i(el: ET.Element, default: int = 0) -> int:
|
||||||
|
"""Int из text."""
|
||||||
|
try:
|
||||||
|
s = _text(el, '0')
|
||||||
|
# Roblox иногда пишет '1.0' где ожидается int
|
||||||
|
return int(float(s)) if '.' in s else int(s)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vector3(el: ET.Element) -> Vector3:
|
||||||
|
x = _f(el.find('X'))
|
||||||
|
y = _f(el.find('Y'))
|
||||||
|
z = _f(el.find('Z'))
|
||||||
|
return Vector3(x, y, z)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vector2(el: ET.Element) -> Vector2:
|
||||||
|
x = _f(el.find('X'))
|
||||||
|
y = _f(el.find('Y'))
|
||||||
|
return Vector2(x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cframe(el: ET.Element) -> CFrame:
|
||||||
|
"""CoordinateFrame: 3 позиции + 9 элементов матрицы ротации."""
|
||||||
|
pos = Vector3(_f(el.find('X')), _f(el.find('Y')), _f(el.find('Z')))
|
||||||
|
matrix = tuple(_f(el.find(f'R{i}{j}'), 1.0 if i == j else 0.0)
|
||||||
|
for i in range(3) for j in range(3))
|
||||||
|
return CFrame(position=pos, matrix=matrix)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_color3(el: ET.Element) -> Color3:
|
||||||
|
"""<Color3 name="..."><R>...</R><G>...</G><B>...</B></Color3>"""
|
||||||
|
r = _f(el.find('R'))
|
||||||
|
g = _f(el.find('G'))
|
||||||
|
b = _f(el.find('B'))
|
||||||
|
return Color3(r, g, b)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_color3uint8(el: ET.Element) -> Color3:
|
||||||
|
"""<Color3uint8>4286611584</Color3uint8> — packed RGB как uint32."""
|
||||||
|
val = _i(el, 0)
|
||||||
|
# uint32 = 0xFFRRGGBB (alpha=FF). r=byte2, g=byte1, b=byte0
|
||||||
|
b = (val & 0xff) / 255.0
|
||||||
|
g = ((val >> 8) & 0xff) / 255.0
|
||||||
|
r = ((val >> 16) & 0xff) / 255.0
|
||||||
|
return Color3(r, g, b)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_property(prop_el: ET.Element) -> Tuple[str, Any]:
|
||||||
|
"""Парсит один <тип name="имя">значение</тип>. Возвращает (name, value)."""
|
||||||
|
tag = prop_el.tag
|
||||||
|
name = prop_el.attrib.get('name', '')
|
||||||
|
|
||||||
|
if tag == 'string' or tag == 'ProtectedString' or tag == 'Content':
|
||||||
|
return name, _text(prop_el)
|
||||||
|
|
||||||
|
if tag == 'bool':
|
||||||
|
return name, _text(prop_el).lower() == 'true'
|
||||||
|
|
||||||
|
if tag in ('int', 'int64'):
|
||||||
|
val = _i(prop_el)
|
||||||
|
# В старом XML цвет хранится как <int name="BrickColor">21</int>,
|
||||||
|
# а converter ожидает BrickColor-объект с .code.
|
||||||
|
if name == 'BrickColor':
|
||||||
|
return name, BrickColor(code=val)
|
||||||
|
return name, val
|
||||||
|
|
||||||
|
if tag in ('float', 'double'):
|
||||||
|
return name, _f(prop_el)
|
||||||
|
|
||||||
|
if tag == 'token':
|
||||||
|
# token — int-значение enum
|
||||||
|
return name, EnumValue(value=_i(prop_el))
|
||||||
|
|
||||||
|
if tag == 'Vector3':
|
||||||
|
return name, _parse_vector3(prop_el)
|
||||||
|
|
||||||
|
if tag == 'Vector2':
|
||||||
|
return name, _parse_vector2(prop_el)
|
||||||
|
|
||||||
|
if tag == 'CoordinateFrame':
|
||||||
|
return name, _parse_cframe(prop_el)
|
||||||
|
|
||||||
|
if tag == 'Color3':
|
||||||
|
return name, _parse_color3(prop_el)
|
||||||
|
|
||||||
|
if tag == 'Color3uint8':
|
||||||
|
return name, _parse_color3uint8(prop_el)
|
||||||
|
|
||||||
|
if tag == 'BrickColor':
|
||||||
|
return name, BrickColor(code=_i(prop_el))
|
||||||
|
|
||||||
|
if tag == 'Ref':
|
||||||
|
# Ссылка на другой Item по referent (например "RBX42" или "null")
|
||||||
|
txt = _text(prop_el, 'null')
|
||||||
|
if txt in ('null', 'nil', ''):
|
||||||
|
return name, None
|
||||||
|
return name, txt # храним как строку-referent
|
||||||
|
|
||||||
|
if tag == 'BinaryString':
|
||||||
|
# base64 → bytes
|
||||||
|
try:
|
||||||
|
return name, base64.b64decode(_text(prop_el))
|
||||||
|
except Exception:
|
||||||
|
return name, b''
|
||||||
|
|
||||||
|
if tag == 'UDim':
|
||||||
|
scale = _f(prop_el.find('S'))
|
||||||
|
offset = _i(prop_el.find('O'))
|
||||||
|
return name, {'scale': scale, 'offset': offset}
|
||||||
|
|
||||||
|
if tag == 'UDim2':
|
||||||
|
xs = _f(prop_el.find('XS'))
|
||||||
|
xo = _i(prop_el.find('XO'))
|
||||||
|
ys = _f(prop_el.find('YS'))
|
||||||
|
yo = _i(prop_el.find('YO'))
|
||||||
|
return name, {'x_scale': xs, 'x_offset': xo, 'y_scale': ys, 'y_offset': yo}
|
||||||
|
|
||||||
|
if tag == 'Rect2D':
|
||||||
|
# min/max
|
||||||
|
min_el = prop_el.find('min')
|
||||||
|
max_el = prop_el.find('max')
|
||||||
|
return name, {
|
||||||
|
'min': _parse_vector2(min_el) if min_el is not None else Vector2(0, 0),
|
||||||
|
'max': _parse_vector2(max_el) if max_el is not None else Vector2(0, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag == 'OptionalCoordinateFrame':
|
||||||
|
cf_el = prop_el.find('CFrame')
|
||||||
|
return name, OptionalCFrame(cframe=_parse_cframe(cf_el)) if cf_el is not None else OptionalCFrame(cframe=None)
|
||||||
|
|
||||||
|
if tag == 'PhysicalProperties':
|
||||||
|
cust = prop_el.find('CustomPhysics')
|
||||||
|
custom = cust is not None and _text(cust).lower() == 'true'
|
||||||
|
return name, PhysicalProperties(
|
||||||
|
custom_physics=custom,
|
||||||
|
density=_f(prop_el.find('Density'), 0.7),
|
||||||
|
friction=_f(prop_el.find('Friction'), 0.3),
|
||||||
|
elasticity=_f(prop_el.find('Elasticity'), 0.5),
|
||||||
|
friction_weight=_f(prop_el.find('FrictionWeight'), 1.0),
|
||||||
|
elasticity_weight=_f(prop_el.find('ElasticityWeight'), 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
if tag == 'NumberRange':
|
||||||
|
return name, {'min': _f(prop_el.find('Min')), 'max': _f(prop_el.find('Max'))}
|
||||||
|
|
||||||
|
# SharedString / Uri / другие незнакомые — оставляем как текст
|
||||||
|
return name, _text(prop_el)
|
||||||
|
|
||||||
|
|
||||||
|
# Регекс для извлечения referent из строк типа "RBX42"
|
||||||
|
_REF_RE = re.compile(r'^RBX(\d+)$')
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_to_int(ref: Optional[str]) -> Optional[int]:
|
||||||
|
"""RBX42 → 42, null → None. Если уникальной номер не найден — None."""
|
||||||
|
if ref is None or ref == 'null':
|
||||||
|
return None
|
||||||
|
m = _REF_RE.match(str(ref))
|
||||||
|
if m:
|
||||||
|
return int(m.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_xml(blob: bytes) -> RobloxModel:
|
||||||
|
"""Главный entry: bytes → RobloxModel."""
|
||||||
|
try:
|
||||||
|
text = blob.decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
text = blob.decode('latin-1', errors='replace')
|
||||||
|
|
||||||
|
# XML может иметь BOM или leading whitespace
|
||||||
|
text = text.lstrip('').lstrip()
|
||||||
|
|
||||||
|
root = ET.fromstring(text)
|
||||||
|
|
||||||
|
instances: List[Instance] = []
|
||||||
|
by_referent: Dict[int, Instance] = {}
|
||||||
|
roots: List[Instance] = []
|
||||||
|
|
||||||
|
# Auto-increment id для Item'ов без referent (старые форматы)
|
||||||
|
next_id_counter = [100000]
|
||||||
|
|
||||||
|
def _walk(item_el: ET.Element, parent_ref: Optional[int]) -> None:
|
||||||
|
"""Рекурсивный обход <Item class="..."> элементов."""
|
||||||
|
cls = item_el.attrib.get('class', 'Unknown')
|
||||||
|
|
||||||
|
# Referent из атрибута (например referent="RBX42")
|
||||||
|
ref_attr = item_el.attrib.get('referent') or item_el.attrib.get('Referent')
|
||||||
|
ref_int = _ref_to_int(ref_attr) if ref_attr else None
|
||||||
|
if ref_int is None:
|
||||||
|
# Назначаем auto-id чтобы converter мог отслеживать parent_referent
|
||||||
|
ref_int = next_id_counter[0]
|
||||||
|
next_id_counter[0] += 1
|
||||||
|
|
||||||
|
# Парсим properties
|
||||||
|
props: Dict[str, Any] = {}
|
||||||
|
props_el = item_el.find('Properties')
|
||||||
|
if props_el is not None:
|
||||||
|
for prop_el in props_el:
|
||||||
|
try:
|
||||||
|
pname, pval = _parse_property(prop_el)
|
||||||
|
if pname:
|
||||||
|
props[pname] = pval
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Roblox в старых картах использовал имена с маленькой первой буквы:
|
||||||
|
# name → Name, size → Size, shape → Shape, и т.д. Converter ожидает
|
||||||
|
# PascalCase. Делаем алиасы (старое имя остаётся, новое добавляется).
|
||||||
|
_ALIAS_TO_PASCAL = {
|
||||||
|
'name': 'Name',
|
||||||
|
'size': 'Size',
|
||||||
|
'shape': 'Shape',
|
||||||
|
'archivable': 'Archivable',
|
||||||
|
'shape3d': 'Shape',
|
||||||
|
}
|
||||||
|
for old, new in _ALIAS_TO_PASCAL.items():
|
||||||
|
if old in props and new not in props:
|
||||||
|
props[new] = props[old]
|
||||||
|
|
||||||
|
# Convert Ref-properties (string "RBX42") в parent_referent если нужно
|
||||||
|
# — пока оставляем как строки.
|
||||||
|
|
||||||
|
inst = Instance(
|
||||||
|
referent=ref_int,
|
||||||
|
class_name=cls,
|
||||||
|
properties=props,
|
||||||
|
parent_referent=parent_ref,
|
||||||
|
children=[],
|
||||||
|
)
|
||||||
|
instances.append(inst)
|
||||||
|
by_referent[ref_int] = inst
|
||||||
|
if parent_ref is None:
|
||||||
|
roots.append(inst)
|
||||||
|
|
||||||
|
# Рекурсивно дочерние Item'ы
|
||||||
|
for child in item_el.findall('Item'):
|
||||||
|
_walk(child, ref_int)
|
||||||
|
|
||||||
|
# Roblox-XML: top-level <Item class="..."> идут под <roblox>
|
||||||
|
for item in root.findall('Item'):
|
||||||
|
_walk(item, None)
|
||||||
|
|
||||||
|
# Заполняем children после полного прохода (для удобства converter'а)
|
||||||
|
for inst in instances:
|
||||||
|
if inst.parent_referent is not None:
|
||||||
|
parent = by_referent.get(inst.parent_referent)
|
||||||
|
if parent is not None:
|
||||||
|
parent.children.append(inst)
|
||||||
|
|
||||||
|
# Версия из атрибута <roblox version="4">
|
||||||
|
version_attr = root.attrib.get('version', '4')
|
||||||
|
try:
|
||||||
|
version = int(version_attr)
|
||||||
|
except ValueError:
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
return RobloxModel(
|
||||||
|
version=version,
|
||||||
|
class_count=len(set(i.class_name for i in instances)),
|
||||||
|
instance_count=len(instances),
|
||||||
|
instances=instances,
|
||||||
|
by_referent=by_referent,
|
||||||
|
roots=roots,
|
||||||
|
shared_strings=[],
|
||||||
|
meta={},
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
@ -10,7 +10,7 @@ export const USER_addres = BASE + '/api-user';
|
|||||||
export const ACHIVES_addres = BASE + '/api-achievs';
|
export const ACHIVES_addres = BASE + '/api-achievs';
|
||||||
export const COMMENTS_addres = BASE + '/api-comments';
|
export const COMMENTS_addres = BASE + '/api-comments';
|
||||||
export const STORYS_addres = BASE + '/api-storys';
|
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 RBXL_addres = BASE + '/api-rbxl';
|
||||||
export const NOTICES_addres = BASE + '/api-notices';
|
export const NOTICES_addres = BASE + '/api-notices';
|
||||||
export const HELP_addres = BASE + '/api-help';
|
export const HELP_addres = BASE + '/api-help';
|
||||||
|
|||||||
@ -52,11 +52,20 @@ export async function analyzeRbxl(file) {
|
|||||||
/**
|
/**
|
||||||
* Создаёт проект из preview_hash. Возвращает { project_id, redirect, assets_downloaded, assets_failed }.
|
* Создаёт проект из 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`, {
|
const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
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) {
|
if (!resp.ok) {
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import { GAMES, GAME_GROUPS } from './docsGames';
|
|||||||
import { LESSONS, hasLesson } from './docsLessons';
|
import { LESSONS, hasLesson } from './docsLessons';
|
||||||
import { buildGameProject } from './docsGamesBuilders';
|
import { buildGameProject } from './docsGamesBuilders';
|
||||||
import DocIcon from './docsIcons';
|
import DocIcon from './docsIcons';
|
||||||
|
import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang';
|
||||||
|
import { LUA_OVERRIDES } from './docsGamesBuildersLua';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KubikonDocs — вика редактора Рублокс.
|
* KubikonDocs — вика редактора Рублокс.
|
||||||
@ -76,6 +78,7 @@ const KubikonDocs = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={cl.studio}>
|
<div className={cl.studio}>
|
||||||
<style>{INLINE_STYLES}</style>
|
<style>{INLINE_STYLES}</style>
|
||||||
|
<style>{DOCS_LANG_STYLES}</style>
|
||||||
|
|
||||||
{/* === Левая боковая панель === */}
|
{/* === Левая боковая панель === */}
|
||||||
<aside className={cl.sidebar}>
|
<aside className={cl.sidebar}>
|
||||||
@ -383,12 +386,15 @@ const ChapterPage = ({ chapter, mainRef }) => {
|
|||||||
|
|
||||||
{/* Контент раздела */}
|
{/* Контент раздела */}
|
||||||
<div className="docsContent">
|
<div className="docsContent">
|
||||||
{chapter.sections.map((s) => (
|
<DocsLangProvider>
|
||||||
<article key={s.id} id={`sec-${s.id}`} className="docsChapter">
|
<DocsLangPicker />
|
||||||
<h3 className="docsSectionTitle">{s.title}</h3>
|
{chapter.sections.map((s) => (
|
||||||
<div className="docsSectionBody">{s.body}</div>
|
<article key={s.id} id={`sec-${s.id}`} className="docsChapter">
|
||||||
</article>
|
<h3 className="docsSectionTitle">{s.title}</h3>
|
||||||
))}
|
<div className="docsSectionBody">{s.body}</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</DocsLangProvider>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@ -399,17 +405,20 @@ const ChapterPage = ({ chapter, mainRef }) => {
|
|||||||
// ══════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════
|
||||||
const LessonPage = ({ game, navigate }) => {
|
const LessonPage = ({ game, navigate }) => {
|
||||||
const lesson = LESSONS[game.id];
|
const lesson = LESSONS[game.id];
|
||||||
// 'idle' | 'creating' | 'error'
|
// 'idle' | 'choosing' | 'creating' | 'error'
|
||||||
const [state, setState] = useState('idle');
|
const [state, setState] = useState('idle');
|
||||||
|
|
||||||
// Создаёт НОВУЮ копию игры-урока на текущем пользователе и
|
// Шаг 1: юзер нажал «Открыть копию» → показываем модалку выбора языка.
|
||||||
// открывает её в редакторе. Оригинал при этом ВСЕГДА цел.
|
const openInEditor = () => {
|
||||||
const openInEditor = async () => {
|
|
||||||
const userId = getCurrentUserId();
|
const userId = getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) { setState('error'); return; }
|
||||||
setState('error');
|
setState('choosing');
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
// Шаг 2: язык выбран → создаём копию с нужными скриптами и открываем.
|
||||||
|
const createCopyWithLang = async (lang) => {
|
||||||
|
const userId = getCurrentUserId();
|
||||||
|
if (!userId) { setState('error'); return; }
|
||||||
setState('creating');
|
setState('creating');
|
||||||
try {
|
try {
|
||||||
// project_data копии берём двумя способами:
|
// project_data копии берём двумя способами:
|
||||||
@ -422,9 +431,11 @@ const LessonPage = ({ game, navigate }) => {
|
|||||||
const pd = orig && orig.data && orig.data.project_data;
|
const pd = orig && orig.data && orig.data.project_data;
|
||||||
if (!pd) { setState('error'); return; }
|
if (!pd) { setState('error'); return; }
|
||||||
// project_data может прийти строкой или объектом — нормализуем в строку.
|
// 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 {
|
} else {
|
||||||
const project = buildGameProject(game.id);
|
const project = buildGameProject(game.id, { lang });
|
||||||
if (!project) { setState('error'); return; }
|
if (!project) { setState('error'); return; }
|
||||||
projectDataStr = JSON.stringify(project);
|
projectDataStr = JSON.stringify(project);
|
||||||
}
|
}
|
||||||
@ -477,6 +488,12 @@ const LessonPage = ({ game, navigate }) => {
|
|||||||
: <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>}
|
: <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{state === 'choosing' && (
|
||||||
|
<LangChoiceModal
|
||||||
|
onPick={(lang) => createCopyWithLang(lang)}
|
||||||
|
onCancel={() => setState('idle')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{state === 'error' && (
|
{state === 'error' && (
|
||||||
<div className="lessonErr">
|
<div className="lessonErr">
|
||||||
Не получилось открыть игру. Проверь, что ты вошёл в аккаунт,
|
Не получилось открыть игру. Проверь, что ты вошёл в аккаунт,
|
||||||
@ -484,14 +501,134 @@ const LessonPage = ({ game, navigate }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Тело урока */}
|
{/* Тело урока с переключателем JS/Lua */}
|
||||||
<article className="docsChapter lessonBody">
|
<article className="docsChapter lessonBody">
|
||||||
<div className="docsSectionBody">{lesson.body}</div>
|
<DocsLangProvider>
|
||||||
|
<DocsLangPicker />
|
||||||
|
<LuaLessonBanner gameId={game.id} />
|
||||||
|
<div className="docsSectionBody">{lesson.body}</div>
|
||||||
|
</DocsLangProvider>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</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.
|
||||||
|
* Полноценная транспиляция JS→Lua невозможна без 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 b { color: #0f172a; font-weight: 800; }
|
||||||
.docsSectionBody h4 { font-family: inherit; }
|
.docsSectionBody h4 { font-family: inherit; }
|
||||||
.docsSectionBody code {
|
.docsSectionBody code {
|
||||||
background: #e0e8ff;
|
background: #fff5e0;
|
||||||
color: #3357ff;
|
color: #b14400;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: Consolas, Menlo, "Courier New", monospace;
|
font-family: Consolas, Menlo, "Courier New", monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
border: 1px solid #f5d8a8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* kbd */
|
/* kbd */
|
||||||
@ -770,6 +908,7 @@ const INLINE_STYLES = `
|
|||||||
.docCode code {
|
.docCode code {
|
||||||
background: none; color: inherit; padding: 0;
|
background: none; color: inherit; padding: 0;
|
||||||
font-weight: 500; font-size: 13px; white-space: pre;
|
font-weight: 500; font-size: 13px; white-space: pre;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Скриншот интерфейса с подписью.
|
/* Скриншот интерфейса с подписью.
|
||||||
|
|||||||
@ -390,18 +390,16 @@ const KubikonStudio = () => {
|
|||||||
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
|
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
|
||||||
<span>ВИКИ</span>
|
<span>ВИКИ</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */}
|
{/* Импорт Roblox .rbxl — доступно всем */}
|
||||||
{getCurrentUserId() === 1 && (
|
<button
|
||||||
<button
|
className={cl.navItem}
|
||||||
className={cl.navItem}
|
onClick={() => setRbxlImportOpen(true)}
|
||||||
onClick={() => setRbxlImportOpen(true)}
|
title="Импортировать игру из Roblox (.rbxl файл)"
|
||||||
title="Импортировать игру из Roblox (.rbxl файл) — тест-фича"
|
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
|
||||||
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
|
>
|
||||||
>
|
<span className={cl.navIcon}>📦</span>
|
||||||
<span className={cl.navIcon}>📦</span>
|
<span>Импорт Roblox</span>
|
||||||
<span>Импорт Roblox</span>
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<RbxlImportModal
|
<RbxlImportModal
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -748,7 +748,9 @@ function game6ColorTiles() {
|
|||||||
id,
|
id,
|
||||||
type: 'cube',
|
type: 'cube',
|
||||||
name: 'Плитка_' + id,
|
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,
|
sx: 1.8, sy: 0.3, sz: 1.8,
|
||||||
color: '#9aa0aa', // серый — не раскрашена
|
color: '#9aa0aa', // серый — не раскрашена
|
||||||
material: 'matte',
|
material: 'matte',
|
||||||
@ -6158,8 +6160,143 @@ export function hasGameBuilder(id) {
|
|||||||
return typeof GAME_BUILDERS[id] === 'function';
|
return typeof GAME_BUILDERS[id] === 'function';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Построить project_data для игры-урока. Возвращает объект или null. */
|
// ══════════════════════════════════════════════════════════════════
|
||||||
export function buildGameProject(id) {
|
// LUA_OVERRIDES — реестр Lua-версий скриптов для уроков.
|
||||||
const fn = GAME_BUILDERS[id];
|
// Структура: { gameId: { scriptId: 'lua code' | (script) => 'lua code' } }
|
||||||
return fn ? fn() : null;
|
// Если скрипт описан здесь — при 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;
|
||||||
}
|
}
|
||||||
|
|||||||
4779
src/community/docsGamesBuildersLua.js
Normal file
4779
src/community/docsGamesBuildersLua.js
Normal file
File diff suppressed because it is too large
Load Diff
463
src/community/docsLang.jsx
Normal file
463
src/community/docsLang.jsx
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает 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
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* RbxlImportModal — модалка импорта .rbxl Roblox-карт в Rublox.
|
* RbxlImportModal — модалка импорта .rbxl Roblox-карт в Rublox.
|
||||||
*
|
*
|
||||||
* Доступна ТОЛЬКО МИНу (user_id === 1) — это тест-фича.
|
* Доступна всем пользователям (см. вики «Импорт из Roblox» о нюансах).
|
||||||
*
|
*
|
||||||
* Поток:
|
* Поток:
|
||||||
* 1. Юзер дропает или выбирает .rbxl файл.
|
* 1. Юзер дропает или выбирает .rbxl файл.
|
||||||
@ -13,8 +13,6 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js';
|
import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js';
|
||||||
|
|
||||||
const ALLOWED_USER_ID = 1; // МИН
|
|
||||||
|
|
||||||
const MAX_SIZE = 50 * 1024 * 1024; // 50 MB
|
const MAX_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
|
|
||||||
export default function RbxlImportModal({ open, onClose, currentUserId, onCreated }) {
|
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 [previewHash, setPreviewHash] = useState(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [error, setError] = useState(null);
|
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);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
if (!open) return 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 = () => {
|
const reset = () => {
|
||||||
setFile(null); setReport(null); setPreviewHash(null);
|
setFile(null); setReport(null); setPreviewHash(null);
|
||||||
setTitle(''); setError(null); setAnalyzing(false); setCreating(false);
|
setTitle(''); setError(null); setAnalyzing(false); setCreating(false);
|
||||||
|
setScriptsMode('disabled');
|
||||||
|
setGuiMode('all');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => { reset(); onClose?.(); };
|
const handleClose = () => { reset(); onClose?.(); };
|
||||||
@ -88,7 +83,7 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
|
|||||||
setCreating(true);
|
setCreating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await createRbxlProject(previewHash, title);
|
const result = await createRbxlProject(previewHash, title, { scriptsMode, guiMode });
|
||||||
onCreated?.(result);
|
onCreated?.(result);
|
||||||
handleClose();
|
handleClose();
|
||||||
// редирект на редактор
|
// редирект на редактор
|
||||||
@ -175,6 +170,29 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 < 1.'
|
||||||
|
: ' тормозить (FPS 10-30).'}
|
||||||
|
{' '}Рекомендуем выбрать ниже «Не импортировать скрипты»
|
||||||
|
чтобы хоть посмотреть геометрию.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{report.top_classes?.length > 0 && (
|
{report.top_classes?.length > 0 && (
|
||||||
<details style={{ marginTop: 12 }}>
|
<details style={{ marginTop: 12 }}>
|
||||||
<summary style={{ cursor: 'pointer' }}>Что внутри (топ-25 классов)</summary>
|
<summary style={{ cursor: 'pointer' }}>Что внутри (топ-25 классов)</summary>
|
||||||
@ -206,6 +224,98 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
|
|||||||
</div>
|
</div>
|
||||||
</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 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label style={{ display: 'block', marginBottom: 6 }}>Название игры:</label>
|
<label style={{ display: 'block', marginBottom: 6 }}>Название игры:</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
197
src/editor/ConfirmModal.jsx
Normal file
197
src/editor/ConfirmModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -37,7 +37,7 @@ const renderRowIcon = (val) => {
|
|||||||
const ItemRow = ({
|
const ItemRow = ({
|
||||||
icon, label, title, depth = 0, selected, plusItems,
|
icon, label, title, depth = 0, selected, plusItems,
|
||||||
onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
|
onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
|
||||||
extraStyle, selId,
|
extraStyle, selId, badge,
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const rowRef = React.useRef(null);
|
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={{ 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>
|
<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 && (
|
{plusItems && plusItems.length > 0 && (
|
||||||
<HoverPlusMenu visible={hovered} items={plusItems} />
|
<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 ScriptRow = ({ script, depth, selected, onSelect, onDelete, onRename, onContextMenu, onStartRename }) => {
|
||||||
const displayName = script.name || (script.id === 'demo' ? 'Демо-скрипт' : script.id);
|
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 (
|
return (
|
||||||
<ItemRow
|
<ItemRow
|
||||||
icon="📜"
|
icon="📜"
|
||||||
label={displayName}
|
label={displayName}
|
||||||
title={`${displayName} (id: ${script.id})`}
|
title={`${displayName} (id: ${script.id}, язык: ${isLua ? 'Lua' : 'JavaScript'})`}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
badge={badge}
|
||||||
plusItems={[
|
plusItems={[
|
||||||
{
|
{
|
||||||
id: 'rename', label: 'Переименовать', icon: '✏️',
|
id: 'rename', label: 'Переименовать', icon: '✏️',
|
||||||
|
|||||||
@ -526,11 +526,73 @@ const InspectorPanel = ({
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</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.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 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 4 }}>
|
||||||
<Icon name="sparkle" size={11} /> Цвет окружающего света подбирается автоматически по времени суток.
|
<Icon name="sparkle" size={11} /> Цвет окружающего света подбирается автоматически по времени суток.
|
||||||
</div>
|
</div>
|
||||||
</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 }}>
|
||||||
|
Общая яркость. <1 = темнее, >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.section}>
|
||||||
<div className={cl.sectionTitle}><Icon name="fog" size={12} /> Туман</div>
|
<div className={cl.sectionTitle}><Icon name="fog" size={12} /> Туман</div>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import BillboardEditorModal from './BillboardEditorModal';
|
|||||||
import TerrainGenPanel from './TerrainGenPanel';
|
import TerrainGenPanel from './TerrainGenPanel';
|
||||||
import ScriptConsole from './ScriptConsole';
|
import ScriptConsole from './ScriptConsole';
|
||||||
import SceneTabs from './SceneTabs';
|
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 GameHud from './GameHud';
|
||||||
import MinimapOverlay from './MinimapOverlay';
|
import MinimapOverlay from './MinimapOverlay';
|
||||||
import GuiOverlay from './GuiOverlay';
|
import GuiOverlay from './GuiOverlay';
|
||||||
@ -43,6 +43,7 @@ import KubikonDesktopOnlyStub from '../community/KubikonDesktopOnlyStub';
|
|||||||
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
|
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
|
||||||
import cl from './KubikonEditor.module.css';
|
import cl from './KubikonEditor.module.css';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины → авто-сохранение
|
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины → авто-сохранение
|
||||||
|
|
||||||
@ -512,6 +513,8 @@ const KubikonEditor = () => {
|
|||||||
// BillboardEditorModal — открывается из инспектора при клике
|
// BillboardEditorModal — открывается из инспектора при клике
|
||||||
// «Редактировать табличку…». Содержит primitiveData выделенного билборда.
|
// «Редактировать табличку…». Содержит primitiveData выделенного билборда.
|
||||||
const [billboardEditorData, setBillboardEditorData] = useState(null);
|
const [billboardEditorData, setBillboardEditorData] = useState(null);
|
||||||
|
// ConfirmModal — кастомная модалка вместо window.confirm.
|
||||||
|
const [confirmState, setConfirmState] = useState(null);
|
||||||
// Bumper для обновления списков в Toolbox после edit/settings/delete.
|
// Bumper для обновления списков в Toolbox после edit/settings/delete.
|
||||||
const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0);
|
const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0);
|
||||||
// Bump-счётчик: инкрементируется при создании/очистке гладкого
|
// Bump-счётчик: инкрементируется при создании/очистке гладкого
|
||||||
@ -2043,13 +2046,19 @@ const KubikonEditor = () => {
|
|||||||
// Флаш ScriptEditor — без этого 600мс свежих правок не успеют
|
// Флаш ScriptEditor — без этого 600мс свежих правок не успеют
|
||||||
// попасть в _scripts[]/dirtyRef и confirm-диалог не покажется.
|
// попасть в _scripts[]/dirtyRef и confirm-диалог не покажется.
|
||||||
try { scriptEditorFlushRef.current?.(); } catch (_) {}
|
try { scriptEditorFlushRef.current?.(); } catch (_) {}
|
||||||
// Несохранённые изменения — спрашиваем
|
// Несохранённые изменения — кастомная модалка с 3 кнопками:
|
||||||
|
// Сохранить (по умолчанию), Не сохранять, Отмена.
|
||||||
if (dirtyRef.current) {
|
if (dirtyRef.current) {
|
||||||
const ok = window.confirm('Есть несохранённые изменения. Сохранить перед выходом?');
|
setConfirmState({
|
||||||
if (ok) {
|
title: 'Несохранённые изменения',
|
||||||
doSave().finally(() => navigate('/'));
|
message: 'Сохранить проект перед выходом? Если выйти без сохранения — последние правки пропадут.',
|
||||||
return;
|
confirmLabel: 'Сохранить и выйти',
|
||||||
}
|
cancelLabel: 'Выйти без сохранения',
|
||||||
|
confirmTone: 'primary',
|
||||||
|
onConfirm: () => doSave().finally(() => navigate('/')),
|
||||||
|
onCancel: () => navigate('/'), // выйти без сохранения
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
navigate('/');
|
navigate('/');
|
||||||
};
|
};
|
||||||
@ -3324,10 +3333,43 @@ const KubikonEditor = () => {
|
|||||||
scriptId={sc.id}
|
scriptId={sc.id}
|
||||||
value={sc.code}
|
value={sc.code}
|
||||||
target={sc.target}
|
target={sc.target}
|
||||||
|
language={sc.language || 'js'}
|
||||||
flushRef={scriptEditorFlushRef}
|
flushRef={scriptEditorFlushRef}
|
||||||
isSoloRunning={soloScriptId === sc.id}
|
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) => {
|
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?.() || []);
|
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||||||
markDirty();
|
markDirty();
|
||||||
}}
|
}}
|
||||||
@ -4187,6 +4229,13 @@ const KubikonEditor = () => {
|
|||||||
setBillboardEditorData(null);
|
setBillboardEditorData(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Кастомная модалка подтверждения вместо window.confirm. */}
|
||||||
|
{confirmState && (
|
||||||
|
<ConfirmModal
|
||||||
|
{...confirmState}
|
||||||
|
onClose={() => setConfirmState(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import Icon from './Icon';
|
|||||||
// при правке одного файла не перетряхивать все остальные.
|
// при правке одного файла не перетряхивать все остальные.
|
||||||
import { GAME_TYPE_LIBS } from './engine/types/bundle';
|
import { GAME_TYPE_LIBS } from './engine/types/bundle';
|
||||||
import { registerSnippets } from './engine/snippets';
|
import { registerSnippets } from './engine/snippets';
|
||||||
|
import { registerLuaInMonaco } from './lua-monaco-setup';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ScriptEditor — Monaco-редактор кода скрипта в табе.
|
* ScriptEditor — Monaco-редактор кода скрипта в табе.
|
||||||
@ -34,7 +36,50 @@ import { registerSnippets } from './engine/snippets';
|
|||||||
// Если нужен какой-то метод, которого нет в автокомплите — добавляйте его
|
// Если нужен какой-то метод, которого нет в автокомплите — добавляйте его
|
||||||
// в соответствующий .d.ts (player.d.ts / scene.d.ts / ...) и пересобирайте
|
// в соответствующий .d.ts (player.d.ts / scene.d.ts / ...) и пересобирайте
|
||||||
// командой `python _build_bundle.py` в той же папке.
|
// командой `python _build_bundle.py` в той же папке.
|
||||||
function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, onClose, flushRef }) {
|
// Дефолтный шаблон Lua-скрипта для нового скрипта (на Part или глобальный).
|
||||||
|
// Используется при смене языка JS→Lua когда текущий код выглядит «пустым».
|
||||||
|
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.
|
// Синхронизируется с external value только при смене scriptId.
|
||||||
const [localCode, setLocalCode] = useState(value || '');
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [value]);
|
}, [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) => {
|
const scheduleSave = useCallback((code) => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
@ -162,6 +216,9 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
|
|||||||
// Сниппеты для быстрого старта (door/coin/portal/npc/quest/save и т.д.).
|
// Сниппеты для быстрого старта (door/coin/portal/npc/quest/save и т.д.).
|
||||||
// Регистрируются один раз для всего Monaco, флаг хранится в monaco.__kubikonSnippetsRegistered.
|
// Регистрируются один раз для всего Monaco, флаг хранится в monaco.__kubikonSnippetsRegistered.
|
||||||
registerSnippets(monaco);
|
registerSnippets(monaco);
|
||||||
|
// Lua: completionProvider (Vector3.new/Color3.fromRGB/script.Parent/...)
|
||||||
|
// + hoverProvider (документация при наведении)
|
||||||
|
registerLuaInMonaco(monaco);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[ScriptEditor] Monaco setup error', e);
|
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)',
|
border: '1px solid rgba(79, 116, 255, 0.35)',
|
||||||
}}>{targetLabel}</span>
|
}}>{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' }}>
|
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
{/* Фаза 6.1.4: кнопка «Проверить» — включает семантический анализ TS
|
{/* Фаза 6.1.4: кнопка «Проверить» — включает семантический анализ TS
|
||||||
на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.).
|
на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.).
|
||||||
@ -394,10 +499,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
|
|||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
<Editor
|
<Editor
|
||||||
height="100%"
|
height="100%"
|
||||||
defaultLanguage="javascript"
|
defaultLanguage={currentLanguage === 'lua' ? 'lua' : 'javascript'}
|
||||||
|
language={currentLanguage === 'lua' ? 'lua' : 'javascript'}
|
||||||
theme="vs-dark"
|
theme="vs-dark"
|
||||||
value={localCode}
|
value={localCode}
|
||||||
path={`script_${scriptId}.js`}
|
path={`script_${scriptId}.${currentLanguage === 'lua' ? 'lua' : 'js'}`}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
beforeMount={handleEditorWillMount}
|
beforeMount={handleEditorWillMount}
|
||||||
onMount={handleEditorMount}
|
onMount={handleEditorMount}
|
||||||
@ -434,6 +540,12 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{confirmState && (
|
||||||
|
<ConfirmModal
|
||||||
|
{...confirmState}
|
||||||
|
onClose={() => setConfirmState(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import {
|
|||||||
Ray,
|
Ray,
|
||||||
PointerEventTypes,
|
PointerEventTypes,
|
||||||
Tools as BabylonTools,
|
Tools as BabylonTools,
|
||||||
|
ColorCurves,
|
||||||
} from '@babylonjs/core';
|
} from '@babylonjs/core';
|
||||||
import { PlacementManager } from './PlacementManager';
|
import { PlacementManager } from './PlacementManager';
|
||||||
import { ShopInventoryUi } from './ShopInventoryUi';
|
import { ShopInventoryUi } from './ShopInventoryUi';
|
||||||
@ -1885,9 +1886,41 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
if (typeof patch.sunIntensity === 'number' && this._sunLight) {
|
if (typeof patch.sunIntensity === 'number' && this._sunLight) {
|
||||||
this._sunLight.intensity = Math.max(0, patch.sunIntensity);
|
this._sunLight.intensity = Math.max(0, patch.sunIntensity);
|
||||||
|
this._sunIntensity = patch.sunIntensity;
|
||||||
}
|
}
|
||||||
if (typeof patch.hemiIntensity === 'number' && this._hemiLight) {
|
if (typeof patch.hemiIntensity === 'number' && this._hemiLight) {
|
||||||
this._hemiLight.intensity = Math.max(0, patch.hemiIntensity);
|
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') {
|
if (this.environment && typeof this.environment.setFog === 'function') {
|
||||||
// Текущие значения берём из Environment, поверх накладываем patch
|
// Текущие значения берём из Environment, поверх накладываем patch
|
||||||
@ -3002,6 +3035,7 @@ export class BabylonScene {
|
|||||||
if (md.isBlock) {
|
if (md.isBlock) {
|
||||||
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
|
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.isModel) return { kind: 'model', id: md.instanceId };
|
||||||
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
|
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
|
||||||
return null;
|
return null;
|
||||||
@ -3036,24 +3070,36 @@ export class BabylonScene {
|
|||||||
const EPS = 0.25;
|
const EPS = 0.25;
|
||||||
|
|
||||||
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
|
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
|
||||||
|
let _firedThisFrame = 0;
|
||||||
for (const s of scripts) {
|
for (const s of scripts) {
|
||||||
if (!s.target) continue;
|
if (!s.target) continue;
|
||||||
const key = 's:' + s.id;
|
try {
|
||||||
seen.add(key);
|
const key = 's:' + s.id;
|
||||||
const aabb = this._targetAABB(s.target);
|
seen.add(key);
|
||||||
if (!aabb) continue;
|
const aabb = this._targetAABB(s.target);
|
||||||
const overlap =
|
if (!aabb) continue;
|
||||||
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
|
const overlap =
|
||||||
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
|
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
|
||||||
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
|
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
|
||||||
const wasTouching = this._touchState.get(key);
|
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
|
||||||
if (overlap && !wasTouching) {
|
const wasTouching = this._touchState.get(key);
|
||||||
this._touchState.set(key, true);
|
if (overlap && !wasTouching) {
|
||||||
rt.routeEvent(s.target, 'touch', {});
|
this._touchState.set(key, true);
|
||||||
rt.routeGlobalEvent('playerTouch', { target: s.target });
|
rt.routeEvent(s.target, 'touch', {});
|
||||||
} else if (!overlap && wasTouching) {
|
rt.routeGlobalEvent('playerTouch', { target: s.target });
|
||||||
this._touchState.set(key, false);
|
_firedThisFrame++;
|
||||||
rt.routeEvent(s.target, 'untouch', {});
|
if (_firedThisFrame === 1) {
|
||||||
|
console.warn(`[Touch FIRE] scriptId=${s.id} target=${s.target} pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
} else if (!overlap && wasTouching) {
|
||||||
|
this._touchState.set(key, false);
|
||||||
|
rt.routeEvent(s.target, 'untouch', {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!this._touchDetectErrored) {
|
||||||
|
this._touchDetectErrored = true;
|
||||||
|
console.error('[TouchDetect] error', e, 'on script', s);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3161,6 +3207,17 @@ export class BabylonScene {
|
|||||||
_targetAABB(target) {
|
_targetAABB(target) {
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
try {
|
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') {
|
if (target.kind === 'block') {
|
||||||
const r = target.ref || target;
|
const r = target.ref || target;
|
||||||
return {
|
return {
|
||||||
@ -3215,7 +3272,30 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.gameRuntime) return;
|
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 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;
|
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
|
||||||
// 1) Self-onClick — только если target есть
|
// 1) Self-onClick — только если target есть
|
||||||
@ -5364,6 +5444,7 @@ export class BabylonScene {
|
|||||||
code: s.code,
|
code: s.code,
|
||||||
name: s.name || null,
|
name: s.name || null,
|
||||||
target: newTarget,
|
target: newTarget,
|
||||||
|
language: s.language || 'js',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (srcScripts.length > 0) {
|
if (srcScripts.length > 0) {
|
||||||
@ -5506,7 +5587,7 @@ export class BabylonScene {
|
|||||||
};
|
};
|
||||||
clip.scripts = (this._scripts || [])
|
clip.scripts = (this._scripts || [])
|
||||||
.filter(s => matchTarget(s.target))
|
.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 = []; }
|
} catch (e) { clip.scripts = []; }
|
||||||
try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); }
|
try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); }
|
||||||
catch (e) { /* ignore — приватный режим / переполнение */ }
|
catch (e) { /* ignore — приватный режим / переполнение */ }
|
||||||
@ -5521,7 +5602,7 @@ export class BabylonScene {
|
|||||||
const target = kind === 'block'
|
const target = kind === 'block'
|
||||||
? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
|
? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
|
||||||
: { kind, id: dstRef };
|
: { 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();
|
this.history?.markChange();
|
||||||
if (this._onSceneChange) this._onSceneChange();
|
if (this._onSceneChange) this._onSceneChange();
|
||||||
@ -6677,7 +6758,7 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Установить код одного скрипта по id. Если id нет — создать новый. */
|
/** Установить код одного скрипта по 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);
|
const i = this._scripts.findIndex(s => s.id === id);
|
||||||
if (i >= 0) {
|
if (i >= 0) {
|
||||||
this._scripts[i] = {
|
this._scripts[i] = {
|
||||||
@ -6685,6 +6766,11 @@ export class BabylonScene {
|
|||||||
code,
|
code,
|
||||||
...(target !== undefined ? { target } : {}),
|
...(target !== undefined ? { target } : {}),
|
||||||
...(name !== undefined ? { name } : {}),
|
...(name !== undefined ? { name } : {}),
|
||||||
|
...(language !== undefined ? { language } : {}),
|
||||||
|
// Слоты code_js и code_lua — сохраняемый код для каждого языка.
|
||||||
|
// Передаются при переключении языка, чтобы код другого языка
|
||||||
|
// не пропадал.
|
||||||
|
...(slots && typeof slots === 'object' ? slots : {}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this._scripts.push({
|
this._scripts.push({
|
||||||
@ -6692,6 +6778,8 @@ export class BabylonScene {
|
|||||||
code,
|
code,
|
||||||
target: target !== undefined ? target : null,
|
target: target !== undefined ? target : null,
|
||||||
name: name || null,
|
name: name || null,
|
||||||
|
language: language || 'js',
|
||||||
|
...(slots && typeof slots === 'object' ? slots : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
|
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
|
||||||
@ -7717,6 +7805,15 @@ export class BabylonScene {
|
|||||||
crosshair: this._crosshair || 'dot',
|
crosshair: this._crosshair || 'dot',
|
||||||
shadowQuality: this._shadowQuality || 'soft',
|
shadowQuality: this._shadowQuality || 'soft',
|
||||||
environment: this.environment ? this.environment.serialize() : null,
|
environment: this.environment ? this.environment.serialize() : null,
|
||||||
|
// Кастомные настройки света — слайдеры из «Свет и атмосфера»
|
||||||
|
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,
|
skybox: this.skybox ? this.skybox.serialize() : null,
|
||||||
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
|
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
|
||||||
achievements: this.achievements ? this.achievements.serialize() : null,
|
achievements: this.achievements ? this.achievements.serialize() : null,
|
||||||
@ -7736,6 +7833,7 @@ export class BabylonScene {
|
|||||||
code: s.code,
|
code: s.code,
|
||||||
target: s.target || null,
|
target: s.target || null,
|
||||||
name: s.name || null,
|
name: s.name || null,
|
||||||
|
language: s.language === 'lua' ? 'lua' : 'js',
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
editorCamera: this.camera ? {
|
editorCamera: this.camera ? {
|
||||||
@ -8193,12 +8291,19 @@ export class BabylonScene {
|
|||||||
code: s.code,
|
code: s.code,
|
||||||
target: s.target || null,
|
target: s.target || null,
|
||||||
name: s.name || null,
|
name: s.name || null,
|
||||||
|
language: s.language === 'lua' ? 'lua' : 'js',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// Окружение (время суток, скайбокс, туман)
|
// Окружение (время суток, скайбокс, туман)
|
||||||
if (state.scene.environment && this.environment) {
|
if (state.scene.environment && this.environment) {
|
||||||
this.environment.load(state.scene.environment);
|
this.environment.load(state.scene.environment);
|
||||||
}
|
}
|
||||||
|
// Кастомные настройки света/цветокоррекции — применяем через
|
||||||
|
// setLightingProps (он сам подхватит default-ы если значения нет).
|
||||||
|
if (state.scene.lighting) {
|
||||||
|
try { this.setLightingProps(state.scene.lighting); }
|
||||||
|
catch (e) { console.warn('[BabylonScene] lighting load failed:', e); }
|
||||||
|
}
|
||||||
// Кастомное небо (задача 16)
|
// Кастомное небо (задача 16)
|
||||||
if (state.scene.skybox && this.skybox) {
|
if (state.scene.skybox && this.skybox) {
|
||||||
this.skybox.load(state.scene.skybox);
|
this.skybox.load(state.scene.skybox);
|
||||||
|
|||||||
@ -19,7 +19,9 @@ import { ScriptSandbox } from './ScriptSandbox';
|
|||||||
import { STORYS_addres } from '../../api/API';
|
import { STORYS_addres } from '../../api/API';
|
||||||
import { PhysicsWorld } from './PhysicsWorld';
|
import { PhysicsWorld } from './PhysicsWorld';
|
||||||
import { LabelManager } from './LabelManager';
|
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 {
|
export class GameRuntime {
|
||||||
constructor(scene3d) {
|
constructor(scene3d) {
|
||||||
@ -115,11 +117,70 @@ export class GameRuntime {
|
|||||||
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
|
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
|
||||||
// Roblox-Lua скрипты собираем для single-VM режима: один shared Worker
|
// Roblox-Lua скрипты собираем для single-VM режима: один shared Worker
|
||||||
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
|
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
|
||||||
const rbxlBatch = [];
|
|
||||||
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
|
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) {
|
for (const s of scripts) {
|
||||||
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
|
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
|
||||||
@ -151,25 +212,157 @@ export class GameRuntime {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('[GameRuntime] sandbox started for script id=', s.id);
|
console.log('[GameRuntime] sandbox started for script id=', s.id);
|
||||||
}
|
}
|
||||||
// Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом.
|
// Импортированные .rbxl-скрипты теперь идут через тот же LuaSharedSandbox
|
||||||
let rbxlCount = 0;
|
// вместе с user-Lua (см. luaUserBatch выше). Отдельный Worker больше не нужен.
|
||||||
if (rbxlBatch.length > 0) {
|
let luaUserCount = 0;
|
||||||
// GUI-дерево из projectData для pre-population
|
if (luaUserBatch.length > 0) {
|
||||||
const guiElements = this.projectData?.scene?.gui || [];
|
try {
|
||||||
const result = startRobloxLuaShared(rbxlBatch, {
|
const sb = new LuaSharedSandbox();
|
||||||
primitives,
|
// partSet/sceneCreate — переиспользуем обработчик rbxl
|
||||||
guiElements,
|
sb.setOnCommand(({ cmd, payload }) => {
|
||||||
onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this),
|
if (cmd === 'partSet' || cmd === 'partVel' ||
|
||||||
});
|
cmd === 'sceneCreate' || cmd === 'sceneDelete') {
|
||||||
if (result && result.sandbox) {
|
try {
|
||||||
this.sandboxes.push(result.sandbox);
|
handleLuaCommand(null, cmd, payload, this);
|
||||||
this._rbxlSharedSandbox = result.sandbox;
|
} catch (e) {
|
||||||
rbxlCount = result.count;
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Передаём 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)}`);
|
const rbxlImported = luaUserBatch.filter(s => s._rbxlImported).length;
|
||||||
if (rbxlCount > 0) {
|
const luaWritten = luaUserCount - rbxlImported;
|
||||||
this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`);
|
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'
|
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
|
||||||
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
||||||
@ -467,6 +660,146 @@ export class GameRuntime {
|
|||||||
return null;
|
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() {
|
stop() {
|
||||||
if (this.sandboxes.length > 0) {
|
if (this.sandboxes.length > 0) {
|
||||||
this._log('info', 'Остановка скриптов');
|
this._log('info', 'Остановка скриптов');
|
||||||
@ -474,6 +807,14 @@ export class GameRuntime {
|
|||||||
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
|
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
|
||||||
for (const sb of this.sandboxes) sb.stop();
|
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 они остаются на сцене
|
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
|
||||||
// и накапливаются при повторных запусках.
|
// и накапливаются при повторных запусках.
|
||||||
@ -621,7 +962,61 @@ export class GameRuntime {
|
|||||||
this._syncPhysicsToScene();
|
this._syncPhysicsToScene();
|
||||||
}
|
}
|
||||||
const state = this._collectState();
|
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) {
|
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
|
// Для скриптов с target — добавляем актуальную позицию self
|
||||||
const stateForSb = sb.target
|
const stateForSb = sb.target
|
||||||
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
|
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
|
||||||
@ -1118,7 +1513,8 @@ export class GameRuntime {
|
|||||||
const nid = this._resolveNpcId(ref);
|
const nid = this._resolveNpcId(ref);
|
||||||
if (nid != null) { fn(nid); return; }
|
if (nid != null) { fn(nid); return; }
|
||||||
// ещё не резолвится — откладываем (только для локальных ref NPC)
|
// ещё не резолвится — откладываем (только для локальных 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) this._pendingNpcCmds = new Map();
|
||||||
if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []);
|
if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []);
|
||||||
this._pendingNpcCmds.get(ref).push(fn);
|
this._pendingNpcCmds.get(ref).push(fn);
|
||||||
@ -1183,6 +1579,32 @@ export class GameRuntime {
|
|||||||
const d = tryGet(this.scene3d?.modelManager);
|
const d = tryGet(this.scene3d?.modelManager);
|
||||||
if (d) return { kind: 'model', data: d };
|
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);
|
const um = tryGet(this.scene3d?.userModelManager);
|
||||||
if (um) return { kind: 'userModel', data: um };
|
if (um) return { kind: 'userModel', data: um };
|
||||||
return null;
|
return null;
|
||||||
@ -1288,6 +1710,17 @@ export class GameRuntime {
|
|||||||
routeEvent(target, eventType, extra = {}) {
|
routeEvent(target, eventType, extra = {}) {
|
||||||
if (!target || !eventType) return;
|
if (!target || !eventType) return;
|
||||||
for (const sb of this.sandboxes) {
|
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 (!sb.target) continue;
|
||||||
if (!this._targetMatches(sb.target, target)) continue;
|
if (!this._targetMatches(sb.target, target)) continue;
|
||||||
sb.sendEvent({ type: eventType, ...extra });
|
sb.sendEvent({ type: eventType, ...extra });
|
||||||
@ -1739,6 +2172,13 @@ export class GameRuntime {
|
|||||||
// после spawnNpc (follow/moveTo/say) — они ждали
|
// после spawnNpc (follow/moveTo/say) — они ждали
|
||||||
// резолва ref в очереди.
|
// резолва ref в очереди.
|
||||||
this._flushPendingNpcCmds(payload.ref, npcId);
|
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, чтобы
|
// Сообщаем воркеру маппинг localRef → npcId, чтобы
|
||||||
// npc.onDeath по локальному ref находил правильного NPC.
|
// npc.onDeath по локальному ref находил правильного NPC.
|
||||||
@ -3198,16 +3638,28 @@ export class GameRuntime {
|
|||||||
const ref = payload?.ref;
|
const ref = payload?.ref;
|
||||||
const text = payload?.text;
|
const text = payload?.text;
|
||||||
if (typeof ref !== 'string') return;
|
if (typeof ref !== 'string') return;
|
||||||
// ленивое создание менеджера меток
|
|
||||||
if (!this.scene3d._labelManager) {
|
if (!this.scene3d._labelManager) {
|
||||||
this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
|
this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
|
||||||
}
|
}
|
||||||
const lm = this.scene3d._labelManager;
|
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);
|
const tgt = this._resolveTweenTarget(ref);
|
||||||
const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode);
|
if (tgt) {
|
||||||
if (mesh) {
|
applyLabel();
|
||||||
lm.setLabel(ref, mesh, text, payload?.opts || {});
|
} 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) {
|
} catch (e) {
|
||||||
console.warn('[GameRuntime] scene.setLabel failed', e);
|
console.warn('[GameRuntime] scene.setLabel failed', e);
|
||||||
@ -3935,6 +4387,73 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
return;
|
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
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[GameRuntime] unknown cmd', cmd);
|
console.warn('[GameRuntime] unknown cmd', cmd);
|
||||||
}
|
}
|
||||||
@ -4213,6 +4732,7 @@ export class GameRuntime {
|
|||||||
if (s?.primitiveManager) {
|
if (s?.primitiveManager) {
|
||||||
for (const data of s.primitiveManager.instances.values()) {
|
for (const data of s.primitiveManager.instances.values()) {
|
||||||
primitives.push({
|
primitives.push({
|
||||||
|
id: data.id,
|
||||||
ref: 'primitive:' + data.id,
|
ref: 'primitive:' + data.id,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
x: data.x, y: data.y, z: data.z,
|
x: data.x, y: data.y, z: data.z,
|
||||||
@ -4222,11 +4742,18 @@ export class GameRuntime {
|
|||||||
sz: data.sz != null ? data.sz : 1,
|
sz: data.sz != null ? data.sz : 1,
|
||||||
rotationY: data.rotationY || 0,
|
rotationY: data.rotationY || 0,
|
||||||
visible: data.visible !== false,
|
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
|
// Разобрать ref-строку ('primitive:N' / 'model:N' / 'block:x,y,z') в target
|
||||||
@ -4359,6 +4886,13 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_log(level, text, scriptId = null, scriptName = null) {
|
_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) {
|
if (this._onLog) {
|
||||||
try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ }
|
try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -470,7 +470,7 @@ export class NpcManager {
|
|||||||
const show = npc.hp < npc.maxHp;
|
const show = npc.hp < npc.maxHp;
|
||||||
hb.anchor.setEnabled(show);
|
hb.anchor.setEnabled(show);
|
||||||
if (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));
|
const pct = Math.max(0, Math.min(1, npc.hp / npc.maxHp));
|
||||||
hb.fill.scaling.x = pct;
|
hb.fill.scaling.x = pct;
|
||||||
hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;
|
hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;
|
||||||
|
|||||||
@ -507,6 +507,11 @@ export class PrimitiveManager {
|
|||||||
const matName = `${mesh.name}_mat`;
|
const matName = `${mesh.name}_mat`;
|
||||||
const mat = new StandardMaterial(matName, this.scene);
|
const mat = new StandardMaterial(matName, this.scene);
|
||||||
mat.diffuseColor = Color3.FromHexString(color || '#888888');
|
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. Это
|
// Если задан textureUrl — подгружаем PNG как diffuseTexture. Это
|
||||||
// используется для GD-скинов куба (например /gd/skins/cube_smile.png).
|
// используется для GD-скинов куба (например /gd/skins/cube_smile.png).
|
||||||
@ -567,9 +572,18 @@ export class PrimitiveManager {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'matte':
|
case 'matte':
|
||||||
default:
|
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
break;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Триггеры — всегда полупрозрачные жёлтые в редакторе
|
// Триггеры — всегда полупрозрачные жёлтые в редакторе
|
||||||
@ -689,7 +703,16 @@ export class PrimitiveManager {
|
|||||||
const data = this.instances.get(id);
|
const data = this.instances.get(id);
|
||||||
if (!data) return;
|
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.x !== undefined) data.x = patch.x;
|
||||||
if (patch.y !== undefined) data.y = patch.y;
|
if (patch.y !== undefined) data.y = patch.y;
|
||||||
if (patch.z !== undefined) data.z = patch.z;
|
if (patch.z !== undefined) data.z = patch.z;
|
||||||
|
|||||||
177
src/editor/engine/RbxlHudOverlay.js
Normal file
177
src/editor/engine/RbxlHudOverlay.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,164 +0,0 @@
|
|||||||
/**
|
|
||||||
* RobloxLuaSandbox — main-side обёртка над одним RobloxLuaWorker.
|
|
||||||
*
|
|
||||||
* Использование (по аналогии с ScriptSandbox):
|
|
||||||
* const sb = new RobloxLuaSandbox(luaSource, targetPrimitiveId);
|
|
||||||
* sb.setOnCommand((cmd, payload) => ...);
|
|
||||||
* sb.setInitialScene({primitives: {...}});
|
|
||||||
* sb.start();
|
|
||||||
* sb.tick(dt, sceneSnap);
|
|
||||||
* sb.fireEvent('touched', {primId, otherPrimId});
|
|
||||||
* sb.stop();
|
|
||||||
*
|
|
||||||
* Команды от Worker:
|
|
||||||
* { cmd: 'boot' } — Lua-VM запущена
|
|
||||||
* { cmd: 'ready' } — top-level код выполнен
|
|
||||||
* { cmd: 'log', payload: { level, text } }
|
|
||||||
* { cmd: 'partSet', payload: { primId, prop, value } }
|
|
||||||
* { cmd: 'partVel', payload: { primId, vx, vy, vz } }
|
|
||||||
* { cmd: 'playerCmd', payload: { method, args } }
|
|
||||||
* { cmd: 'tweenStart', payload: { ... } }
|
|
||||||
* { cmd: 'broadcast', payload: { msg, data } }
|
|
||||||
* { cmd: 'spawn', payload: { template, props, parentId } }
|
|
||||||
*/
|
|
||||||
|
|
||||||
let _workerUrl = null;
|
|
||||||
|
|
||||||
function getWorkerUrl() {
|
|
||||||
if (_workerUrl) return _workerUrl;
|
|
||||||
// Vite worker syntax — лучше через ?worker импорт; но мы можем
|
|
||||||
// динамически генерировать URL для ScriptSandboxWorker-style.
|
|
||||||
// Здесь упрощённо: загружаем worker как module через Vite ?worker&inline.
|
|
||||||
// Это будет настроено при интеграции в GameRuntime.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RobloxLuaSandbox {
|
|
||||||
constructor(luaSource, targetPrimitiveId = null) {
|
|
||||||
this.luaSource = luaSource || '';
|
|
||||||
this.targetPrimitiveId = targetPrimitiveId;
|
|
||||||
this.worker = null;
|
|
||||||
this._onCommand = null;
|
|
||||||
this._booted = false;
|
|
||||||
this._ready = false;
|
|
||||||
this._stopped = false;
|
|
||||||
this._pendingTicks = [];
|
|
||||||
this._pendingEvents = [];
|
|
||||||
this._initialScene = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOnCommand(cb) { this._onCommand = cb; }
|
|
||||||
setInitialScene(snap) { this._initialScene = snap; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Worker} worker — экземпляр Worker'а (предоставляется снаружи,
|
|
||||||
* так как Vite требует new Worker(new URL(...)) syntax который надо
|
|
||||||
* прописать в месте импорта)
|
|
||||||
*/
|
|
||||||
start(worker) {
|
|
||||||
if (this.worker) return;
|
|
||||||
if (!worker) throw new Error('RobloxLuaSandbox.start(worker): worker is required');
|
|
||||||
|
|
||||||
this.worker = worker;
|
|
||||||
this.worker.onmessage = (e) => this._handle(e);
|
|
||||||
this.worker.onerror = (err) => {
|
|
||||||
this._emit('log', { level: 'error', text: `Worker error: ${err.message || err}` });
|
|
||||||
};
|
|
||||||
this.worker.postMessage({
|
|
||||||
cmd: 'init',
|
|
||||||
payload: {
|
|
||||||
code: this.luaSource,
|
|
||||||
target: this.targetPrimitiveId,
|
|
||||||
sceneSnap: this._initialScene || { primitives: {} },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Передать кадр (snap сцены + dt). */
|
|
||||||
tick(dt, sceneSnap) {
|
|
||||||
if (!this.worker) return;
|
|
||||||
if (!this._ready) {
|
|
||||||
this._pendingTicks.push({ dt, sceneSnap });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Передать событие. */
|
|
||||||
fireEvent(kind, args, signalId) {
|
|
||||||
if (!this.worker) return;
|
|
||||||
if (!this._ready) {
|
|
||||||
this._pendingEvents.push({ kind, args, signalId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, signalId } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this._stopped = true;
|
|
||||||
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
|
|
||||||
try { this.worker?.terminate(); } catch (e) {}
|
|
||||||
this.worker = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Совместимость с интерфейсом ScriptSandbox (GameRuntime ожидает эти методы) ──
|
|
||||||
// Все no-op либо мапятся на fireEvent — наш Worker сам держит state сцены.
|
|
||||||
sendSceneSnapshot(_snap) { /* no-op: ничего не делает, наш Worker не использует snapshot напрямую */ }
|
|
||||||
sendGuiSnapshot(_snap) { /* no-op */ }
|
|
||||||
sendSkinsSnapshot(_snap) { /* no-op */ }
|
|
||||||
sendInventorySnapshot(_snap) { /* no-op */ }
|
|
||||||
sendTerrainHeightmap(_payload) { /* no-op */ }
|
|
||||||
sendGlobalEvent(kind, payload) {
|
|
||||||
// Глобальные события (input, hpChange, broadcast) маршрутизируем в наш fireEvent.
|
|
||||||
try { this.fireEvent(kind, [payload]); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendBroadcast(msg, data) {
|
|
||||||
try { this.fireEvent('broadcast', [msg, data]); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendOnTouchEvent(payload) {
|
|
||||||
try { this.fireEvent('touched', [payload]); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendOnTickEvent(dt) {
|
|
||||||
try { this.tick(dt, null); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendTweenDone(payload) {
|
|
||||||
try { this.fireEvent('tweenDone', [payload]); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendSpawnResolved(payload) {
|
|
||||||
try { this.fireEvent('spawnResolved', [payload]); } catch (e) {}
|
|
||||||
}
|
|
||||||
setInitialSelfPosition(_p) { /* no-op */ }
|
|
||||||
setModules(_modules) { /* no-op: rbxl Lua не использует наш game.require */ }
|
|
||||||
get scriptId() { return this._scriptId; }
|
|
||||||
set scriptId(v) { this._scriptId = v; }
|
|
||||||
|
|
||||||
_handle(ev) {
|
|
||||||
if (this._stopped) return;
|
|
||||||
const { cmd, payload } = ev.data || {};
|
|
||||||
if (cmd === 'boot') {
|
|
||||||
this._booted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'ready') {
|
|
||||||
this._ready = true;
|
|
||||||
// флушим накопленное
|
|
||||||
for (const t of this._pendingTicks) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {}
|
|
||||||
}
|
|
||||||
this._pendingTicks = [];
|
|
||||||
for (const e of this._pendingEvents) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {}
|
|
||||||
}
|
|
||||||
this._pendingEvents = [];
|
|
||||||
this._emit('ready', null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._emit(cmd, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
_emit(cmd, payload) {
|
|
||||||
if (this._onCommand) {
|
|
||||||
try { this._onCommand(cmd, payload); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* RobloxLuaSharedSandbox — main-side обёртка над одним shared Lua-worker'ом.
|
|
||||||
*
|
|
||||||
* v2 (после rewrite):
|
|
||||||
* - start(sceneSnap, guiTree, worker) → init с GUI-деревом
|
|
||||||
* - addScriptsBatch(scripts) → одной пачкой все скрипты регистрируются в VM
|
|
||||||
* - kickoff() → запускает event loop, fire'ит PlayerAdded
|
|
||||||
* - tick(dt) каждый кадр
|
|
||||||
* - fireEvent(kind, payload) — маршрутизирует в Worker.handleEvent
|
|
||||||
*
|
|
||||||
* GameRuntime пушит ОДИН экземпляр в this.sandboxes.
|
|
||||||
*/
|
|
||||||
export class RobloxLuaSharedSandbox {
|
|
||||||
constructor() {
|
|
||||||
this.worker = null;
|
|
||||||
this._onCommand = null;
|
|
||||||
this._booted = false;
|
|
||||||
this._scriptsLoaded = false;
|
|
||||||
this._stopped = false;
|
|
||||||
this._pendingTicks = [];
|
|
||||||
this._pendingEvents = [];
|
|
||||||
this._pendingScripts = null;
|
|
||||||
this._pendingKickoff = false;
|
|
||||||
this.scriptId = 'rbxl-shared';
|
|
||||||
}
|
|
||||||
|
|
||||||
setOnCommand(cb) { this._onCommand = cb; }
|
|
||||||
|
|
||||||
start(sceneSnap, guiTree, worker) {
|
|
||||||
if (this.worker) return;
|
|
||||||
this.worker = worker;
|
|
||||||
this.worker.onmessage = (e) => this._handle(e);
|
|
||||||
this.worker.onerror = (err) => {
|
|
||||||
this._emit('log', { level: 'error', text: `SharedWorker error: ${err.message || err}` });
|
|
||||||
};
|
|
||||||
this.worker.postMessage({ cmd: 'init', payload: { sceneSnap, guiTree } });
|
|
||||||
}
|
|
||||||
|
|
||||||
addScriptsBatch(scripts) {
|
|
||||||
if (!this._booted) { this._pendingScripts = scripts; return; }
|
|
||||||
try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
kickoff() {
|
|
||||||
if (!this._scriptsLoaded) { this._pendingKickoff = true; return; }
|
|
||||||
try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
tick(dt) {
|
|
||||||
if (!this.worker) return;
|
|
||||||
if (!this._booted) { this._pendingTicks.push(dt); return; }
|
|
||||||
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fireEvent(kind, payload) {
|
|
||||||
if (!this.worker) return;
|
|
||||||
const ev = { kind, ...(payload || {}) };
|
|
||||||
if (!this._booted) { this._pendingEvents.push(ev); return; }
|
|
||||||
try { this.worker.postMessage({ cmd: 'event', payload: ev }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this._stopped = true;
|
|
||||||
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
|
|
||||||
try { this.worker?.terminate(); } catch (e) {}
|
|
||||||
this.worker = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_handle(ev) {
|
|
||||||
if (this._stopped) return;
|
|
||||||
const { cmd, payload } = ev.data || {};
|
|
||||||
if (cmd === 'boot') {
|
|
||||||
this._booted = true;
|
|
||||||
// флушим pending scripts
|
|
||||||
if (this._pendingScripts) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts: this._pendingScripts } }); } catch (e) {}
|
|
||||||
this._pendingScripts = null;
|
|
||||||
}
|
|
||||||
// ticks накопленные до boot
|
|
||||||
for (const dt of this._pendingTicks) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
this._pendingTicks = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'ready') {
|
|
||||||
this._scriptsLoaded = true;
|
|
||||||
this._emit('ready', payload);
|
|
||||||
if (this._pendingKickoff) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
|
|
||||||
this._pendingKickoff = false;
|
|
||||||
}
|
|
||||||
// флушим pending events
|
|
||||||
for (const e of this._pendingEvents) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (er) {}
|
|
||||||
}
|
|
||||||
this._pendingEvents = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._emit(cmd, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
_emit(cmd, payload) {
|
|
||||||
if (this._onCommand) { try { this._onCommand(cmd, payload); } catch (e) {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ──
|
|
||||||
sendSceneSnapshot(_snap) {}
|
|
||||||
sendGuiSnapshot(_snap) {}
|
|
||||||
sendSkinsSnapshot(_snap) {}
|
|
||||||
sendInventorySnapshot(_snap) {}
|
|
||||||
sendTerrainHeightmap(_payload) {}
|
|
||||||
sendGlobalEvent(payload) {
|
|
||||||
if (!payload || typeof payload !== 'object') return;
|
|
||||||
const type = payload.type;
|
|
||||||
// playerTouch: BabylonScene уже детектит касания → Touched на Part
|
|
||||||
if (type === 'playerTouch' && payload.target) {
|
|
||||||
const m = /^primitive:(\d+)$/.exec(String(payload.target));
|
|
||||||
if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; }
|
|
||||||
}
|
|
||||||
// GUI click — Rublox GuiOverlay шлёт guiClick с id
|
|
||||||
if (type === 'guiClick' && (payload.id || payload.localId)) {
|
|
||||||
this.fireEvent('guiClick', { guiId: payload.id || payload.localId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// keyboard
|
|
||||||
if (type === 'keydown' || type === 'keyup') {
|
|
||||||
this.fireEvent(type, { key: payload.key });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// hp/death
|
|
||||||
if (type === 'hpChange' || type === 'humanoidHealth') {
|
|
||||||
this.fireEvent('humanoidHealth', { health: payload.hp || payload.health || 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (type === 'died' || type === 'humanoidDied') {
|
|
||||||
this.fireEvent('humanoidDied', {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// default: пробрасываем как kind=type
|
|
||||||
this.fireEvent(type || 'unknown', payload);
|
|
||||||
}
|
|
||||||
sendBroadcast(msg, data) { this.fireEvent('broadcast', { msg, data }); }
|
|
||||||
sendOnTouchEvent(payload) { this.fireEvent('touched', { primId: payload?.primId, isPlayer: true }); }
|
|
||||||
sendOnTickEvent(dt) { this.tick(dt); }
|
|
||||||
sendTweenDone(payload) { this.fireEvent('tweenDone', payload); }
|
|
||||||
sendSpawnResolved(payload) { this.fireEvent('spawnResolved', payload); }
|
|
||||||
setInitialSelfPosition(_p) {}
|
|
||||||
setModules(_modules) {}
|
|
||||||
}
|
|
||||||
@ -1,380 +0,0 @@
|
|||||||
/* eslint-disable no-restricted-globals */
|
|
||||||
/**
|
|
||||||
* RobloxLuaSharedWorker.js — single-VM Lua-runtime для импортированных Roblox-скриптов.
|
|
||||||
*
|
|
||||||
* Архитектура v2 (после ITERATION 5-step rewrite):
|
|
||||||
*
|
|
||||||
* ФАЗА 1 (boot): создаём wasmoon-VM, регистрируем Roblox API без скриптов.
|
|
||||||
*
|
|
||||||
* ФАЗА 2 (populate): main шлёт snapshot сцены (primitives + GUI tree).
|
|
||||||
* Создаём workspace со всеми Part'ами и PlayerGui со всем GUI-деревом.
|
|
||||||
* На каждом TextButton — MouseButton1Click сигнал, на каждом Part — Touched.
|
|
||||||
*
|
|
||||||
* ФАЗА 3 (addScripts): main шлёт ВСЕ скрипты ОДНИМ батчем. Worker загружает
|
|
||||||
* их в Lua-VM как отдельные функции в pcall. Скрипты регистрируют свои
|
|
||||||
* Connect'ы (Touched, MouseButton1Click, Heartbeat, ...). top-level wait()
|
|
||||||
* yield'ится через coroutine — управление возвращается в worker.
|
|
||||||
*
|
|
||||||
* ФАЗА 4 (run): main шлёт 'startEvents'. Worker запускает scheduler-tick
|
|
||||||
* и начинает обрабатывать события (touched/guiClick/heartbeat).
|
|
||||||
*
|
|
||||||
* IPC:
|
|
||||||
* <- init { sceneSnap, guiTree }
|
|
||||||
* <- addScripts { scripts: [{id, target, luaSource}] }
|
|
||||||
* <- start
|
|
||||||
* <- tick { dt }
|
|
||||||
* <- event { kind, payload }
|
|
||||||
* <- stop
|
|
||||||
* -> boot
|
|
||||||
* -> ready
|
|
||||||
* -> log/partSet/partVel/playerCmd/broadcast/guiUpdate
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxApi, RbxSignal } from './roblox-shim.js';
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
factory: null,
|
|
||||||
lua: null,
|
|
||||||
sceneSnap: { primitives: {} },
|
|
||||||
guiTree: [],
|
|
||||||
isStopped: false,
|
|
||||||
initPromise: null,
|
|
||||||
eventsStarted: false,
|
|
||||||
pendingEvents: [],
|
|
||||||
scriptCount: 0,
|
|
||||||
coroutines: [], // активные ждущие корутины: { co, resumeAt }
|
|
||||||
nowSec: 0,
|
|
||||||
api: null, // результат registerRobloxApi: { workspace, game, part_by_id, gui_by_id, localPlayer, humanoid }
|
|
||||||
};
|
|
||||||
|
|
||||||
function send(cmd, payload) {
|
|
||||||
self.postMessage({ cmd, payload });
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(level, text) {
|
|
||||||
send('log', { level, text });
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduler = {
|
|
||||||
now: () => state.nowSec,
|
|
||||||
schedule: (sec, fn) => {
|
|
||||||
state.coroutines.push({ resumeAt: state.nowSec + (sec || 0), fn });
|
|
||||||
},
|
|
||||||
spawn: (fn) => {
|
|
||||||
// spawn — fn запускается асинхронно (на следующем tick'е)
|
|
||||||
state.coroutines.push({ resumeAt: state.nowSec, fn });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
self.addEventListener('message', async (ev) => {
|
|
||||||
const { cmd, payload } = ev.data || {};
|
|
||||||
try {
|
|
||||||
if (cmd === 'init') await handleInit(payload);
|
|
||||||
else if (cmd === 'addScripts') await handleAddScripts(payload);
|
|
||||||
else if (cmd === 'start') handleStart();
|
|
||||||
else if (cmd === 'tick') handleTick(payload);
|
|
||||||
else if (cmd === 'event') {
|
|
||||||
if (!state.eventsStarted) state.pendingEvents.push(payload);
|
|
||||||
else handleEvent(payload);
|
|
||||||
}
|
|
||||||
else if (cmd === 'stop') {
|
|
||||||
state.isStopped = true;
|
|
||||||
try { state.lua?.global?.close?.(); } catch (e) {}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleInit({ sceneSnap, guiTree }) {
|
|
||||||
if (state.initPromise) { await state.initPromise; return; }
|
|
||||||
state.initPromise = (async () => {
|
|
||||||
state.sceneSnap = sceneSnap || { primitives: {} };
|
|
||||||
state.guiTree = guiTree || [];
|
|
||||||
state.factory = new LuaFactory();
|
|
||||||
state.lua = await state.factory.createEngine({
|
|
||||||
injectObjects: true,
|
|
||||||
enableProxy: true,
|
|
||||||
traceAllocations: false,
|
|
||||||
});
|
|
||||||
state.api = registerRobloxApi(state.lua, {
|
|
||||||
getSceneSnap: () => state.sceneSnap,
|
|
||||||
getGuiTree: () => state.guiTree,
|
|
||||||
targetPrimitiveId: null,
|
|
||||||
send,
|
|
||||||
scheduler,
|
|
||||||
});
|
|
||||||
// Передаём part_by_id в Lua как table {id → Instance}
|
|
||||||
// ВНИМАНИЕ: lua_table принимает string keys, ключи кладём как строки.
|
|
||||||
try {
|
|
||||||
const m = state.api?.part_by_id;
|
|
||||||
if (m) {
|
|
||||||
const obj = {};
|
|
||||||
for (const [id, part] of m) obj[String(id)] = part;
|
|
||||||
state.lua.global.set('__rbxl_parts_by_id', obj);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
// null-stub builder: возвращает Instance-like объект который безопасно
|
|
||||||
// отвечает на .Parent, .Name, .WaitForChild и т.п. чтобы цепочки
|
|
||||||
// script.Parent.Parent.X не валили.
|
|
||||||
const makeNullStub = () => {
|
|
||||||
const stub = {
|
|
||||||
Name: 'NullStub',
|
|
||||||
ClassName: 'Nil',
|
|
||||||
Children: [],
|
|
||||||
__isNullStub: true,
|
|
||||||
};
|
|
||||||
// Parent — самоссылающийся nullStub
|
|
||||||
stub.Parent = stub;
|
|
||||||
stub.FindFirstChild = () => stub;
|
|
||||||
stub.FindFirstChildOfClass = () => stub;
|
|
||||||
stub.FindFirstAncestor = () => stub;
|
|
||||||
stub.FindFirstAncestorOfClass = () => stub;
|
|
||||||
stub.WaitForChild = () => stub;
|
|
||||||
stub.GetChildren = () => [];
|
|
||||||
stub.GetDescendants = () => [];
|
|
||||||
stub.IsA = () => false;
|
|
||||||
stub.Clone = () => makeNullStub();
|
|
||||||
stub.Destroy = () => {};
|
|
||||||
stub.GetService = () => stub;
|
|
||||||
// Сигналы — пустой connector
|
|
||||||
const nullSig = {
|
|
||||||
Connect: () => ({ Disconnect: () => {}, Connected: false }),
|
|
||||||
Wait: () => null,
|
|
||||||
Fire: () => {},
|
|
||||||
};
|
|
||||||
// Любой каpitalized property — сигнал-stub
|
|
||||||
return new Proxy(stub, {
|
|
||||||
get(t, k) {
|
|
||||||
if (k in t) return t[k];
|
|
||||||
if (typeof k === 'string' && /^[A-Z]/.test(k)) return nullSig;
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
set(t, k, v) { t[k] = v; return true; },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
state.lua.global.set('__rbxl_make_null_stub', makeNullStub);
|
|
||||||
// ВАЖНО: создаём nullStub НА СТОРОНЕ LUA как настоящую table с
|
|
||||||
// metatable __index возвращающей сам stub. Это позволит цепочкам
|
|
||||||
// .Parent.X.Y:WaitForChild():Connect() корректно работать и обе
|
|
||||||
// нотации (. и :) сработают.
|
|
||||||
await state.lua.doString(`
|
|
||||||
__null_stub_mt = {}
|
|
||||||
function __make_null_stub()
|
|
||||||
local t = setmetatable({
|
|
||||||
Name = "Nil",
|
|
||||||
ClassName = "Nil",
|
|
||||||
__isNullStub = true,
|
|
||||||
Visible = false,
|
|
||||||
Enabled = false,
|
|
||||||
Value = 0,
|
|
||||||
Text = "",
|
|
||||||
}, __null_stub_mt)
|
|
||||||
return t
|
|
||||||
end
|
|
||||||
__null_stub_singleton = __make_null_stub()
|
|
||||||
-- nullSignal с обоими Connect/connect:
|
|
||||||
local function null_sig_method() return { Disconnect = function() end, disconnect = function() end, Connected = false } end
|
|
||||||
__null_signal = setmetatable({
|
|
||||||
Connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
|
|
||||||
connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
|
|
||||||
Wait = function() return nil end,
|
|
||||||
wait = function() return nil end,
|
|
||||||
Fire = function() end,
|
|
||||||
fire = function() end,
|
|
||||||
}, { __index = function() return function() return __null_stub_singleton end end })
|
|
||||||
-- Любой index nullStub'а → возвращает либо null_signal (для уже известных
|
|
||||||
-- сигнальных имён) либо noop-функцию которая возвращает сам stub.
|
|
||||||
__null_stub_mt.__index = function(t, k)
|
|
||||||
-- известные сигнал-имена
|
|
||||||
local sig_names = {Touched=true,TouchEnded=true,Changed=true,Activated=true,
|
|
||||||
MouseButton1Click=true,MouseButton1Down=true,MouseButton1Up=true,
|
|
||||||
MouseEnter=true,MouseLeave=true,InputBegan=true,InputEnded=true,
|
|
||||||
PlayerAdded=true,PlayerRemoving=true,CharacterAdded=true,CharacterRemoving=true,
|
|
||||||
Heartbeat=true,Stepped=true,RenderStepped=true,Died=true,HealthChanged=true,
|
|
||||||
FocusLost=true,Focused=true,ChildAdded=true,ChildRemoved=true,
|
|
||||||
AncestryChanged=true,DescendantAdded=true,DescendantRemoving=true}
|
|
||||||
if sig_names[k] then return __null_signal end
|
|
||||||
-- любой метод → функция которая возвращает stub (поддерживает оба синтаксиса)
|
|
||||||
return function(...) return __null_stub_singleton end
|
|
||||||
end
|
|
||||||
__null_stub_mt.__newindex = function(t, k, v) rawset(t, k, v) end
|
|
||||||
__null_stub_mt.__call = function(t, ...) return __null_stub_singleton end
|
|
||||||
-- Сделаем __null_stub_singleton.Parent = сам себя (lazy)
|
|
||||||
rawset(__null_stub_singleton, "Parent", __null_stub_singleton)
|
|
||||||
`);
|
|
||||||
// Заменяем __rbxl_make_null_stub на Lua-side функцию
|
|
||||||
await state.lua.doString(`
|
|
||||||
function __rbxl_make_null_stub() return __null_stub_singleton end
|
|
||||||
`);
|
|
||||||
// КРИТИЧНО: расширенные metatable для nil + function + number чтобы
|
|
||||||
// любые цепочки nil.x.y:method() и func.x не валили скрипты.
|
|
||||||
await state.lua.doString(`
|
|
||||||
if debug and debug.setmetatable then
|
|
||||||
local _stub_mt = {
|
|
||||||
__index = function(t, k) return __null_stub_singleton end,
|
|
||||||
__newindex = function(t, k, v) end,
|
|
||||||
__call = function(t, ...) return __null_stub_singleton end,
|
|
||||||
__add = function(a, b) return 0 end,
|
|
||||||
__sub = function(a, b) return 0 end,
|
|
||||||
__mul = function(a, b) return 0 end,
|
|
||||||
__div = function(a, b) return 0 end,
|
|
||||||
__mod = function(a, b) return 0 end,
|
|
||||||
__pow = function(a, b) return 0 end,
|
|
||||||
__unm = function() return 0 end,
|
|
||||||
__concat = function(a, b) return "" end,
|
|
||||||
__len = function() return 0 end,
|
|
||||||
__eq = function(a, b) return false end,
|
|
||||||
__lt = function(a, b) return false end,
|
|
||||||
__le = function(a, b) return false end,
|
|
||||||
__tostring = function() return "nil" end,
|
|
||||||
}
|
|
||||||
debug.setmetatable(nil, _stub_mt)
|
|
||||||
debug.setmetatable(function() end, _stub_mt)
|
|
||||||
-- НЕ ставим на number/string/boolean — они должны работать нормально
|
|
||||||
end
|
|
||||||
`);
|
|
||||||
// helper: безопасный pcall с warn'ом при ошибке
|
|
||||||
await state.lua.doString(`
|
|
||||||
__rbxl_scripts = {}
|
|
||||||
function __rbxl_safe_run(id, fn)
|
|
||||||
local ok, err = pcall(fn)
|
|
||||||
if not ok then warn("[rbxl-lua " .. tostring(id) .. " err] " .. tostring(err)) end
|
|
||||||
end
|
|
||||||
-- Lookup Part по primitiveId. Используем __rbxl_parts_by_id из JS,
|
|
||||||
-- т.к. ipairs() на JS array не работает (0-indexed vs Lua 1-indexed).
|
|
||||||
function __rbxl_lookup_part(id)
|
|
||||||
if __rbxl_parts_by_id then
|
|
||||||
return __rbxl_parts_by_id[tostring(id)] or __rbxl_parts_by_id[id]
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
`);
|
|
||||||
send('boot', null);
|
|
||||||
})();
|
|
||||||
await state.initPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddScripts({ scripts }) {
|
|
||||||
if (!state.lua) { log('error', 'addScripts before init'); return; }
|
|
||||||
let ok = 0, fail = 0;
|
|
||||||
for (const s of scripts) {
|
|
||||||
const safeId = String(s.id).replace(/[^a-zA-Z0-9_]/g, '_');
|
|
||||||
const targetExpr = s.target != null
|
|
||||||
? `__rbxl_lookup_part(${JSON.stringify(s.target)}) or __rbxl_make_null_stub()`
|
|
||||||
: '__rbxl_make_null_stub()';
|
|
||||||
// Оборачиваем в pcall. script — локальный, не конфликтует между скриптами.
|
|
||||||
// script.Parent НИКОГДА не nil — даём nullStub чтобы цепочки
|
|
||||||
// script.Parent.Parent.X не валили.
|
|
||||||
const wrapped = `
|
|
||||||
do
|
|
||||||
local script = setmetatable({
|
|
||||||
Name = "Script_${safeId}",
|
|
||||||
Parent = ${targetExpr},
|
|
||||||
ClassName = "LocalScript",
|
|
||||||
}, { __index = function(t, k) return rawget(t, k) end })
|
|
||||||
__rbxl_safe_run("${safeId}", function()
|
|
||||||
${s.luaSource}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
`;
|
|
||||||
try {
|
|
||||||
await state.lua.doString(wrapped);
|
|
||||||
ok++;
|
|
||||||
} catch (e) {
|
|
||||||
fail++;
|
|
||||||
// ошибки парсинга/runtime, считаем но не валим всё
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.scriptCount = ok;
|
|
||||||
send('ready', { ok, fail });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStart() {
|
|
||||||
state.eventsStarted = true;
|
|
||||||
// Эмитим Players.PlayerAdded + CharacterAdded чтобы скрипты которые
|
|
||||||
// делают game.Players.PlayerAdded:Connect(...) получили событие.
|
|
||||||
try {
|
|
||||||
const lp = state.api?.localPlayer;
|
|
||||||
const players = state.api?.services?.get('Players');
|
|
||||||
if (lp && players?.PlayerAdded?.Fire) players.PlayerAdded.Fire(lp);
|
|
||||||
if (lp?.CharacterAdded?.Fire && lp.Character) lp.CharacterAdded.Fire(lp.Character);
|
|
||||||
} catch (e) {}
|
|
||||||
// Флушим накопленные события
|
|
||||||
for (const e of state.pendingEvents) handleEvent(e);
|
|
||||||
state.pendingEvents = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTick({ dt }) {
|
|
||||||
if (state.isStopped || !state.lua) return;
|
|
||||||
state.nowSec += dt || 0;
|
|
||||||
// Резолвим планированные корутины
|
|
||||||
if (state.coroutines.length > 0) {
|
|
||||||
const due = [];
|
|
||||||
const left = [];
|
|
||||||
for (const c of state.coroutines) {
|
|
||||||
if (c.resumeAt <= state.nowSec) due.push(c); else left.push(c);
|
|
||||||
}
|
|
||||||
state.coroutines = left;
|
|
||||||
for (const c of due) {
|
|
||||||
try { c.fn(); } catch (e) { log('warn', `coroutine err: ${e?.message || e}`); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// RunService сигналы
|
|
||||||
try {
|
|
||||||
const rs = state.api?.services?.get('RunService');
|
|
||||||
if (rs?.Heartbeat?.Fire) rs.Heartbeat.Fire(dt);
|
|
||||||
if (rs?.Stepped?.Fire) rs.Stepped.Fire(state.nowSec, dt);
|
|
||||||
if (rs?.RenderStepped?.Fire) rs.RenderStepped.Fire(dt);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEvent(payload) {
|
|
||||||
if (state.isStopped || !state.lua || !state.api) return;
|
|
||||||
const { kind } = payload || {};
|
|
||||||
try {
|
|
||||||
if (kind === 'guiClick' || kind === 'guiActivated') {
|
|
||||||
const guiId = payload.guiId;
|
|
||||||
const inst = state.api.gui_by_id?.get(guiId);
|
|
||||||
if (inst) {
|
|
||||||
if (kind === 'guiActivated') inst.Activated?.Fire?.(1);
|
|
||||||
else inst.MouseButton1Click?.Fire?.();
|
|
||||||
}
|
|
||||||
} else if (kind === 'touched') {
|
|
||||||
const primId = payload.primId;
|
|
||||||
const part = state.api.part_by_id?.get(primId);
|
|
||||||
if (part?.Touched?.Fire) {
|
|
||||||
// hit = HumanoidRootPart
|
|
||||||
part.Touched.Fire(state.api.character?.HumanoidRootPart || part);
|
|
||||||
}
|
|
||||||
// также Humanoid.Touched на самом игроке
|
|
||||||
if (payload.isPlayer) {
|
|
||||||
state.api.humanoid?.Touched?.Fire?.(part);
|
|
||||||
}
|
|
||||||
} else if (kind === 'keydown' || kind === 'keyup') {
|
|
||||||
// UserInputService.InputBegan/Ended
|
|
||||||
const uis = state.api.services?.get('UserInputService') ||
|
|
||||||
(() => {
|
|
||||||
const s = new (state.lua.global.get('Instance')?.new ? Object : Object)();
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
if (uis) {
|
|
||||||
if (kind === 'keydown') uis.InputBegan?.Fire?.({ KeyCode: { Name: payload.key } });
|
|
||||||
else uis.InputEnded?.Fire?.({ KeyCode: { Name: payload.key } });
|
|
||||||
}
|
|
||||||
} else if (kind === 'humanoidDied') {
|
|
||||||
state.api.humanoid?.Died?.Fire?.();
|
|
||||||
} else if (kind === 'humanoidHealth') {
|
|
||||||
const h = state.api.humanoid;
|
|
||||||
if (h) {
|
|
||||||
h.Health = payload.health;
|
|
||||||
h.HealthChanged?.Fire?.(payload.health);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log('warn', `event ${kind} err: ${e?.message || e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.__rbxlSharedState = state;
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
/* eslint-disable no-restricted-globals */
|
|
||||||
/**
|
|
||||||
* RobloxLuaWorker.js — Web Worker, хостящий Lua 5.4 VM (wasmoon) для исполнения
|
|
||||||
* Roblox-Lua скриптов импортированных через rbxl-importer.
|
|
||||||
*
|
|
||||||
* Запускается из RobloxLuaSandbox.js (main thread).
|
|
||||||
*
|
|
||||||
* IPC (с main):
|
|
||||||
* <- init { code: string, target?: id, shim: 'full'|'mini', sceneSnap: object }
|
|
||||||
* <- tick { dt, sceneSnap } — каждый кадр
|
|
||||||
* <- event { kind: 'touched'|'changed'|..., args } — события сцены
|
|
||||||
* -> boot нет payload — Worker запустился, Lua-VM ready
|
|
||||||
* -> ready нет payload — top-level lua код исполнен
|
|
||||||
* -> log { level, text }
|
|
||||||
* -> partSet { primId, prop, value } — изменение свойства Part'а
|
|
||||||
* -> partVel { primId, vx, vy, vz }
|
|
||||||
* -> playerCmd { method, args } — методы game.player (teleport, damage, walkSpeed)
|
|
||||||
* -> tweenStart{ targetId, prop, from, to, durationSec, easing }
|
|
||||||
* -> broadcast { msg, data } — RemoteEvent аналог
|
|
||||||
* -> spawn { template, props, parentId } — Instance.new()
|
|
||||||
*
|
|
||||||
* Lua-runtime архитектура:
|
|
||||||
* - wasmoon = Lua 5.4 в WebAssembly, ~500KB, ~5x быстрее fengari.
|
|
||||||
* - Lua-VM глобалы: game, workspace, script, task, wait, print, warn, error.
|
|
||||||
* - Все Roblox-классы — JS-объекты-прокси (см. roblox-shim.js, регистрируемые
|
|
||||||
* через factory.setProxy).
|
|
||||||
*
|
|
||||||
* Безопасность:
|
|
||||||
* - Worker изолирован от DOM.
|
|
||||||
* - Memory limit ~50 MB на VM (через wasmoon options).
|
|
||||||
* - На каждые N=10000 инструкций Lua hook → возможность отменить (TODO).
|
|
||||||
*
|
|
||||||
* Воркер ДЕРЖИТ И ОБНОВЛЯЕТ snapshot сцены (зеркало того что в Babylon-сцене),
|
|
||||||
* чтобы Lua-код мог читать Position/Color без round-trip к main thread.
|
|
||||||
* Обновление от main: cmd='tick' с дельтой сцены.
|
|
||||||
*
|
|
||||||
* Это первый MVP-вариант. Полный shim API регистрируется в фазе 4.3-4.13.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxApi } from './roblox-shim.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Worker-side state. Один Worker = один скрипт.
|
|
||||||
*/
|
|
||||||
const state = {
|
|
||||||
factory: null,
|
|
||||||
lua: null,
|
|
||||||
target: null, // id примитива к которому привязан script.Parent
|
|
||||||
sceneSnap: { primitives: {} },// зеркало
|
|
||||||
isStopped: false,
|
|
||||||
pendingEvents: [], // события до init
|
|
||||||
signals: new Map(), // signalId → [callbacks]
|
|
||||||
nextSignalId: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ──────── IPC helpers ──────── */
|
|
||||||
|
|
||||||
function send(cmd, payload) {
|
|
||||||
self.postMessage({ cmd, payload });
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(level, text) {
|
|
||||||
send('log', { level, text });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Worker entrypoint ──────── */
|
|
||||||
|
|
||||||
self.addEventListener('message', async (ev) => {
|
|
||||||
const { cmd, payload } = ev.data || {};
|
|
||||||
try {
|
|
||||||
if (cmd === 'init') {
|
|
||||||
await handleInit(payload);
|
|
||||||
} else if (cmd === 'tick') {
|
|
||||||
handleTick(payload);
|
|
||||||
} else if (cmd === 'event') {
|
|
||||||
handleEvent(payload);
|
|
||||||
} else if (cmd === 'stop') {
|
|
||||||
state.isStopped = true;
|
|
||||||
try { state.lua?.global?.close?.(); } catch (e) {}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleInit({ code, target, sceneSnap }) {
|
|
||||||
state.target = target;
|
|
||||||
state.sceneSnap = sceneSnap || { primitives: {} };
|
|
||||||
|
|
||||||
state.factory = new LuaFactory();
|
|
||||||
state.lua = await state.factory.createEngine({
|
|
||||||
injectObjects: true,
|
|
||||||
enableProxy: true,
|
|
||||||
traceAllocations: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Регистрируем Roblox API.
|
|
||||||
registerRobloxApi(state.lua, {
|
|
||||||
getSceneSnap: () => state.sceneSnap,
|
|
||||||
targetPrimitiveId: state.target,
|
|
||||||
send,
|
|
||||||
registerSignal: (callback) => {
|
|
||||||
const id = state.nextSignalId++;
|
|
||||||
const list = state.signals.get(id) || [];
|
|
||||||
list.push(callback);
|
|
||||||
state.signals.set(id, list);
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
send('boot', null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Оборачиваем в pcall + ловим errors. Roblox-карты часто делают
|
|
||||||
// game.Players.LocalPlayer:WaitForChild("PlayerGui") который у нас
|
|
||||||
// даёт null — top-level код падает на первой такой строке.
|
|
||||||
// pcall ловит и даёт сигналам/Touched зарегистрироваться там где смогли.
|
|
||||||
const wrapped = `
|
|
||||||
local _ok, _err = pcall(function()
|
|
||||||
${code}
|
|
||||||
end)
|
|
||||||
if not _ok then
|
|
||||||
warn("[rbxl-lua partial fail] " .. tostring(_err))
|
|
||||||
end
|
|
||||||
`;
|
|
||||||
await state.lua.doString(wrapped);
|
|
||||||
send('ready', null);
|
|
||||||
} catch (e) {
|
|
||||||
log('error', `Lua error: ${e && e.message ? e.message : e}`);
|
|
||||||
send('ready', null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// После ready доставляем events которые накопились
|
|
||||||
for (const ev of state.pendingEvents) handleEvent(ev);
|
|
||||||
state.pendingEvents = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTick({ dt, sceneSnap }) {
|
|
||||||
if (state.isStopped || !state.lua) return;
|
|
||||||
if (sceneSnap) state.sceneSnap = sceneSnap;
|
|
||||||
// Heartbeat — для всех подписанных
|
|
||||||
fireSignalByName('Heartbeat', [dt]);
|
|
||||||
// Stepped (старая API) — тоже даём
|
|
||||||
fireSignalByName('Stepped', [dt]);
|
|
||||||
// RenderStepped — отдельно (на клиенте между physics и render)
|
|
||||||
fireSignalByName('RenderStepped', [dt]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEvent({ kind, args, signalId }) {
|
|
||||||
if (!state.lua) {
|
|
||||||
state.pendingEvents.push({ kind, args, signalId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (signalId != null) {
|
|
||||||
const list = state.signals.get(signalId) || [];
|
|
||||||
for (const cb of list) {
|
|
||||||
try { cb(...(args || [])); } catch (e) { log('error', `signal callback error: ${e}`); }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fireSignalByName(kind, args || []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fireSignalByName(name, args) {
|
|
||||||
// namedSignals регистрируются в roblox-shim как сильные строки
|
|
||||||
// (например 'Heartbeat'). Все callback'и под этим именем в signals.
|
|
||||||
// Без отдельной мапы — ищем линейно.
|
|
||||||
for (const [id, list] of state.signals.entries()) {
|
|
||||||
if (list.__name === name) {
|
|
||||||
for (const cb of list) {
|
|
||||||
try { cb(...args); } catch (e) { log('error', `${name} cb error: ${e}`); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Helper export для тестов ──────── */
|
|
||||||
|
|
||||||
self.__rbxlState = state;
|
|
||||||
@ -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',
|
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',
|
shadowQuality: this._scene3d.getShadowQuality?.() || 'soft',
|
||||||
ssaoEnabled: this._scene3d.getSsaoEnabled?.() || false,
|
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();
|
this._notifyChange();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -170,8 +170,8 @@ export class StudioCollab {
|
|||||||
sc.__collabScriptsPatched = true;
|
sc.__collabScriptsPatched = true;
|
||||||
if (typeof sc.upsertScript === 'function') {
|
if (typeof sc.upsertScript === 'function') {
|
||||||
const origUpsert = sc.upsertScript.bind(sc);
|
const origUpsert = sc.upsertScript.bind(sc);
|
||||||
sc.upsertScript = function (id, code, target, name) {
|
sc.upsertScript = function (id, code, target, name, language) {
|
||||||
const r = origUpsert(id, code, target, name);
|
const r = origUpsert(id, code, target, name, language);
|
||||||
if (!self._applyingRemote) {
|
if (!self._applyingRemote) {
|
||||||
// id может быть сгенерён внутри upsertScript, если был null —
|
// id может быть сгенерён внутри upsertScript, если был null —
|
||||||
// достаём фактический из _scripts (последний с этим code).
|
// достаём фактический из _scripts (последний с этим code).
|
||||||
@ -188,6 +188,7 @@ export class StudioCollab {
|
|||||||
code: rec.code,
|
code: rec.code,
|
||||||
target: rec.target ?? null,
|
target: rec.target ?? null,
|
||||||
name: rec.name ?? null,
|
name: rec.name ?? null,
|
||||||
|
language: rec.language ?? 'js',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -523,7 +524,7 @@ export function applyRemoteOp(scene, op) {
|
|||||||
// Создание/редактирование скрипта у соавтора. _applyingRemote уже
|
// Создание/редактирование скрипта у соавтора. _applyingRemote уже
|
||||||
// выставлен (см. _applyRemoteOp) → обёртка upsertScript не зашлёт
|
// выставлен (см. _applyRemoteOp) → обёртка upsertScript не зашлёт
|
||||||
// эхо обратно. _onSceneChange внутри обновит React-панели.
|
// эхо обратно. _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?.();
|
scene._onCollabScriptsChange?.();
|
||||||
return;
|
return;
|
||||||
case 'scriptRemove':
|
case 'scriptRemove':
|
||||||
|
|||||||
337
src/editor/engine/lua/LuaSharedSandbox.js
Normal file
337
src/editor/engine/lua/LuaSharedSandbox.js
Normal 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;
|
||||||
2500
src/editor/engine/lua/RobloxShim.js
Normal file
2500
src/editor/engine/lua/RobloxShim.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* rbxl-lua-integration.js — single-VM интеграция (v2).
|
* rbxl-lua-integration.js — вспомогательные функции для импорта .rbxl-карт.
|
||||||
*
|
*
|
||||||
* Двухфазная инициализация:
|
* Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные
|
||||||
* 1) init worker → pre-populate workspace + GUI tree (включая сигналы)
|
* Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua
|
||||||
* 2) addScriptsBatch ВСЕ скрипты одним IPC сообщением
|
* (см. GameRuntime.start()). Этот файл оставлен только для:
|
||||||
* 3) ready → kickoff → emit PlayerAdded, начать tick
|
* - 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-кода. */
|
/** Распаковка lua_source из packed-кода. */
|
||||||
export function unpackRobloxLuaCode(code) {
|
export function unpackRobloxLuaCode(code) {
|
||||||
@ -20,6 +20,20 @@ export function unpackRobloxLuaCode(code) {
|
|||||||
return code.slice(start, closeIdx);
|
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). */
|
/** Сцена → snap для shim'а (workspace:GetChildren). */
|
||||||
export function buildLuaSceneSnap(primitives) {
|
export function buildLuaSceneSnap(primitives) {
|
||||||
const out = { primitives: {} };
|
const out = { primitives: {} };
|
||||||
@ -80,37 +94,6 @@ export function buildLuaGuiTree(guiElements) {
|
|||||||
return out;
|
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-сцене.
|
* Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене.
|
||||||
*/
|
*/
|
||||||
@ -122,28 +105,78 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cmd === 'partSet') {
|
if (cmd === 'partSet') {
|
||||||
|
const pm = runtime.scene3d?.primitiveManager;
|
||||||
|
if (!pm) {
|
||||||
|
console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const primId = payload?.primId;
|
||||||
|
const prop = payload?.prop;
|
||||||
|
const value = payload?.value;
|
||||||
|
const patch = {};
|
||||||
|
if (prop === 'position' && value) {
|
||||||
|
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
||||||
|
} else if (prop === 'cframe' && value) {
|
||||||
|
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
||||||
|
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
|
||||||
|
} else if (prop === 'size' && value) {
|
||||||
|
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz;
|
||||||
|
} else if (prop === 'color') patch.color = value;
|
||||||
|
else if (prop === 'material') patch.material = value;
|
||||||
|
else if (prop === 'anchored') patch.anchored = value;
|
||||||
|
else if (prop === 'canCollide') patch.canCollide = value;
|
||||||
|
else if (prop === 'opacity') patch.opacity = value;
|
||||||
|
try {
|
||||||
|
if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch);
|
||||||
|
else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
|
||||||
|
else if (typeof pm.update === 'function') pm.update(primId, patch);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[partSet] updateInstance failed:', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'sceneCreate') {
|
||||||
|
// Lua: Instance.new("Part") + part.Parent = workspace → создание примитива.
|
||||||
|
// payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored }
|
||||||
try {
|
try {
|
||||||
const pm = runtime.scene3d?.primitiveManager;
|
const pm = runtime.scene3d?.primitiveManager;
|
||||||
if (!pm) return;
|
if (!pm || typeof pm.addInstance !== 'function') return;
|
||||||
const primId = payload?.primId;
|
const opts = {
|
||||||
const prop = payload?.prop;
|
id: payload?.primId,
|
||||||
const value = payload?.value;
|
x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0,
|
||||||
const patch = {};
|
sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1,
|
||||||
if (prop === 'position' && value) {
|
color: payload?.color,
|
||||||
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
anchored: payload?.anchored !== false,
|
||||||
} else if (prop === 'cframe' && value) {
|
canCollide: payload?.canCollide !== false,
|
||||||
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
};
|
||||||
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
|
pm.addInstance(payload?.type || 'cube', opts);
|
||||||
} else if (prop === 'size' && value) {
|
// Если unanchored — регистрируем в физике на лету, иначе он не падает.
|
||||||
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz;
|
if (opts.anchored === false) {
|
||||||
} else if (prop === 'color') patch.color = value;
|
try {
|
||||||
else if (prop === 'material') patch.material = value;
|
const dm = runtime.scene3d?.dynamics;
|
||||||
else if (prop === 'anchored') patch.anchored = value;
|
const data = pm.instances?.get?.(opts.id);
|
||||||
else if (prop === 'canCollide') patch.canCollide = value;
|
if (dm && data && typeof dm.registerPrimitive === 'function') {
|
||||||
else if (prop === 'opacity') patch.opacity = value;
|
dm.registerPrimitive(data);
|
||||||
if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
|
}
|
||||||
else if (typeof pm.update === 'function') pm.update(primId, patch);
|
} catch (e) {
|
||||||
} 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;
|
return;
|
||||||
}
|
}
|
||||||
if (cmd === 'partVel') {
|
if (cmd === 'partVel') {
|
||||||
|
|||||||
@ -1,216 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-physics.js — эмуляция BodyMover / Constraint объектов Roblox.
|
|
||||||
*
|
|
||||||
* Roblox BodyMover'ы (старые, deprecated но массово используются):
|
|
||||||
* BodyVelocity — поддерживает заданную линейную velocity
|
|
||||||
* BodyAngularVelocity — поддерживает заданную угловую velocity
|
|
||||||
* BodyGyro — пытается удержать ориентацию (Lookat)
|
|
||||||
* BodyForce — постоянная сила
|
|
||||||
* BodyPosition — пытается удержать позицию
|
|
||||||
* BodyThrust — направленный импульс
|
|
||||||
*
|
|
||||||
* Constraint (новые):
|
|
||||||
* AlignPosition, AlignOrientation, LinearVelocity, AngularVelocity, Torque,
|
|
||||||
* VectorForce, Spring, RodConstraint, RopeConstraint, ...
|
|
||||||
*
|
|
||||||
* MVP: реализуем самые частые (BodyVelocity, BodyGyro, AlignPosition, VectorForce).
|
|
||||||
* Остальные — заглушки + warning.
|
|
||||||
*
|
|
||||||
* Архитектура:
|
|
||||||
* - Когда Lua делает `Instance.new("BodyVelocity", part)`, мы создаём RbxBodyVelocity,
|
|
||||||
* прикрепляем к Part через .Parent.
|
|
||||||
* - На каждом tick шедулера обходим активные movers и отсылаем physForce в main.
|
|
||||||
* - Main применяет к Babylon physics impostor.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
|
|
||||||
|
|
||||||
class RbxBodyMoverBase extends RbxInstance {
|
|
||||||
constructor(className) {
|
|
||||||
super(className, { Name: className });
|
|
||||||
this._ctx = null; // { send, registerMover }
|
|
||||||
this.__parentPart = null;
|
|
||||||
}
|
|
||||||
/** Установить родителя и зарегистрироваться в physics-manager. */
|
|
||||||
setMoverParent(part) {
|
|
||||||
this.Parent = part;
|
|
||||||
if (part && part.__primId != null) {
|
|
||||||
this.__parentPart = part;
|
|
||||||
this._ctx?.registerMover?.(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyVelocity extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyVelocity');
|
|
||||||
this.Velocity = new RbxVector3(0, 0, 0);
|
|
||||||
this.MaxForce = new RbxVector3(4000, 4000, 4000);
|
|
||||||
this.P = 1250;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx) return;
|
|
||||||
// posVel — желаемая velocity. Применяем как setVelocity.
|
|
||||||
this._ctx.send('partVel', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
vx: this.Velocity.X,
|
|
||||||
vy: this.Velocity.Y,
|
|
||||||
vz: this.Velocity.Z,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyGyro extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyGyro');
|
|
||||||
this.CFrame = null; // целевое вращение
|
|
||||||
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
|
|
||||||
this.D = 500;
|
|
||||||
this.P = 3000;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx || !this.CFrame) return;
|
|
||||||
const [rx, ry, rz] = this.CFrame.toEulerXYZ();
|
|
||||||
this._ctx.send('partSet', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
prop: 'rotation',
|
|
||||||
value: { rx, ry, rz },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyPosition extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyPosition');
|
|
||||||
this.Position = new RbxVector3(0, 0, 0);
|
|
||||||
this.MaxForce = new RbxVector3(4000, 4000, 4000);
|
|
||||||
this.D = 1250;
|
|
||||||
this.P = 10000;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partSet', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
prop: 'position',
|
|
||||||
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyForce extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyForce');
|
|
||||||
this.Force = new RbxVector3(0, 0, 0);
|
|
||||||
}
|
|
||||||
_step(dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partForce', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
fx: this.Force.X * dt, fy: this.Force.Y * dt, fz: this.Force.Z * dt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyAngularVelocity extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyAngularVelocity');
|
|
||||||
this.AngularVelocity = new RbxVector3(0, 0, 0);
|
|
||||||
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partAngVel', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
wx: this.AngularVelocity.X, wy: this.AngularVelocity.Y, wz: this.AngularVelocity.Z,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── New Constraints ──────── */
|
|
||||||
|
|
||||||
export class RbxAlignPosition extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('AlignPosition');
|
|
||||||
this.Position = new RbxVector3(0, 0, 0);
|
|
||||||
this.Attachment0 = null;
|
|
||||||
this.Attachment1 = null;
|
|
||||||
this.MaxForce = 1e6;
|
|
||||||
this.Enabled = true;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partSet', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
prop: 'position',
|
|
||||||
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxLinearVelocity extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('LinearVelocity');
|
|
||||||
this.VectorVelocity = new RbxVector3(0, 0, 0);
|
|
||||||
this.MaxForce = 1e6;
|
|
||||||
this.Enabled = true;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partVel', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
vx: this.VectorVelocity.X,
|
|
||||||
vy: this.VectorVelocity.Y,
|
|
||||||
vz: this.VectorVelocity.Z,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Manager ──────── */
|
|
||||||
|
|
||||||
export class RobloxPhysicsManager {
|
|
||||||
constructor(send) {
|
|
||||||
this._send = send;
|
|
||||||
this._movers = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
install(lua) {
|
|
||||||
const self = this;
|
|
||||||
const ctx = {
|
|
||||||
send: this._send,
|
|
||||||
registerMover: (m) => self._movers.add(m),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Подменяем Instance.new для физических классов
|
|
||||||
const origInstance = lua.global.get('Instance');
|
|
||||||
lua.global.set('Instance', {
|
|
||||||
new: (className, parent) => {
|
|
||||||
let inst = null;
|
|
||||||
switch (className) {
|
|
||||||
case 'BodyVelocity': inst = new RbxBodyVelocity(); break;
|
|
||||||
case 'BodyGyro': inst = new RbxBodyGyro(); break;
|
|
||||||
case 'BodyPosition': inst = new RbxBodyPosition(); break;
|
|
||||||
case 'BodyForce': inst = new RbxBodyForce(); break;
|
|
||||||
case 'BodyAngularVelocity':inst = new RbxBodyAngularVelocity(); break;
|
|
||||||
case 'AlignPosition': inst = new RbxAlignPosition(); break;
|
|
||||||
case 'LinearVelocity': inst = new RbxLinearVelocity(); break;
|
|
||||||
}
|
|
||||||
if (inst) {
|
|
||||||
inst._ctx = ctx;
|
|
||||||
if (parent) {
|
|
||||||
inst.setMoverParent(parent);
|
|
||||||
if (parent.Children) parent.Children.push(inst);
|
|
||||||
}
|
|
||||||
return inst;
|
|
||||||
}
|
|
||||||
return origInstance.new(className, parent);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
tick(dt) {
|
|
||||||
for (const m of [...this._movers]) {
|
|
||||||
if (m.__destroyed || !m.__parentPart) { this._movers.delete(m); continue; }
|
|
||||||
try { m._step(dt); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-scheduler.js — шедулер корутин для Roblox-Lua wait/task.
|
|
||||||
*
|
|
||||||
* Архитектура:
|
|
||||||
* - Каждый верхне-уровневый Lua-код оборачивается в coroutine.
|
|
||||||
* - wait(sec) / task.wait(sec) делают coroutine.yield(sec)
|
|
||||||
* - Шедулер запоминает: { coro, resumeAt: tick + sec }
|
|
||||||
* - На каждом handleTick из main thread шедулер ресюмит готовые корутины
|
|
||||||
*
|
|
||||||
* RBXScriptSignal.Wait() = аналогично, но wait не на время, а на event'е:
|
|
||||||
* - { coro, waitingForSignal: signalName }
|
|
||||||
* - При Fire() сигнала шедулер ресюмит все ждущие
|
|
||||||
*
|
|
||||||
* Использование:
|
|
||||||
* const sched = new RobloxScheduler(luaEngine);
|
|
||||||
* sched.spawnMain(luaSource);
|
|
||||||
* // Каждый кадр:
|
|
||||||
* sched.tick(dtSec);
|
|
||||||
* // При событии:
|
|
||||||
* sched.fireSignal('Heartbeat', dt);
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class RobloxScheduler {
|
|
||||||
constructor(lua) {
|
|
||||||
this.lua = lua;
|
|
||||||
this.time = 0;
|
|
||||||
this.tasks = []; // [{ coro, resumeAt, waitForSignal?, signalArgsBuf? }]
|
|
||||||
this.signalWaiters = new Map(); // name → [task]
|
|
||||||
this._coroBox = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Регистрирует глобалы wait/task.wait/task.spawn/task.delay в Lua-VM.
|
|
||||||
* Должно вызываться ПОСЛЕ registerRobloxApi (т.к. перебивает заглушки).
|
|
||||||
*/
|
|
||||||
install() {
|
|
||||||
const self = this;
|
|
||||||
// wait(sec) — yield в корутине на sec секунд
|
|
||||||
this.lua.global.set('wait', (sec) => {
|
|
||||||
// Этот wait вызовется из Lua. Если мы внутри корутины (а мы внутри
|
|
||||||
// т.к. spawnMain обернул всё) — yield. Иначе вернём дельту времени
|
|
||||||
// как обычное wait в Roblox.
|
|
||||||
const s = +sec || 0;
|
|
||||||
self._currentYield = { kind: 'sleep', sec: s };
|
|
||||||
// Возврат тут — это значение которое получит await в Lua;
|
|
||||||
// wasmoon обработает yield извне.
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
this.lua.global.set('task', {
|
|
||||||
wait: (sec) => {
|
|
||||||
self._currentYield = { kind: 'sleep', sec: +sec || 0 };
|
|
||||||
return +sec || 0;
|
|
||||||
},
|
|
||||||
spawn: (fn, ...args) => {
|
|
||||||
self.spawnCoroutine(fn, args);
|
|
||||||
},
|
|
||||||
delay: (sec, fn, ...args) => {
|
|
||||||
self.tasks.push({
|
|
||||||
resumeAt: self.time + (+sec || 0),
|
|
||||||
runFn: () => { try { fn(...args); } catch (e) {} },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
defer: (fn, ...args) => {
|
|
||||||
self.tasks.push({
|
|
||||||
resumeAt: self.time,
|
|
||||||
runFn: () => { try { fn(...args); } catch (e) {} },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.lua.global.set('spawn', (fn) => { self.spawnCoroutine(fn, []); });
|
|
||||||
this.lua.global.set('delay', (sec, fn) => {
|
|
||||||
self.tasks.push({
|
|
||||||
resumeAt: self.time + (+sec || 0),
|
|
||||||
runFn: () => { try { fn(); } catch (e) {} },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Запустить верхне-уровневый Lua-код как корутину.
|
|
||||||
* Возвращает Promise который резолвится когда код достиг ready (либо ушёл в первый yield).
|
|
||||||
*/
|
|
||||||
async spawnMain(luaSource) {
|
|
||||||
// Оборачиваем источник в coroutine.wrap(function() ... end)
|
|
||||||
// и сразу зовём — это даёт нам ручку на корутине через специальный
|
|
||||||
// приём: храним её в global _userCoro.
|
|
||||||
const wrapped = `
|
|
||||||
_userCoro = coroutine.create(function()
|
|
||||||
${luaSource}
|
|
||||||
end)
|
|
||||||
local ok, yieldVal = coroutine.resume(_userCoro)
|
|
||||||
if not ok then
|
|
||||||
error("user script error: " .. tostring(yieldVal))
|
|
||||||
end
|
|
||||||
return yieldVal
|
|
||||||
`;
|
|
||||||
try {
|
|
||||||
await this.lua.doString(wrapped);
|
|
||||||
const coroStatus = await this.lua.doString('return coroutine.status(_userCoro)');
|
|
||||||
if (coroStatus === 'suspended') {
|
|
||||||
// Ушла в yield — добавляем в шедулер
|
|
||||||
const yieldInfo = this._currentYield || { kind: 'sleep', sec: 0 };
|
|
||||||
this._currentYield = null;
|
|
||||||
this.tasks.push({
|
|
||||||
resumeAt: this.time + (yieldInfo.kind === 'sleep' ? yieldInfo.sec : 0),
|
|
||||||
waitForSignal: yieldInfo.kind === 'signal' ? yieldInfo.name : null,
|
|
||||||
coro: '_userCoro',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('spawnMain error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Запустить произвольную функцию как корутину (для task.spawn).
|
|
||||||
*/
|
|
||||||
spawnCoroutine(fn, args) {
|
|
||||||
// Создаём корутину на JS-стороне: просто вызываем fn() сразу,
|
|
||||||
// а если внутри неё дёрнут wait — yield не сработает (JS не делает
|
|
||||||
// sync yield в обычной функции). Поэтому task.spawn для JS-функций
|
|
||||||
// равен прямому вызову.
|
|
||||||
// В будущем (4.7.1) можно через Lua coroutine реализовать.
|
|
||||||
try { fn(...(args || [])); } catch (e) { /* swallow */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Продвинуть время на dt и резюмить готовые корутины.
|
|
||||||
* Также автоматически fire'ит RunService.Heartbeat / Stepped / RenderStepped.
|
|
||||||
*/
|
|
||||||
async tick(dtSec) {
|
|
||||||
const dt = +dtSec || 0;
|
|
||||||
this.time += dt;
|
|
||||||
// Heartbeat / Stepped / RenderStepped для RunService
|
|
||||||
const game = this.lua.global.get('game');
|
|
||||||
if (game && typeof game.GetService === 'function') {
|
|
||||||
const rs = game.GetService('RunService');
|
|
||||||
if (rs) {
|
|
||||||
if (rs.Heartbeat && rs.Heartbeat.Fire) rs.Heartbeat.Fire(dt);
|
|
||||||
if (rs.Stepped && rs.Stepped.Fire) rs.Stepped.Fire(this.time, dt);
|
|
||||||
if (rs.RenderStepped && rs.RenderStepped.Fire) rs.RenderStepped.Fire(dt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Резюмим всё что готово
|
|
||||||
const ready = this.tasks.filter(t => !t.waitForSignal && t.resumeAt <= this.time);
|
|
||||||
this.tasks = this.tasks.filter(t => !(ready.includes(t)));
|
|
||||||
for (const t of ready) {
|
|
||||||
await this._resumeTask(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire signal — разбудить все task'и ждущие этого сигнала.
|
|
||||||
*/
|
|
||||||
async fireSignal(name, ...args) {
|
|
||||||
const waiters = this.signalWaiters.get(name) || [];
|
|
||||||
this.signalWaiters.set(name, []);
|
|
||||||
for (const t of waiters) {
|
|
||||||
// Resume корутины передавая args как возврат :Wait()
|
|
||||||
await this._resumeTask(t, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _resumeTask(task, resumeArgs = []) {
|
|
||||||
if (task.runFn) {
|
|
||||||
try {
|
|
||||||
const ret = task.runFn();
|
|
||||||
if (ret && typeof ret.then === 'function') await ret;
|
|
||||||
} catch (e) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (task.coro) {
|
|
||||||
try {
|
|
||||||
// resumeArgs идут как аргументы в coroutine.resume
|
|
||||||
const argsCode = resumeArgs.map((a, i) => {
|
|
||||||
if (typeof a === 'number') return String(a);
|
|
||||||
if (typeof a === 'string') return JSON.stringify(a);
|
|
||||||
return 'nil';
|
|
||||||
}).join(', ');
|
|
||||||
const code = `
|
|
||||||
local ok, val = coroutine.resume(${task.coro}${argsCode ? ', ' + argsCode : ''})
|
|
||||||
if not ok then
|
|
||||||
error("coro error: " .. tostring(val))
|
|
||||||
end
|
|
||||||
return val
|
|
||||||
`;
|
|
||||||
await this.lua.doString(code);
|
|
||||||
const status = await this.lua.doString(`return coroutine.status(${task.coro})`);
|
|
||||||
if (status === 'suspended') {
|
|
||||||
// Опять ушла в yield
|
|
||||||
const yi = this._currentYield || { kind: 'sleep', sec: 0 };
|
|
||||||
this._currentYield = null;
|
|
||||||
if (yi.kind === 'sleep') {
|
|
||||||
this.tasks.push({
|
|
||||||
resumeAt: this.time + yi.sec,
|
|
||||||
coro: task.coro,
|
|
||||||
});
|
|
||||||
} else if (yi.kind === 'signal') {
|
|
||||||
const list = this.signalWaiters.get(yi.name) || [];
|
|
||||||
list.push({ coro: task.coro });
|
|
||||||
this.signalWaiters.set(yi.name, list);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Корутина завершилась с ошибкой — просто дропаем
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,384 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-services.js — расширения Roblox-API для сервисов:
|
|
||||||
* Players / Humanoid / UserInputService / RemoteEvent / RemoteFunction
|
|
||||||
* / DataStoreService / HttpService.
|
|
||||||
*
|
|
||||||
* Регистрируется ПОСЛЕ registerRobloxApi (см. roblox-shim.js).
|
|
||||||
*
|
|
||||||
* Поведение:
|
|
||||||
* - Players.LocalPlayer.Character.Humanoid.Health, WalkSpeed, JumpPower
|
|
||||||
* мапятся на game.player.* в Rublox через `playerCmd` IPC.
|
|
||||||
* - UserInputService.InputBegan/InputEnded — пробрасываются из main
|
|
||||||
* по событию через fireEvent.
|
|
||||||
* - RemoteEvent:FireServer/FireClient → broadcast.
|
|
||||||
* - DataStoreService:GetDataStore → game.save.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
|
|
||||||
|
|
||||||
/* ──────── Humanoid ──────── */
|
|
||||||
|
|
||||||
class RbxHumanoid extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('Humanoid', { Name: 'Humanoid' });
|
|
||||||
this._ctx = ctx; // { send, getPlayerState }
|
|
||||||
this._snap = {
|
|
||||||
Health: 100,
|
|
||||||
MaxHealth: 100,
|
|
||||||
WalkSpeed: 16,
|
|
||||||
JumpPower: 50,
|
|
||||||
JumpHeight: 7.2,
|
|
||||||
HipHeight: 0,
|
|
||||||
HumanoidStateType: 'GettingUp',
|
|
||||||
PlatformStand: false,
|
|
||||||
};
|
|
||||||
this.Died = new RbxSignal('Died');
|
|
||||||
this.HealthChanged = new RbxSignal('HealthChanged');
|
|
||||||
this.Touched = new RbxSignal('Touched');
|
|
||||||
this.Running = new RbxSignal('Running');
|
|
||||||
this.Jumping = new RbxSignal('Jumping');
|
|
||||||
this.StateChanged = new RbxSignal('StateChanged');
|
|
||||||
}
|
|
||||||
|
|
||||||
get Health() { return this._snap.Health; }
|
|
||||||
set Health(v) {
|
|
||||||
const old = this._snap.Health;
|
|
||||||
const nv = Math.max(0, +v || 0);
|
|
||||||
this._snap.Health = nv;
|
|
||||||
if (nv !== old) this.HealthChanged.Fire(nv);
|
|
||||||
if (nv <= 0 && old > 0) {
|
|
||||||
this.Died.Fire();
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'die', args: [] });
|
|
||||||
} else {
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setHealth', args: [nv] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get MaxHealth() { return this._snap.MaxHealth; }
|
|
||||||
set MaxHealth(v) {
|
|
||||||
this._snap.MaxHealth = +v || 100;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setMaxHealth', args: [this._snap.MaxHealth] });
|
|
||||||
}
|
|
||||||
get WalkSpeed() { return this._snap.WalkSpeed; }
|
|
||||||
set WalkSpeed(v) {
|
|
||||||
this._snap.WalkSpeed = +v || 0;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setWalkSpeed', args: [this._snap.WalkSpeed] });
|
|
||||||
}
|
|
||||||
get JumpPower() { return this._snap.JumpPower; }
|
|
||||||
set JumpPower(v) {
|
|
||||||
this._snap.JumpPower = +v || 0;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setJumpPower', args: [this._snap.JumpPower] });
|
|
||||||
}
|
|
||||||
get JumpHeight() { return this._snap.JumpHeight; }
|
|
||||||
set JumpHeight(v) {
|
|
||||||
this._snap.JumpHeight = +v || 0;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setJumpHeight', args: [this._snap.JumpHeight] });
|
|
||||||
}
|
|
||||||
get PlatformStand() { return !!this._snap.PlatformStand; }
|
|
||||||
set PlatformStand(v) {
|
|
||||||
this._snap.PlatformStand = !!v;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setPlatformStand', args: [!!v] });
|
|
||||||
}
|
|
||||||
TakeDamage(amount) {
|
|
||||||
this.Health = Math.max(0, this.Health - (+amount || 0));
|
|
||||||
}
|
|
||||||
Move(direction, relative) {
|
|
||||||
if (direction instanceof RbxVector3) {
|
|
||||||
this._ctx.send?.('playerCmd', {
|
|
||||||
method: 'move',
|
|
||||||
args: [{ x: direction.X, y: direction.Y, z: direction.Z }, !!relative],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Jump() {
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'jump', args: [] });
|
|
||||||
}
|
|
||||||
LoadAnimation(animation) {
|
|
||||||
// Animation объект — content rbxassetid. Возвращаем animation-track stub.
|
|
||||||
const aid = animation?.AnimationId || '';
|
|
||||||
return {
|
|
||||||
AnimationId: aid,
|
|
||||||
Length: 0,
|
|
||||||
IsPlaying: false,
|
|
||||||
Looped: false,
|
|
||||||
Play: () => this._ctx.send?.('playerCmd', { method: 'playAnim', args: [aid] }),
|
|
||||||
Stop: () => this._ctx.send?.('playerCmd', { method: 'stopAnim', args: [aid] }),
|
|
||||||
AdjustSpeed: (s) => this._ctx.send?.('playerCmd', { method: 'animSpeed', args: [aid, s] }),
|
|
||||||
GetTimeOfKeyframe: () => 0,
|
|
||||||
KeyframeReached: new RbxSignal('KeyframeReached'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
ChangeState(state) {
|
|
||||||
this._snap.HumanoidStateType = state;
|
|
||||||
this.StateChanged.Fire(state);
|
|
||||||
}
|
|
||||||
SetStateEnabled(_state, _enabled) { /* noop */ }
|
|
||||||
GetState() { return this._snap.HumanoidStateType; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Character / Player ──────── */
|
|
||||||
|
|
||||||
class RbxCharacter extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('Model', { Name: 'Character' });
|
|
||||||
// HumanoidRootPart — это «Position персонажа»
|
|
||||||
this.HumanoidRootPart = new RbxInstance('Part', { Name: 'HumanoidRootPart', Parent: this });
|
|
||||||
// mock Position через getter — берём текущую позицию из ctx
|
|
||||||
Object.defineProperty(this.HumanoidRootPart, 'Position', {
|
|
||||||
get: () => {
|
|
||||||
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
|
|
||||||
return new RbxVector3(p.x, p.y, p.z);
|
|
||||||
},
|
|
||||||
set: (v) => {
|
|
||||||
if (v instanceof RbxVector3) {
|
|
||||||
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X, v.Y, v.Z] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(this.HumanoidRootPart, 'CFrame', {
|
|
||||||
get: () => {
|
|
||||||
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
|
|
||||||
return { X: p.x, Y: p.y, Z: p.z, p: { X: p.x, Y: p.y, Z: p.z } };
|
|
||||||
},
|
|
||||||
set: (v) => {
|
|
||||||
if (v && typeof v === 'object') {
|
|
||||||
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X || 0, v.Y || 0, v.Z || 0] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.Children.push(this.HumanoidRootPart);
|
|
||||||
this.Humanoid = new RbxHumanoid(ctx);
|
|
||||||
this.Humanoid.Parent = this;
|
|
||||||
this.Children.push(this.Humanoid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxPlayer extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('Player', { Name: 'Player' });
|
|
||||||
this.UserId = 1;
|
|
||||||
this.DisplayName = 'Player';
|
|
||||||
this.Character = new RbxCharacter(ctx);
|
|
||||||
this.CharacterAdded = new RbxSignal('CharacterAdded');
|
|
||||||
this.CharacterRemoving = new RbxSignal('CharacterRemoving');
|
|
||||||
// На MVP — характер уже создан.
|
|
||||||
setTimeout(() => this.CharacterAdded.Fire(this.Character), 0);
|
|
||||||
this.leaderstats = new RbxInstance('Folder', { Name: 'leaderstats', Parent: this });
|
|
||||||
this.Children.push(this.leaderstats);
|
|
||||||
}
|
|
||||||
GetMouse() {
|
|
||||||
return { Hit: { Position: new RbxVector3(0, 0, 0) }, Target: null,
|
|
||||||
Button1Down: new RbxSignal('Button1Down'), Move: new RbxSignal('Move') };
|
|
||||||
}
|
|
||||||
Kick(reason) {
|
|
||||||
// в нашем плеере — просто log
|
|
||||||
return reason;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── UserInputService ──────── */
|
|
||||||
|
|
||||||
class RbxUserInputService extends RbxInstance {
|
|
||||||
constructor() {
|
|
||||||
super('UserInputService', { Name: 'UserInputService' });
|
|
||||||
this.InputBegan = new RbxSignal('InputBegan');
|
|
||||||
this.InputEnded = new RbxSignal('InputEnded');
|
|
||||||
this.InputChanged = new RbxSignal('InputChanged');
|
|
||||||
this.JumpRequest = new RbxSignal('JumpRequest');
|
|
||||||
this.KeyboardEnabled = true;
|
|
||||||
this.MouseEnabled = true;
|
|
||||||
this.TouchEnabled = false;
|
|
||||||
}
|
|
||||||
GetMouseLocation() { return { X: 0, Y: 0 }; }
|
|
||||||
IsKeyDown(_keyCode) { return false; } // в MVP всегда false
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── RemoteEvent / RemoteFunction ──────── */
|
|
||||||
|
|
||||||
class RbxRemoteEvent extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('RemoteEvent', { Name: 'RemoteEvent' });
|
|
||||||
this._ctx = ctx;
|
|
||||||
this.OnServerEvent = new RbxSignal('OnServerEvent');
|
|
||||||
this.OnClientEvent = new RbxSignal('OnClientEvent');
|
|
||||||
}
|
|
||||||
FireServer(...args) {
|
|
||||||
// singleplayer: server == client, просто отдаём в OnServerEvent
|
|
||||||
this.OnServerEvent.Fire(this._ctx.localPlayer, ...args);
|
|
||||||
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
|
|
||||||
}
|
|
||||||
FireClient(_player, ...args) {
|
|
||||||
this.OnClientEvent.Fire(...args);
|
|
||||||
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
|
|
||||||
}
|
|
||||||
FireAllClients(...args) {
|
|
||||||
this.OnClientEvent.Fire(...args);
|
|
||||||
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxRemoteFunction extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('RemoteFunction', { Name: 'RemoteFunction' });
|
|
||||||
this._ctx = ctx;
|
|
||||||
this.OnServerInvoke = null; // function(player, ...args) → result
|
|
||||||
}
|
|
||||||
InvokeServer(...args) {
|
|
||||||
if (typeof this.OnServerInvoke === 'function') {
|
|
||||||
try { return this.OnServerInvoke(this._ctx.localPlayer, ...args); } catch (e) {}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
InvokeClient(_player, ...args) {
|
|
||||||
if (typeof this.OnClientInvoke === 'function') {
|
|
||||||
try { return this.OnClientInvoke(...args); } catch (e) {}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── DataStoreService ──────── */
|
|
||||||
|
|
||||||
class RbxDataStore {
|
|
||||||
constructor(name, ctx) {
|
|
||||||
this.name = name;
|
|
||||||
this._ctx = ctx;
|
|
||||||
}
|
|
||||||
GetAsync(key) {
|
|
||||||
try {
|
|
||||||
const data = this._ctx.loadSave?.(this.name + ':' + key);
|
|
||||||
return data ?? null;
|
|
||||||
} catch (e) { return null; }
|
|
||||||
}
|
|
||||||
SetAsync(key, value) {
|
|
||||||
this._ctx.saveSave?.(this.name + ':' + key, value);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
UpdateAsync(key, updaterFn) {
|
|
||||||
const cur = this.GetAsync(key);
|
|
||||||
const next = updaterFn(cur);
|
|
||||||
if (next !== undefined) this.SetAsync(key, next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
IncrementAsync(key, delta) {
|
|
||||||
const cur = +this.GetAsync(key) || 0;
|
|
||||||
const next = cur + (+delta || 1);
|
|
||||||
this.SetAsync(key, next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
RemoveAsync(key) {
|
|
||||||
this._ctx.removeSave?.(this.name + ':' + key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxDataStoreService extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('DataStoreService', { Name: 'DataStoreService' });
|
|
||||||
this._ctx = ctx;
|
|
||||||
this._stores = new Map();
|
|
||||||
}
|
|
||||||
GetDataStore(name) {
|
|
||||||
if (!this._stores.has(name)) this._stores.set(name, new RbxDataStore(name, this._ctx));
|
|
||||||
return this._stores.get(name);
|
|
||||||
}
|
|
||||||
GetGlobalDataStore() { return this.GetDataStore('__global__'); }
|
|
||||||
GetOrderedDataStore(name) { return this.GetDataStore('ordered:' + name); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── HttpService ──────── */
|
|
||||||
|
|
||||||
class RbxHttpService extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('HttpService', { Name: 'HttpService' });
|
|
||||||
this._ctx = ctx;
|
|
||||||
this.HttpEnabled = false; // в нашем плеере по дефолту выкл, безопаснее
|
|
||||||
}
|
|
||||||
GenerateGUID(wrap) {
|
|
||||||
const c = () => Math.random().toString(16).slice(2, 6);
|
|
||||||
const guid = `${c()}${c()}-${c()}-${c()}-${c()}-${c()}${c()}${c()}`.toUpperCase();
|
|
||||||
return wrap === false ? guid : `{${guid}}`;
|
|
||||||
}
|
|
||||||
JSONEncode(value) { try { return JSON.stringify(value); } catch (e) { return ''; } }
|
|
||||||
JSONDecode(s) { try { return JSON.parse(s); } catch (e) { return null; } }
|
|
||||||
GetAsync(url) {
|
|
||||||
// CORS / sandbox: блокируем в MVP, возвращаем заглушку
|
|
||||||
this._ctx.send?.('log', { level: 'warn', text: `HttpService:GetAsync(${url}) blocked in MVP` });
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
PostAsync(url) {
|
|
||||||
this._ctx.send?.('log', { level: 'warn', text: `HttpService:PostAsync(${url}) blocked in MVP` });
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── install ──────── */
|
|
||||||
|
|
||||||
export function installRobloxServices(lua, ctx) {
|
|
||||||
// ctx: { send, getPlayerState, getSnapPlayer, loadSave, saveSave, removeSave }
|
|
||||||
const game = lua.global.get('game');
|
|
||||||
if (!game) return;
|
|
||||||
|
|
||||||
// Создаём LocalPlayer
|
|
||||||
const player = new RbxPlayer({
|
|
||||||
send: ctx.send,
|
|
||||||
getPlayerState: ctx.getPlayerState,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Players service апгрейдим
|
|
||||||
const players = game.GetService('Players');
|
|
||||||
if (players) {
|
|
||||||
players.LocalPlayer = player;
|
|
||||||
// GetPlayers / GetPlayerFromCharacter
|
|
||||||
players.GetPlayers = () => [player];
|
|
||||||
players.GetPlayerFromCharacter = (c) => (c === player.Character ? player : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserInputService
|
|
||||||
const uis = new RbxUserInputService();
|
|
||||||
// RemoteEvent / DataStoreService / HttpService — выдаются через GetService
|
|
||||||
const dss = new RbxDataStoreService({
|
|
||||||
loadSave: ctx.loadSave,
|
|
||||||
saveSave: ctx.saveSave,
|
|
||||||
removeSave: ctx.removeSave,
|
|
||||||
});
|
|
||||||
const httpSvc = new RbxHttpService({ send: ctx.send });
|
|
||||||
|
|
||||||
// Подмена GetService — добавляем наши новые сервисы
|
|
||||||
const origGetService = game.GetService;
|
|
||||||
game.GetService = function(svc) {
|
|
||||||
if (svc === 'UserInputService') return uis;
|
|
||||||
if (svc === 'DataStoreService') return dss;
|
|
||||||
if (svc === 'HttpService') return httpSvc;
|
|
||||||
// ContextActionService — стаб
|
|
||||||
if (svc === 'ContextActionService') {
|
|
||||||
return {
|
|
||||||
ClassName: 'ContextActionService', Name: 'ContextActionService',
|
|
||||||
BindAction: (_name, fn, _gui, ...keys) => { /* в MVP — игнор */ },
|
|
||||||
UnbindAction: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return origGetService.call(this, svc);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Instance.new('RemoteEvent') / 'RemoteFunction' — переопределяем фабрику
|
|
||||||
const origInstance = lua.global.get('Instance');
|
|
||||||
lua.global.set('Instance', {
|
|
||||||
new: (className, parent) => {
|
|
||||||
if (className === 'RemoteEvent') {
|
|
||||||
const r = new RbxRemoteEvent({ send: ctx.send, localPlayer: player });
|
|
||||||
if (parent) { r.Parent = parent; parent.Children.push(r); }
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
if (className === 'RemoteFunction') {
|
|
||||||
const r = new RbxRemoteFunction({ send: ctx.send, localPlayer: player });
|
|
||||||
if (parent) { r.Parent = parent; parent.Children.push(r); }
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
return origInstance.new(className, parent);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { player, uis, dss, httpSvc };
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RbxHumanoid, RbxCharacter, RbxPlayer, RbxUserInputService,
|
|
||||||
RbxRemoteEvent, RbxRemoteFunction, RbxDataStoreService, RbxHttpService };
|
|
||||||
@ -1,715 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-shim.js — регистрация Roblox API внутри Lua-VM (wasmoon).
|
|
||||||
*
|
|
||||||
* Используется из RobloxLuaWorker.js. Регистрирует глобалы:
|
|
||||||
* - game, workspace, script ← Instance-прокси
|
|
||||||
* - Vector3, Color3, CFrame, UDim, UDim2 ← конструкторы математических классов
|
|
||||||
* - Instance.new(class) ← фабрика
|
|
||||||
* - wait, task, tick, os, print, warn ← стандартные глобалы
|
|
||||||
* - Enum ← enum-таблица
|
|
||||||
*
|
|
||||||
* Архитектура:
|
|
||||||
* - JS-классы (RbxVector3, RbxCFrame, ...) — обычные дата-объекты с
|
|
||||||
* перегруженными методами.
|
|
||||||
* - Instance — прокси-объект который хранит { className, properties, children, parent }.
|
|
||||||
* Геттеры/сеттеры эмулируются через __index/__newindex (mt в wasmoon).
|
|
||||||
* - RBXScriptSignal — JS-объект с Connect/Wait/Disconnect.
|
|
||||||
*
|
|
||||||
* Sandbox-side: при изменении Part.Position и т.п. отсылаем в main thread
|
|
||||||
* `partSet` → main применит к Babylon-сцене.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ──────── Math classes ──────── */
|
|
||||||
|
|
||||||
class RbxVector3 {
|
|
||||||
constructor(x, y, z) {
|
|
||||||
this.X = +x || 0;
|
|
||||||
this.Y = +y || 0;
|
|
||||||
this.Z = +z || 0;
|
|
||||||
}
|
|
||||||
get Magnitude() {
|
|
||||||
return Math.sqrt(this.X*this.X + this.Y*this.Y + this.Z*this.Z);
|
|
||||||
}
|
|
||||||
get Unit() {
|
|
||||||
const m = this.Magnitude || 1;
|
|
||||||
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
|
|
||||||
}
|
|
||||||
Dot(o) { return this.X*o.X + this.Y*o.Y + this.Z*o.Z; }
|
|
||||||
Cross(o) {
|
|
||||||
return new RbxVector3(
|
|
||||||
this.Y*o.Z - this.Z*o.Y,
|
|
||||||
this.Z*o.X - this.X*o.Z,
|
|
||||||
this.X*o.Y - this.Y*o.X,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Lerp(o, alpha) {
|
|
||||||
return new RbxVector3(
|
|
||||||
this.X + (o.X - this.X) * alpha,
|
|
||||||
this.Y + (o.Y - this.Y) * alpha,
|
|
||||||
this.Z + (o.Z - this.Z) * alpha,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
add(o) { return new RbxVector3(this.X + o.X, this.Y + o.Y, this.Z + o.Z); }
|
|
||||||
sub(o) { return new RbxVector3(this.X - o.X, this.Y - o.Y, this.Z - o.Z); }
|
|
||||||
mul(scalar) {
|
|
||||||
if (typeof scalar === 'number') {
|
|
||||||
return new RbxVector3(this.X * scalar, this.Y * scalar, this.Z * scalar);
|
|
||||||
}
|
|
||||||
return new RbxVector3(this.X * scalar.X, this.Y * scalar.Y, this.Z * scalar.Z);
|
|
||||||
}
|
|
||||||
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxColor3 {
|
|
||||||
constructor(r, g, b) {
|
|
||||||
this.R = +r || 0;
|
|
||||||
this.G = +g || 0;
|
|
||||||
this.B = +b || 0;
|
|
||||||
}
|
|
||||||
static fromRGB(r, g, b) {
|
|
||||||
return new RbxColor3((r||0)/255, (g||0)/255, (b||0)/255);
|
|
||||||
}
|
|
||||||
static fromHex(hex) {
|
|
||||||
const h = String(hex || '#000000').replace('#','');
|
|
||||||
return new RbxColor3(
|
|
||||||
parseInt(h.slice(0,2), 16)/255,
|
|
||||||
parseInt(h.slice(2,4), 16)/255,
|
|
||||||
parseInt(h.slice(4,6), 16)/255,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Lerp(o, alpha) {
|
|
||||||
return new RbxColor3(
|
|
||||||
this.R + (o.R - this.R) * alpha,
|
|
||||||
this.G + (o.G - this.G) * alpha,
|
|
||||||
this.B + (o.B - this.B) * alpha,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
toHex() {
|
|
||||||
const h = (n) => Math.max(0, Math.min(255, Math.round(n * 255))).toString(16).padStart(2, '0');
|
|
||||||
return `#${h(this.R)}${h(this.G)}${h(this.B)}`;
|
|
||||||
}
|
|
||||||
toString() { return `${this.R}, ${this.G}, ${this.B}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxCFrame {
|
|
||||||
constructor(x, y, z, r00=1, r01=0, r02=0, r10=0, r11=1, r12=0, r20=0, r21=0, r22=1) {
|
|
||||||
this.X = +x || 0; this.Y = +y || 0; this.Z = +z || 0;
|
|
||||||
// Row-major 3x3
|
|
||||||
this.r00 = r00; this.r01 = r01; this.r02 = r02;
|
|
||||||
this.r10 = r10; this.r11 = r11; this.r12 = r12;
|
|
||||||
this.r20 = r20; this.r21 = r21; this.r22 = r22;
|
|
||||||
}
|
|
||||||
static new(x, y, z) {
|
|
||||||
if (x instanceof RbxVector3) return new RbxCFrame(x.X, x.Y, x.Z);
|
|
||||||
return new RbxCFrame(x || 0, y || 0, z || 0);
|
|
||||||
}
|
|
||||||
static Angles(rx, ry, rz) {
|
|
||||||
// Euler XYZ → 3x3 (intrinsic)
|
|
||||||
const cx = Math.cos(rx), sx = Math.sin(rx);
|
|
||||||
const cy = Math.cos(ry), sy = Math.sin(ry);
|
|
||||||
const cz = Math.cos(rz), sz = Math.sin(rz);
|
|
||||||
// R = Rx * Ry * Rz
|
|
||||||
const r00 = cy*cz, r01 = -cy*sz, r02 = sy;
|
|
||||||
const r10 = sx*sy*cz + cx*sz, r11 = -sx*sy*sz + cx*cz, r12 = -sx*cy;
|
|
||||||
const r20 = -cx*sy*cz + sx*sz, r21 = cx*sy*sz + sx*cz, r22 = cx*cy;
|
|
||||||
return new RbxCFrame(0, 0, 0, r00, r01, r02, r10, r11, r12, r20, r21, r22);
|
|
||||||
}
|
|
||||||
static fromEulerAnglesXYZ(rx, ry, rz) { return RbxCFrame.Angles(rx, ry, rz); }
|
|
||||||
get Position() { return new RbxVector3(this.X, this.Y, this.Z); }
|
|
||||||
get LookVector() { return new RbxVector3(-this.r02, -this.r12, -this.r22); }
|
|
||||||
get RightVector() { return new RbxVector3(this.r00, this.r10, this.r20); }
|
|
||||||
get UpVector() { return new RbxVector3(this.r01, this.r11, this.r21); }
|
|
||||||
Lerp(o, a) {
|
|
||||||
// Линейная интерполяция (без правильного slerp на матрицах — для MVP сойдёт)
|
|
||||||
return new RbxCFrame(
|
|
||||||
this.X + (o.X - this.X) * a,
|
|
||||||
this.Y + (o.Y - this.Y) * a,
|
|
||||||
this.Z + (o.Z - this.Z) * a,
|
|
||||||
this.r00, this.r01, this.r02,
|
|
||||||
this.r10, this.r11, this.r12,
|
|
||||||
this.r20, this.r21, this.r22,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Inverse() {
|
|
||||||
// Транспонируем 3x3 (для rotation matrix Inverse == Transpose)
|
|
||||||
return new RbxCFrame(
|
|
||||||
-this.X, -this.Y, -this.Z,
|
|
||||||
this.r00, this.r10, this.r20,
|
|
||||||
this.r01, this.r11, this.r21,
|
|
||||||
this.r02, this.r12, this.r22,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
toEulerXYZ() {
|
|
||||||
const rx = Math.atan2(this.r21, this.r22);
|
|
||||||
const ry = Math.atan2(-this.r20, Math.sqrt(this.r21*this.r21 + this.r22*this.r22));
|
|
||||||
const rz = Math.atan2(this.r10, this.r00);
|
|
||||||
return [rx, ry, rz];
|
|
||||||
}
|
|
||||||
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxUDim {
|
|
||||||
constructor(scale, offset) { this.Scale = +scale || 0; this.Offset = +offset | 0; }
|
|
||||||
toString() { return `${this.Scale}, ${this.Offset}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxUDim2 {
|
|
||||||
constructor(xs, xo, ys, yo) {
|
|
||||||
this.X = new RbxUDim(xs, xo);
|
|
||||||
this.Y = new RbxUDim(ys, yo);
|
|
||||||
}
|
|
||||||
static new(xs, xo, ys, yo) { return new RbxUDim2(xs, xo, ys, yo); }
|
|
||||||
static fromScale(xs, ys) { return new RbxUDim2(xs, 0, ys, 0); }
|
|
||||||
static fromOffset(xo, yo) { return new RbxUDim2(0, xo, 0, yo); }
|
|
||||||
toString() { return `${this.X.Scale}, ${this.X.Offset}, ${this.Y.Scale}, ${this.Y.Offset}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── RBXScriptSignal ──────── */
|
|
||||||
|
|
||||||
let _signalIdCounter = 1000;
|
|
||||||
|
|
||||||
class RbxSignal {
|
|
||||||
constructor(name) {
|
|
||||||
this.name = name;
|
|
||||||
this.id = _signalIdCounter++;
|
|
||||||
this.connections = [];
|
|
||||||
}
|
|
||||||
Connect(callback) {
|
|
||||||
const conn = { callback, connected: true };
|
|
||||||
this.connections.push(conn);
|
|
||||||
return {
|
|
||||||
Disconnect: () => { conn.connected = false; },
|
|
||||||
disconnect: () => { conn.connected = false; },
|
|
||||||
Connected: () => conn.connected,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Legacy Roblox API — lowercase alias
|
|
||||||
connect(callback) { return this.Connect(callback); }
|
|
||||||
Wait() { return null; }
|
|
||||||
wait() { return null; }
|
|
||||||
Fire(...args) {
|
|
||||||
for (const c of this.connections) {
|
|
||||||
if (!c.connected) continue;
|
|
||||||
try { c.callback(...args); } catch (e) { /* swallow */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fire(...args) { return this.Fire(...args); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Instance прокси ──────── */
|
|
||||||
|
|
||||||
let _instanceCounter = 1;
|
|
||||||
|
|
||||||
// Null-stub: возвращается из FindFirstChild/WaitForChild когда объект не найден.
|
|
||||||
// Имеет все методы Instance как no-op, чтобы Lua-цепочки вроде
|
|
||||||
// game.Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("X").Touched:Connect(fn)
|
|
||||||
// не падали с "attempt to call js_null", когда промежуточный объект не существует.
|
|
||||||
// Все методы возвращают сам _nullStub чтобы цепочка не разорвалась.
|
|
||||||
// nullSignal: callable proxy. Lua делает x:Connect(fn) = x.Connect(x, fn),
|
|
||||||
// но также pattern signal:Connect(fn) сначала достаёт signal.Connect (это функция),
|
|
||||||
// потом вызывает её. Мы возвращаем функцию которая безусловно возвращает {Disconnect}.
|
|
||||||
const _nullConn = { Disconnect: () => {}, disconnect: () => {}, Connected: false };
|
|
||||||
const _nullSignalFn = () => _nullConn;
|
|
||||||
const _nullSignal = new Proxy(_nullSignalFn, {
|
|
||||||
get(_, k) {
|
|
||||||
if (k === 'Connect' || k === 'connect') return _nullSignalFn;
|
|
||||||
if (k === 'Wait' || k === 'wait') return () => null;
|
|
||||||
if (k === 'Fire' || k === 'fire') return () => {};
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Известные имена сигналов (Touched, Changed, MouseButton1Click, ...)
|
|
||||||
const _SIGNAL_NAMES = new Set([
|
|
||||||
'Touched','TouchEnded','Changed','Activated',
|
|
||||||
'MouseButton1Click','MouseButton1Down','MouseButton1Up',
|
|
||||||
'MouseButton2Click','MouseButton2Down','MouseButton2Up',
|
|
||||||
'MouseEnter','MouseLeave','InputBegan','InputEnded','InputChanged',
|
|
||||||
'PlayerAdded','PlayerRemoving','CharacterAdded','CharacterRemoving',
|
|
||||||
'Heartbeat','Stepped','RenderStepped','Died','HealthChanged',
|
|
||||||
'FocusLost','Focused','ChildAdded','ChildRemoved',
|
|
||||||
'AncestryChanged','DescendantAdded','DescendantRemoving',
|
|
||||||
// Tool сигналы
|
|
||||||
'Equipped','Unequipped','Selected','Deselected',
|
|
||||||
// прочие популярные
|
|
||||||
'OnInvoke','OnServerInvoke','OnClientInvoke',
|
|
||||||
'OnServerEvent','OnClientEvent','Fired','Triggered',
|
|
||||||
'ChatMakeSystemMessage','ChatMade',
|
|
||||||
]);
|
|
||||||
// _makeDeepStub — рекурсивный proxy которому всё равно сколько раз его
|
|
||||||
// индексируют. На любом уровне:
|
|
||||||
// - caps-имя из _SIGNAL_NAMES → возвращает _nullSignal
|
|
||||||
// - 'Parent' → возвращает _nullStub
|
|
||||||
// - любое другое имя → callable proxy + рекурсивная глубина
|
|
||||||
// Это позволяет цепочкам типа `tool.Selected:Connect(fn)` или
|
|
||||||
// `script.Parent.Parent.Frame.Visible` молча no-op'аться.
|
|
||||||
// Вместо JS Proxy (который wasmoon оборачивает в js_promise) — используем
|
|
||||||
// специальный маркер. Реальный stub живёт на Lua-стороне.
|
|
||||||
const NULL_STUB_MARKER = { __isNullStubMarker: true };
|
|
||||||
function _makeDeepStub() { return NULL_STUB_MARKER; }
|
|
||||||
const _nullStubBase = { __isNullStub: true, ClassName: 'Nil', Name: 'Nil', Children: [], Value: 0, Text: '', Visible: false };
|
|
||||||
// _nullStub оставлен как маркер, но не используется как реальный stub —
|
|
||||||
// debug.setmetatable(nil) в Lua перехватывает всё это.
|
|
||||||
const _nullStub = _nullStubBase;
|
|
||||||
|
|
||||||
class RbxInstance {
|
|
||||||
constructor(className, init = {}) {
|
|
||||||
this.__id = _instanceCounter++;
|
|
||||||
this.ClassName = className;
|
|
||||||
this.Name = init.Name || className;
|
|
||||||
this.Parent = init.Parent || null;
|
|
||||||
this.Children = [];
|
|
||||||
this.__props = {}; // raw properties (для Position и т.п.)
|
|
||||||
// Signals доступны как прямые свойства, плюс дублируются в __signals для serv-кода
|
|
||||||
this.Touched = new RbxSignal('Touched');
|
|
||||||
this.TouchEnded = new RbxSignal('TouchEnded');
|
|
||||||
this.Changed = new RbxSignal('Changed');
|
|
||||||
this.AncestryChanged = new RbxSignal('AncestryChanged');
|
|
||||||
this.ChildAdded = new RbxSignal('ChildAdded');
|
|
||||||
this.ChildRemoved = new RbxSignal('ChildRemoved');
|
|
||||||
this.__signals = {
|
|
||||||
Touched: this.Touched,
|
|
||||||
TouchEnded: this.TouchEnded,
|
|
||||||
Changed: this.Changed,
|
|
||||||
AncestryChanged: this.AncestryChanged,
|
|
||||||
ChildAdded: this.ChildAdded,
|
|
||||||
ChildRemoved: this.ChildRemoved,
|
|
||||||
};
|
|
||||||
this.__sceneState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
GetChildren() { return [...this.Children]; }
|
|
||||||
GetDescendants() {
|
|
||||||
const out = [];
|
|
||||||
const walk = (n) => {
|
|
||||||
for (const c of n.Children) { out.push(c); walk(c); }
|
|
||||||
};
|
|
||||||
walk(this);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
FindFirstChild(name, recursive) {
|
|
||||||
for (const c of this.Children) {
|
|
||||||
if (c.Name === name) return c;
|
|
||||||
if (recursive) {
|
|
||||||
const found = c.FindFirstChild(name, true);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Возвращаем undefined — wasmoon отдаст это как nil.
|
|
||||||
// Lua-side debug.setmetatable(nil) перехватит дальнейшую индексацию.
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
FindFirstChildOfClass(className) {
|
|
||||||
for (const c of this.Children) {
|
|
||||||
if (c.ClassName === className) return c;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
FindFirstAncestor(name) {
|
|
||||||
let p = this.Parent;
|
|
||||||
while (p) {
|
|
||||||
if (p.Name === name) return p;
|
|
||||||
p = p.Parent;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
WaitForChild(name, _timeout) {
|
|
||||||
// В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать.
|
|
||||||
return this.FindFirstChild(name);
|
|
||||||
}
|
|
||||||
IsA(className) {
|
|
||||||
if (this.ClassName === className) return true;
|
|
||||||
// Roblox class hierarchy: Part isA BasePart isA PVInstance isA Instance.
|
|
||||||
const hierarchy = {
|
|
||||||
'Part': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'WedgePart': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'CornerWedgePart': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'MeshPart': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'UnionOperation': ['PartOperation', 'BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'TrussPart': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'SpawnLocation': ['Part', 'BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'Script': ['BaseScript', 'LuaSourceContainer', 'Instance'],
|
|
||||||
'LocalScript': ['BaseScript', 'LuaSourceContainer', 'Instance'],
|
|
||||||
'ModuleScript': ['LuaSourceContainer', 'Instance'],
|
|
||||||
'Folder': ['Instance'],
|
|
||||||
'Model': ['PVInstance', 'Instance'],
|
|
||||||
'Sound': ['Instance'],
|
|
||||||
'PointLight': ['Light', 'Instance'],
|
|
||||||
'SpotLight': ['Light', 'Instance'],
|
|
||||||
'Humanoid': ['Instance'],
|
|
||||||
};
|
|
||||||
const ancestors = hierarchy[this.ClassName] || [];
|
|
||||||
return ancestors.includes(className);
|
|
||||||
}
|
|
||||||
Destroy() {
|
|
||||||
if (this.Parent && this.Parent.Children) {
|
|
||||||
const idx = this.Parent.Children.indexOf(this);
|
|
||||||
if (idx >= 0) this.Parent.Children.splice(idx, 1);
|
|
||||||
}
|
|
||||||
this.Parent = null;
|
|
||||||
this.__destroyed = true;
|
|
||||||
}
|
|
||||||
Clone() {
|
|
||||||
const cl = new RbxInstance(this.ClassName);
|
|
||||||
cl.Name = this.Name;
|
|
||||||
cl.__props = JSON.parse(JSON.stringify(this.__props));
|
|
||||||
for (const c of this.Children) {
|
|
||||||
const cc = c.Clone();
|
|
||||||
cc.Parent = cl;
|
|
||||||
cl.Children.push(cc);
|
|
||||||
}
|
|
||||||
return cl;
|
|
||||||
}
|
|
||||||
|
|
||||||
GetPropertyChangedSignal(propName) {
|
|
||||||
const sigName = `Changed:${propName}`;
|
|
||||||
if (!this.__signals[sigName]) this.__signals[sigName] = new RbxSignal(sigName);
|
|
||||||
return this.__signals[sigName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Part — наследник Instance с реальными свойствами сцены ──────── */
|
|
||||||
|
|
||||||
class RbxPart extends RbxInstance {
|
|
||||||
constructor(primId, init = {}) {
|
|
||||||
super(init.ClassName || 'Part', init);
|
|
||||||
this.__primId = primId; // id примитива в Rublox-сцене
|
|
||||||
this.__sendFn = null; // setter из shim init
|
|
||||||
// Кешированные свойства (mirror'ятся через handleTick)
|
|
||||||
this._snap = init.snap || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
get Position() {
|
|
||||||
return new RbxVector3(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
|
|
||||||
}
|
|
||||||
set Position(v) {
|
|
||||||
if (v instanceof RbxVector3) {
|
|
||||||
this._snap.x = v.X; this._snap.y = v.Y; this._snap.z = v.Z;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'position', value: { x: v.X, y: v.Y, z: v.Z } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get CFrame() {
|
|
||||||
return new RbxCFrame(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
|
|
||||||
}
|
|
||||||
set CFrame(cf) {
|
|
||||||
if (cf instanceof RbxCFrame) {
|
|
||||||
this._snap.x = cf.X; this._snap.y = cf.Y; this._snap.z = cf.Z;
|
|
||||||
const [rx, ry, rz] = cf.toEulerXYZ();
|
|
||||||
this._snap.rx = rx; this._snap.ry = ry; this._snap.rz = rz;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'cframe', value: { x: cf.X, y: cf.Y, z: cf.Z, rx, ry, rz } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get Size() {
|
|
||||||
return new RbxVector3(this._snap.sx || 1, this._snap.sy || 1, this._snap.sz || 1);
|
|
||||||
}
|
|
||||||
set Size(v) {
|
|
||||||
if (v instanceof RbxVector3) {
|
|
||||||
this._snap.sx = v.X; this._snap.sy = v.Y; this._snap.sz = v.Z;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'size', value: { sx: v.X, sy: v.Y, sz: v.Z } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get Color() { return RbxColor3.fromHex(this._snap.color || '#cccccc'); }
|
|
||||||
set Color(c) {
|
|
||||||
if (c instanceof RbxColor3) {
|
|
||||||
const hex = c.toHex();
|
|
||||||
this._snap.color = hex;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'color', value: hex });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get BrickColor() { return { Color: this.Color, Name: 'Medium stone grey' }; }
|
|
||||||
set BrickColor(b) { if (b && b.Color) this.Color = b.Color; }
|
|
||||||
get Material() { return this._snap.material || 'glossy'; }
|
|
||||||
set Material(m) {
|
|
||||||
this._snap.material = m;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'material', value: m });
|
|
||||||
}
|
|
||||||
get Anchored() { return !!this._snap.anchored; }
|
|
||||||
set Anchored(v) {
|
|
||||||
this._snap.anchored = !!v;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'anchored', value: !!v });
|
|
||||||
}
|
|
||||||
get CanCollide() { return this._snap.canCollide !== false; }
|
|
||||||
set CanCollide(v) {
|
|
||||||
this._snap.canCollide = !!v;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'canCollide', value: !!v });
|
|
||||||
}
|
|
||||||
get Transparency() { return 1.0 - (this._snap.opacity ?? 1.0); }
|
|
||||||
set Transparency(v) {
|
|
||||||
this._snap.opacity = 1.0 - (+v || 0);
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'opacity', value: this._snap.opacity });
|
|
||||||
}
|
|
||||||
get Velocity() { return new RbxVector3(0, 0, 0); }
|
|
||||||
set Velocity(v) {
|
|
||||||
if (v instanceof RbxVector3) {
|
|
||||||
this.__sendFn?.('partVel', { primId: this.__primId, vx: v.X, vy: v.Y, vz: v.Z });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Главный entry-point: регистрация в Lua-VM ──────── */
|
|
||||||
|
|
||||||
export function registerRobloxApi(lua, ctx) {
|
|
||||||
const { getSceneSnap, targetPrimitiveId, send, getGuiTree, scheduler } = ctx;
|
|
||||||
|
|
||||||
// 1. Math classes — как глобалы с .new factory
|
|
||||||
const wrap = (cls) => ({
|
|
||||||
new: (...args) => new cls(...args),
|
|
||||||
});
|
|
||||||
|
|
||||||
lua.global.set('Vector3', {
|
|
||||||
new: (x, y, z) => new RbxVector3(x, y, z),
|
|
||||||
zero: new RbxVector3(0, 0, 0),
|
|
||||||
one: new RbxVector3(1, 1, 1),
|
|
||||||
xAxis: new RbxVector3(1, 0, 0),
|
|
||||||
yAxis: new RbxVector3(0, 1, 0),
|
|
||||||
zAxis: new RbxVector3(0, 0, 1),
|
|
||||||
});
|
|
||||||
lua.global.set('Color3', {
|
|
||||||
new: (r, g, b) => new RbxColor3(r, g, b),
|
|
||||||
fromRGB: RbxColor3.fromRGB,
|
|
||||||
fromHex: RbxColor3.fromHex,
|
|
||||||
});
|
|
||||||
lua.global.set('CFrame', {
|
|
||||||
new: RbxCFrame.new,
|
|
||||||
Angles: RbxCFrame.Angles,
|
|
||||||
fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
|
|
||||||
});
|
|
||||||
lua.global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
|
|
||||||
lua.global.set('UDim2', {
|
|
||||||
new: RbxUDim2.new,
|
|
||||||
fromScale: RbxUDim2.fromScale,
|
|
||||||
fromOffset: RbxUDim2.fromOffset,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Сцена — собираем JS-структуру из snap'а
|
|
||||||
// Workspace — корень.
|
|
||||||
const workspace = new RbxInstance('Workspace', { Name: 'Workspace' });
|
|
||||||
const part_by_id = new Map();
|
|
||||||
const snap = getSceneSnap();
|
|
||||||
if (snap && snap.primitives) {
|
|
||||||
for (const [id, p] of Object.entries(snap.primitives)) {
|
|
||||||
const part = new RbxPart(+id, {
|
|
||||||
ClassName: p.type === 'wedge' ? 'WedgePart' :
|
|
||||||
p.type === 'cornerwedge' ? 'CornerWedgePart' : 'Part',
|
|
||||||
Name: p.name || 'Part',
|
|
||||||
snap: { ...p },
|
|
||||||
});
|
|
||||||
part.__sendFn = send;
|
|
||||||
// Сигналы Part: Touched/TouchEnded существуют на каждом по умолчанию
|
|
||||||
part.Touched = new RbxSignal('Touched');
|
|
||||||
part.TouchEnded = new RbxSignal('TouchEnded');
|
|
||||||
part.Parent = workspace;
|
|
||||||
workspace.Children.push(part);
|
|
||||||
part_by_id.set(+id, part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2b. GUI-tree: предсоздаём ScreenGui + Frame/Button/Label/etc по дереву
|
|
||||||
// конвертера. Каждый button получает MouseButton1Click/MouseButton1Down/Up
|
|
||||||
// сигналы которые fire'аются из main через sendGlobalEvent('guiClick').
|
|
||||||
const gui_by_id = new Map();
|
|
||||||
// PlayerGui контейнер внутри Players.LocalPlayer
|
|
||||||
const playerGui = new RbxInstance('PlayerGui', { Name: 'PlayerGui' });
|
|
||||||
if (getGuiTree) {
|
|
||||||
const tree = getGuiTree() || [];
|
|
||||||
// первый проход — создаём instances
|
|
||||||
for (const el of tree) {
|
|
||||||
const cls = el.__roblox_class || 'Frame';
|
|
||||||
const inst = new RbxInstance(cls, { Name: el.name || cls });
|
|
||||||
inst.__guiId = el.id;
|
|
||||||
inst.Visible = el.visible !== false;
|
|
||||||
inst.Text = el.text || '';
|
|
||||||
// Стандартные сигналы кнопок
|
|
||||||
if (cls === 'TextButton' || cls === 'ImageButton') {
|
|
||||||
inst.MouseButton1Click = new RbxSignal('MouseButton1Click');
|
|
||||||
inst.MouseButton1Down = new RbxSignal('MouseButton1Down');
|
|
||||||
inst.MouseButton1Up = new RbxSignal('MouseButton1Up');
|
|
||||||
inst.Activated = new RbxSignal('Activated');
|
|
||||||
inst.MouseEnter = new RbxSignal('MouseEnter');
|
|
||||||
inst.MouseLeave = new RbxSignal('MouseLeave');
|
|
||||||
}
|
|
||||||
// FocusLost для textboxes
|
|
||||||
if (cls === 'TextBox') {
|
|
||||||
inst.FocusLost = new RbxSignal('FocusLost');
|
|
||||||
inst.Focused = new RbxSignal('Focused');
|
|
||||||
}
|
|
||||||
// Changed-сигнал у каждого
|
|
||||||
inst.Changed = new RbxSignal('Changed');
|
|
||||||
gui_by_id.set(el.id, inst);
|
|
||||||
}
|
|
||||||
// второй проход — parent-связи (parentId → Instance)
|
|
||||||
for (const el of tree) {
|
|
||||||
const inst = gui_by_id.get(el.id);
|
|
||||||
if (!inst) continue;
|
|
||||||
const parentInst = el.parentId ? gui_by_id.get(el.parentId) : playerGui;
|
|
||||||
if (parentInst) {
|
|
||||||
inst.Parent = parentInst;
|
|
||||||
parentInst.Children.push(inst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. script — в shared-режиме не глобал, а локально создаётся при addScript.
|
|
||||||
// Здесь только заглушка чтобы простые non-shared скрипты не падали.
|
|
||||||
if (targetPrimitiveId != null && part_by_id.has(targetPrimitiveId)) {
|
|
||||||
const parentPart = part_by_id.get(targetPrimitiveId);
|
|
||||||
const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' });
|
|
||||||
scriptInst.Parent = parentPart;
|
|
||||||
parentPart.Children.push(scriptInst);
|
|
||||||
lua.global.set('script', scriptInst);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. game / game:GetService
|
|
||||||
const services = new Map();
|
|
||||||
const game = new RbxInstance('DataModel', { Name: 'Game' });
|
|
||||||
game.Children.push(workspace);
|
|
||||||
workspace.Parent = game;
|
|
||||||
|
|
||||||
// Builtin services:
|
|
||||||
const lighting = new RbxInstance('Lighting', { Name: 'Lighting' });
|
|
||||||
lighting.Parent = game;
|
|
||||||
game.Children.push(lighting);
|
|
||||||
services.set('Lighting', lighting);
|
|
||||||
|
|
||||||
const replicatedStorage = new RbxInstance('ReplicatedStorage', { Name: 'ReplicatedStorage' });
|
|
||||||
replicatedStorage.Parent = game;
|
|
||||||
game.Children.push(replicatedStorage);
|
|
||||||
services.set('ReplicatedStorage', replicatedStorage);
|
|
||||||
|
|
||||||
const runService = new RbxInstance('RunService', { Name: 'RunService' });
|
|
||||||
runService.Heartbeat = new RbxSignal('Heartbeat');
|
|
||||||
runService.Stepped = new RbxSignal('Stepped');
|
|
||||||
runService.RenderStepped = new RbxSignal('RenderStepped');
|
|
||||||
services.set('RunService', runService);
|
|
||||||
|
|
||||||
const playersService = new RbxInstance('Players', { Name: 'Players' });
|
|
||||||
playersService.PlayerAdded = new RbxSignal('PlayerAdded');
|
|
||||||
playersService.PlayerRemoving = new RbxSignal('PlayerRemoving');
|
|
||||||
// LocalPlayer с PlayerGui + Character
|
|
||||||
const localPlayer = new RbxInstance('Player', { Name: 'Player1' });
|
|
||||||
localPlayer.UserId = 1;
|
|
||||||
localPlayer.PlayerGui = playerGui;
|
|
||||||
playerGui.Parent = localPlayer;
|
|
||||||
localPlayer.Children.push(playerGui);
|
|
||||||
// Character заглушка с Humanoid и HumanoidRootPart
|
|
||||||
const character = new RbxInstance('Model', { Name: 'Character' });
|
|
||||||
const humanoid = new RbxInstance('Humanoid', { Name: 'Humanoid' });
|
|
||||||
humanoid.WalkSpeed = 16;
|
|
||||||
humanoid.JumpPower = 50;
|
|
||||||
humanoid.Health = 100;
|
|
||||||
humanoid.MaxHealth = 100;
|
|
||||||
humanoid.Died = new RbxSignal('Died');
|
|
||||||
humanoid.HealthChanged = new RbxSignal('HealthChanged');
|
|
||||||
humanoid.Touched = new RbxSignal('Touched');
|
|
||||||
humanoid.Parent = character;
|
|
||||||
character.Children.push(humanoid);
|
|
||||||
character.Humanoid = humanoid;
|
|
||||||
const hrp = new RbxPart(-1, { ClassName: 'Part', Name: 'HumanoidRootPart' });
|
|
||||||
hrp.Touched = new RbxSignal('Touched');
|
|
||||||
hrp.Parent = character;
|
|
||||||
character.Children.push(hrp);
|
|
||||||
character.HumanoidRootPart = hrp;
|
|
||||||
localPlayer.Character = character;
|
|
||||||
localPlayer.CharacterAdded = new RbxSignal('CharacterAdded');
|
|
||||||
localPlayer.CharacterRemoving = new RbxSignal('CharacterRemoving');
|
|
||||||
playersService.LocalPlayer = localPlayer;
|
|
||||||
playersService.Children.push(localPlayer);
|
|
||||||
services.set('Players', playersService);
|
|
||||||
|
|
||||||
game.GetService = function(svc) {
|
|
||||||
if (services.has(svc)) return services.get(svc);
|
|
||||||
if (svc === 'Workspace') return workspace;
|
|
||||||
if (svc === 'Workspace') return workspace;
|
|
||||||
// Неизвестный сервис — создаём заглушку, чтобы не падало
|
|
||||||
const stub = new RbxInstance(svc, { Name: svc });
|
|
||||||
services.set(svc, stub);
|
|
||||||
return stub;
|
|
||||||
};
|
|
||||||
game.Workspace = workspace;
|
|
||||||
game.Lighting = lighting;
|
|
||||||
game.Players = playersService;
|
|
||||||
game.ReplicatedStorage = replicatedStorage;
|
|
||||||
|
|
||||||
lua.global.set('game', game);
|
|
||||||
lua.global.set('workspace', workspace);
|
|
||||||
lua.global.set('Workspace', workspace);
|
|
||||||
|
|
||||||
// 5. Instance.new
|
|
||||||
lua.global.set('Instance', {
|
|
||||||
new: (className, parent) => {
|
|
||||||
const inst = new RbxInstance(className);
|
|
||||||
if (parent && parent instanceof RbxInstance) {
|
|
||||||
inst.Parent = parent;
|
|
||||||
parent.Children.push(inst);
|
|
||||||
}
|
|
||||||
return inst;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. wait/task.wait через scheduler. scheduler — main-side, поддерживает
|
|
||||||
// schedule(sec, fn) что fire'ит fn после задержки в следующих tick'ах.
|
|
||||||
// spawn/delay/defer запускают функцию через scheduler.spawn (отдельная корутина).
|
|
||||||
const sched = scheduler || {
|
|
||||||
schedule: (sec, fn) => { try { fn(); } catch (e) {} },
|
|
||||||
spawn: (fn) => { try { fn(); } catch (e) {} },
|
|
||||||
now: () => Date.now() / 1000,
|
|
||||||
};
|
|
||||||
lua.global.set('wait', (sec) => {
|
|
||||||
// В корутине: yield на (sec || 0). Scheduler сам resume'ит.
|
|
||||||
// Тут мы синхронны (вызов из Lua) — реальный yield делается в lua-wrapper
|
|
||||||
// через coroutine.yield, который мы оборачиваем в addScript.
|
|
||||||
// Здесь просто возвращаем длительность для совместимости.
|
|
||||||
return [sec || 0, 0];
|
|
||||||
});
|
|
||||||
lua.global.set('task', {
|
|
||||||
wait: (sec) => sec || 0,
|
|
||||||
spawn: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
|
|
||||||
delay: (sec, fn, ...args) => { sched.schedule(sec || 0, () => { try { fn(...args); } catch (e) {} }); return null; },
|
|
||||||
defer: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
|
|
||||||
});
|
|
||||||
lua.global.set('spawn', (fn) => { sched.spawn(() => { try { fn(); } catch (e) {} }); });
|
|
||||||
lua.global.set('delay', (sec, fn) => { sched.schedule(sec || 0, () => { try { fn(); } catch (e) {} }); });
|
|
||||||
// require(ModuleScript) — возвращаем nil, debug.setmetatable перехватит.
|
|
||||||
lua.global.set('require', (_arg) => undefined);
|
|
||||||
lua.global.set('tick', () => Date.now() / 1000);
|
|
||||||
lua.global.set('time', () => Date.now() / 1000);
|
|
||||||
lua.global.set('elapsedTime', () => Date.now() / 1000);
|
|
||||||
|
|
||||||
// 7. print / warn / error — пробрасываем в main как log
|
|
||||||
lua.global.set('print', (...args) => {
|
|
||||||
const text = args.map(a => luaToString(a)).join('\t');
|
|
||||||
send('log', { level: 'info', text });
|
|
||||||
});
|
|
||||||
lua.global.set('warn', (...args) => {
|
|
||||||
const text = args.map(a => luaToString(a)).join('\t');
|
|
||||||
send('log', { level: 'warn', text });
|
|
||||||
});
|
|
||||||
|
|
||||||
// 8. Enum — упрощённая заглушка для самых популярных enums
|
|
||||||
const enumTable = {
|
|
||||||
Material: { Plastic: { Value: 256, Name: 'Plastic' }, Neon: { Value: 784, Name: 'Neon' },
|
|
||||||
Metal: { Value: 512, Name: 'Metal' }, Glass: { Value: 1024, Name: 'Glass' },
|
|
||||||
Wood: { Value: 272, Name: 'Wood' }, SmoothPlastic: { Value: 496, Name: 'SmoothPlastic' } },
|
|
||||||
PartType: { Ball: { Value: 0, Name: 'Ball' }, Block: { Value: 1, Name: 'Block' },
|
|
||||||
Cylinder: { Value: 2, Name: 'Cylinder' } },
|
|
||||||
KeyCode: { Space: { Value: 32, Name: 'Space' }, W: { Value: 87, Name: 'W' },
|
|
||||||
A: { Value: 65, Name: 'A' }, S: { Value: 83, Name: 'S' }, D: { Value: 68, Name: 'D' } },
|
|
||||||
EasingStyle: { Linear: { Value: 0, Name: 'Linear' }, Quad: { Value: 1, Name: 'Quad' },
|
|
||||||
Sine: { Value: 5, Name: 'Sine' } },
|
|
||||||
EasingDirection: { In: { Value: 0, Name: 'In' }, Out: { Value: 1, Name: 'Out' },
|
|
||||||
InOut: { Value: 2, Name: 'InOut' } },
|
|
||||||
};
|
|
||||||
lua.global.set('Enum', enumTable);
|
|
||||||
|
|
||||||
return { workspace, game, part_by_id, services, gui_by_id, localPlayer, character, humanoid };
|
|
||||||
}
|
|
||||||
|
|
||||||
function luaToString(v) {
|
|
||||||
if (v == null) return 'nil';
|
|
||||||
if (typeof v === 'string') return v;
|
|
||||||
if (typeof v === 'number') return String(v);
|
|
||||||
if (typeof v === 'boolean') return String(v);
|
|
||||||
if (v.toString) return v.toString();
|
|
||||||
return '<object>';
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal };
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-tween.js — TweenService для Roblox Lua-shim.
|
|
||||||
*
|
|
||||||
* Использование в Lua:
|
|
||||||
* local TS = game:GetService("TweenService")
|
|
||||||
* local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
|
|
||||||
* local tween = TS:Create(part, info, {Position = Vector3.new(0, 10, 0)})
|
|
||||||
* tween:Play()
|
|
||||||
* tween.Completed:Connect(function() print("done") end)
|
|
||||||
*
|
|
||||||
* Реализация:
|
|
||||||
* - Все активные tween'ы держатся в этом модуле.
|
|
||||||
* - На каждом tick() прогрессируется alpha = (now - startTime) / duration.
|
|
||||||
* - Применяется easing-кривая, и обновляется свойство объекта через __sendFn.
|
|
||||||
* - При alpha >= 1 — fire Completed signal и удаляем tween.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RbxSignal, RbxVector3, RbxColor3, RbxCFrame, RbxUDim2 } from './roblox-shim.js';
|
|
||||||
|
|
||||||
/* ──────── EasingStyle / Direction ──────── */
|
|
||||||
|
|
||||||
const EASING_FNS = {
|
|
||||||
'Linear': (t) => t,
|
|
||||||
'Quad': (t) => t * t,
|
|
||||||
'Cubic': (t) => t * t * t,
|
|
||||||
'Quart': (t) => t * t * t * t,
|
|
||||||
'Quint': (t) => t * t * t * t * t,
|
|
||||||
'Sine': (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
||||||
'Bounce': (t) => {
|
|
||||||
const n1 = 7.5625, d1 = 2.75;
|
|
||||||
if (t < 1 / d1) return n1 * t * t;
|
|
||||||
if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; }
|
|
||||||
if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; }
|
|
||||||
t -= 2.625 / d1; return n1 * t * t + 0.984375;
|
|
||||||
},
|
|
||||||
'Elastic': (t) => {
|
|
||||||
if (t === 0) return 0;
|
|
||||||
if (t === 1) return 1;
|
|
||||||
return -(2 ** (10 * (t - 1))) * Math.sin((t - 1.1) * 5 * Math.PI);
|
|
||||||
},
|
|
||||||
'Back': (t) => t * t * (2.70158 * t - 1.70158),
|
|
||||||
'Exponential': (t) => t === 0 ? 0 : 2 ** (10 * (t - 1)),
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyDirection(t, direction) {
|
|
||||||
if (direction === 'In') return t;
|
|
||||||
if (direction === 'Out') return 1 - (1 - t);
|
|
||||||
if (direction === 'InOut') {
|
|
||||||
return t < 0.5 ? t * 2 : (1 - (1 - t) * 2);
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeValue(alpha, style, direction) {
|
|
||||||
const styleFn = EASING_FNS[style] || EASING_FNS.Linear;
|
|
||||||
if (direction === 'In') return styleFn(alpha);
|
|
||||||
if (direction === 'Out') return 1 - styleFn(1 - alpha);
|
|
||||||
// InOut
|
|
||||||
if (alpha < 0.5) return styleFn(alpha * 2) / 2;
|
|
||||||
return 1 - styleFn((1 - alpha) * 2) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── TweenInfo ──────── */
|
|
||||||
|
|
||||||
class RbxTweenInfo {
|
|
||||||
constructor(time = 1, easingStyle = 'Quad', easingDirection = 'Out',
|
|
||||||
repeatCount = 0, reverses = false, delayTime = 0) {
|
|
||||||
this.Time = +time || 0;
|
|
||||||
this.EasingStyle = typeof easingStyle === 'object' ? easingStyle.Name : easingStyle;
|
|
||||||
this.EasingDirection = typeof easingDirection === 'object' ? easingDirection.Name : easingDirection;
|
|
||||||
this.RepeatCount = repeatCount | 0;
|
|
||||||
this.Reverses = !!reverses;
|
|
||||||
this.DelayTime = +delayTime || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Tween ──────── */
|
|
||||||
|
|
||||||
class RbxTween {
|
|
||||||
constructor(instance, info, goalProps, manager) {
|
|
||||||
this.Instance = instance;
|
|
||||||
this.TweenInfo = info;
|
|
||||||
this.GoalProps = goalProps;
|
|
||||||
this._manager = manager;
|
|
||||||
this._startTime = null;
|
|
||||||
this._fromProps = null;
|
|
||||||
this._playing = false;
|
|
||||||
this._completed = false;
|
|
||||||
this.Completed = new RbxSignal('Completed');
|
|
||||||
this.PlaybackState = 'Begin';
|
|
||||||
}
|
|
||||||
|
|
||||||
Play() {
|
|
||||||
if (this._playing) return;
|
|
||||||
// Снимок старых значений
|
|
||||||
this._fromProps = {};
|
|
||||||
for (const k of Object.keys(this.GoalProps)) {
|
|
||||||
this._fromProps[k] = this.Instance[k]; // через getter Part'а
|
|
||||||
}
|
|
||||||
this._startTime = this._manager.time;
|
|
||||||
this._playing = true;
|
|
||||||
this.PlaybackState = 'Playing';
|
|
||||||
this._manager._add(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
Pause() { this._playing = false; this.PlaybackState = 'Paused'; }
|
|
||||||
Cancel() {
|
|
||||||
this._playing = false;
|
|
||||||
this.PlaybackState = 'Cancelled';
|
|
||||||
this._manager._remove(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** internal — вызывается из manager.tick */
|
|
||||||
_step(now) {
|
|
||||||
if (!this._playing) return false;
|
|
||||||
const elapsed = now - this._startTime;
|
|
||||||
const dur = this.TweenInfo.Time || 0.001;
|
|
||||||
let alpha = Math.min(1, Math.max(0, elapsed / dur));
|
|
||||||
const ea = easeValue(alpha, this.TweenInfo.EasingStyle, this.TweenInfo.EasingDirection);
|
|
||||||
for (const k of Object.keys(this.GoalProps)) {
|
|
||||||
const from = this._fromProps[k];
|
|
||||||
const to = this.GoalProps[k];
|
|
||||||
const interp = interpolate(from, to, ea);
|
|
||||||
// Set через setter в Part — он отправит partSet в main
|
|
||||||
try { this.Instance[k] = interp; } catch (e) {}
|
|
||||||
}
|
|
||||||
if (alpha >= 1) {
|
|
||||||
this._playing = false;
|
|
||||||
this._completed = true;
|
|
||||||
this.PlaybackState = 'Completed';
|
|
||||||
this.Completed.Fire('Completed');
|
|
||||||
return true; // удалить из активных
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function interpolate(from, to, a) {
|
|
||||||
if (from instanceof RbxVector3 && to instanceof RbxVector3) {
|
|
||||||
return from.Lerp(to, a);
|
|
||||||
}
|
|
||||||
if (from instanceof RbxColor3 && to instanceof RbxColor3) {
|
|
||||||
return from.Lerp(to, a);
|
|
||||||
}
|
|
||||||
if (from instanceof RbxCFrame && to instanceof RbxCFrame) {
|
|
||||||
return from.Lerp(to, a);
|
|
||||||
}
|
|
||||||
if (typeof from === 'number' && typeof to === 'number') {
|
|
||||||
return from + (to - from) * a;
|
|
||||||
}
|
|
||||||
// Иначе ничего не интерполируем
|
|
||||||
return a >= 1 ? to : from;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Manager ──────── */
|
|
||||||
|
|
||||||
export class RobloxTweenManager {
|
|
||||||
constructor() {
|
|
||||||
this.active = new Set();
|
|
||||||
this.time = 0;
|
|
||||||
}
|
|
||||||
install(lua) {
|
|
||||||
const self = this;
|
|
||||||
// TweenInfo конструктор
|
|
||||||
lua.global.set('TweenInfo', {
|
|
||||||
new: (time, style, direction, repeat_, reverses, delay_) =>
|
|
||||||
new RbxTweenInfo(time, style, direction, repeat_, reverses, delay_),
|
|
||||||
});
|
|
||||||
// Сервис: добавляем в services через game:GetService('TweenService')
|
|
||||||
// (services map передаётся в shim — но мы не имеем к нему доступа здесь;
|
|
||||||
// делаем по-другому: регистрируем сразу глобал TweenService который
|
|
||||||
// совместим с GetService('TweenService'))
|
|
||||||
const tweenService = {
|
|
||||||
ClassName: 'TweenService',
|
|
||||||
Name: 'TweenService',
|
|
||||||
Create(instance, info, goalProps) {
|
|
||||||
return new RbxTween(instance, info, goalProps, self);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
lua.global.set('__tweenService', tweenService);
|
|
||||||
// и в game.GetService — мы делаем монки-патч если игра уже есть:
|
|
||||||
const game = lua.global.get('game');
|
|
||||||
if (game && typeof game.GetService === 'function') {
|
|
||||||
const origGetService = game.GetService;
|
|
||||||
game.GetService = function(svc) {
|
|
||||||
if (svc === 'TweenService') return tweenService;
|
|
||||||
return origGetService.call(this, svc);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_add(tween) { this.active.add(tween); }
|
|
||||||
_remove(tween) { this.active.delete(tween); }
|
|
||||||
|
|
||||||
tick(dtSec) {
|
|
||||||
this.time += +dtSec || 0;
|
|
||||||
for (const t of [...this.active]) {
|
|
||||||
const done = t._step(this.time);
|
|
||||||
if (done) this.active.delete(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RbxTweenInfo, RbxTween };
|
|
||||||
249
src/editor/lua-monaco-setup.js
Normal file
249
src/editor/lua-monaco-setup.js
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* lua-monaco-setup — регистрация Lua-фич в Monaco:
|
||||||
|
* 1) Подсветка через встроенный 'lua' language (Monaco поставляется с basic-languages/lua)
|
||||||
|
* 2) Автодополнение Roblox-API (Vector3.new, Color3.fromRGB, script.Parent, game.Players, ...)
|
||||||
|
* 3) Hover-документация (наведя на Vector3 — описание + пример)
|
||||||
|
* 4) Подсветка ошибок через luaparse (на этапе 7, опционально)
|
||||||
|
*
|
||||||
|
* Регистрируется ОДИН раз глобально через флаг monaco.__rbxLuaRegistered.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROBLOX_LUA_API = [
|
||||||
|
// === Глобальные функции ===
|
||||||
|
{ kind: 'function', name: 'print', insertText: 'print($0)', doc: 'Выводит сообщения в Output-панель.\n```lua\nprint("Привет", x, y)\n```' },
|
||||||
|
{ kind: 'function', name: 'warn', insertText: 'warn($0)', doc: 'Выводит предупреждение (жёлтым).\n```lua\nwarn("Что-то не так")\n```' },
|
||||||
|
{ kind: 'function', name: 'error', insertText: 'error(${1:"сообщение"})', doc: 'Бросает ошибку, останавливая текущий скрипт.\n```lua\nerror("Здоровье < 0")\n```' },
|
||||||
|
{ kind: 'function', name: 'wait', insertText: 'wait(${1:1})', doc: 'Приостанавливает скрипт на N секунд (заменяется на `task.wait` в новом коде).' },
|
||||||
|
{ kind: 'function', name: 'tick', insertText: 'tick()', doc: 'Возвращает количество секунд с эпохи (как `os.time()`, но дробное).' },
|
||||||
|
{ kind: 'function', name: 'pcall', insertText: 'pcall(${1:fn}, $0)', doc: 'Защищённый вызов. Возвращает `success, result|error`.\n```lua\nlocal ok, err = pcall(function() risky() end)\nif not ok then warn(err) end\n```' },
|
||||||
|
{ kind: 'function', name: 'xpcall', insertText: 'xpcall(${1:fn}, ${2:handler})', doc: 'Защищённый вызов с кастомным обработчиком ошибки.' },
|
||||||
|
{ kind: 'function', name: 'tostring', insertText: 'tostring($0)', doc: 'Преобразует значение в строку.' },
|
||||||
|
{ kind: 'function', name: 'tonumber', insertText: 'tonumber($0)', doc: 'Преобразует строку в число. Возвращает nil если не число.' },
|
||||||
|
{ kind: 'function', name: 'type', insertText: 'type($0)', doc: 'Возвращает строку с типом: "nil", "number", "string", "boolean", "table", "function", "userdata".' },
|
||||||
|
{ kind: 'function', name: 'typeof', insertText: 'typeof($0)', doc: 'Расширенная версия type — для Roblox-типов вернёт "Vector3", "CFrame", "Color3", "Instance".' },
|
||||||
|
{ kind: 'function', name: 'ipairs', insertText: 'ipairs(${1:t})', doc: 'Итератор по числовым ключам массива.\n```lua\nfor i, v in ipairs(arr) do ... end\n```' },
|
||||||
|
{ kind: 'function', name: 'pairs', insertText: 'pairs(${1:t})', doc: 'Итератор по всем ключам таблицы.\n```lua\nfor k, v in pairs(t) do ... end\n```' },
|
||||||
|
{ kind: 'function', name: 'next', insertText: 'next(${1:t}, $0)', doc: 'Возвращает следующую пару ключ-значение в таблице.' },
|
||||||
|
{ kind: 'function', name: 'select', insertText: 'select(${1:1}, $0)', doc: 'select("#", ...) — количество аргументов. select(n, ...) — n-й и далее аргументы.' },
|
||||||
|
{ kind: 'function', name: 'unpack', insertText: 'unpack(${1:t})', doc: 'Распаковывает массив в значения. (В Lua 5.4 — `table.unpack`)' },
|
||||||
|
{ kind: 'function', name: 'setmetatable', insertText: 'setmetatable(${1:t}, ${2:mt})', doc: 'Устанавливает metatable для таблицы.' },
|
||||||
|
{ kind: 'function', name: 'getmetatable', insertText: 'getmetatable($0)', doc: 'Возвращает metatable или nil.' },
|
||||||
|
{ kind: 'function', name: 'rawget', insertText: 'rawget(${1:t}, ${2:key})', doc: 'Чтение без вызова __index metatable.' },
|
||||||
|
{ kind: 'function', name: 'rawset', insertText: 'rawset(${1:t}, ${2:key}, ${3:value})', doc: 'Запись без вызова __newindex metatable.' },
|
||||||
|
|
||||||
|
// === task.* ===
|
||||||
|
{ kind: 'module', name: 'task', insertText: 'task', doc: 'Современный API планировщика Roblox-Lua.\nЗаменяет `wait`, `spawn`, `delay`, `defer` из старого API.' },
|
||||||
|
{ kind: 'function', name: 'task.wait', insertText: 'task.wait(${1:1})', doc: 'Приостанавливает на N секунд.\nВозвращает фактическое время ожидания.\n```lua\nlocal dt = task.wait(0.5)\n```' },
|
||||||
|
{ kind: 'function', name: 'task.spawn', insertText: 'task.spawn(${1:function() end})', doc: 'Немедленно запускает функцию как coroutine.\n```lua\ntask.spawn(function() heavy() end)\n```' },
|
||||||
|
{ kind: 'function', name: 'task.delay', insertText: 'task.delay(${1:1}, ${2:function() end})', doc: 'Отложенный запуск функции через N секунд.\n```lua\ntask.delay(3, function() print("через 3 сек") end)\n```' },
|
||||||
|
{ kind: 'function', name: 'task.defer', insertText: 'task.defer(${1:function() end})', doc: 'Запуск в следующем кадре (после Heartbeat).' },
|
||||||
|
|
||||||
|
// === Vector3 ===
|
||||||
|
{ kind: 'class', name: 'Vector3', insertText: 'Vector3', doc: '3D-вектор в Roblox.\nКонструктор: `Vector3.new(x, y, z)`.\nКонстанты: `Vector3.zero`, `Vector3.one`, `Vector3.xAxis`, `Vector3.yAxis`, `Vector3.zAxis`.' },
|
||||||
|
{ kind: 'function', name: 'Vector3.new', insertText: 'Vector3.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт `Vector3(x, y, z)`.\n```lua\nlocal v = Vector3.new(10, 5, 0)\nprint(v.X, v.Y, v.Z, v.Magnitude)\n```' },
|
||||||
|
{ kind: 'function', name: 'Vector3.zero', insertText: 'Vector3.zero', doc: '`Vector3(0, 0, 0)`.' },
|
||||||
|
{ kind: 'function', name: 'Vector3.one', insertText: 'Vector3.one', doc: '`Vector3(1, 1, 1)`.' },
|
||||||
|
{ kind: 'function', name: 'Vector3.xAxis', insertText: 'Vector3.xAxis', doc: '`Vector3(1, 0, 0)`.' },
|
||||||
|
{ kind: 'function', name: 'Vector3.yAxis', insertText: 'Vector3.yAxis', doc: '`Vector3(0, 1, 0)`.' },
|
||||||
|
{ kind: 'function', name: 'Vector3.zAxis', insertText: 'Vector3.zAxis', doc: '`Vector3(0, 0, 1)`.' },
|
||||||
|
|
||||||
|
// === Color3 ===
|
||||||
|
{ kind: 'class', name: 'Color3', insertText: 'Color3', doc: 'Цвет RGB в Roblox.\nКомпоненты `R`, `G`, `B` в диапазоне [0, 1].' },
|
||||||
|
{ kind: 'function', name: 'Color3.new', insertText: 'Color3.new(${1:1}, ${2:1}, ${3:1})', doc: 'Создаёт `Color3(r, g, b)`, где компоненты в [0, 1].' },
|
||||||
|
{ kind: 'function', name: 'Color3.fromRGB', insertText: 'Color3.fromRGB(${1:255}, ${2:255}, ${3:255})', doc: 'Создаёт `Color3` из 0-255 RGB.\n```lua\nlocal red = Color3.fromRGB(255, 0, 0)\n```' },
|
||||||
|
{ kind: 'function', name: 'Color3.fromHSV', insertText: 'Color3.fromHSV(${1:0}, ${2:1}, ${3:1})', doc: 'Создаёт цвет из HSV-компонентов в [0, 1].' },
|
||||||
|
{ kind: 'function', name: 'Color3.fromHex', insertText: 'Color3.fromHex(${1:"#FF0000"})', doc: 'Создаёт цвет из hex-строки.' },
|
||||||
|
|
||||||
|
// === CFrame ===
|
||||||
|
{ kind: 'class', name: 'CFrame', insertText: 'CFrame', doc: 'Coordinate Frame — позиция + поворот в 3D.\nИспользуется для трансформаций Part.CFrame.' },
|
||||||
|
{ kind: 'function', name: 'CFrame.new', insertText: 'CFrame.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт CFrame в указанной позиции.' },
|
||||||
|
{ kind: 'function', name: 'CFrame.lookAt', insertText: 'CFrame.lookAt(${1:eye}, ${2:target})', doc: 'CFrame, направленный из eye на target.' },
|
||||||
|
{ kind: 'function', name: 'CFrame.Angles', insertText: 'CFrame.Angles(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame только с поворотом (в радианах).' },
|
||||||
|
{ kind: 'function', name: 'CFrame.fromEulerAnglesXYZ', insertText: 'CFrame.fromEulerAnglesXYZ(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame с поворотом по эйлеровым углам.' },
|
||||||
|
|
||||||
|
// === UDim2 / Vector2 ===
|
||||||
|
{ kind: 'class', name: 'UDim2', insertText: 'UDim2', doc: 'Размер/позиция GUI: процент + пиксели по обеим осям.' },
|
||||||
|
{ kind: 'function', name: 'UDim2.new', insertText: 'UDim2.new(${1:0}, ${2:0}, ${3:0}, ${4:0})', doc: '`UDim2.new(scaleX, offsetX, scaleY, offsetY)`.\n```lua\nframe.Position = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана\n```' },
|
||||||
|
{ kind: 'function', name: 'UDim2.fromScale', insertText: 'UDim2.fromScale(${1:0.5}, ${2:0.5})', doc: 'Только процентные размеры.' },
|
||||||
|
{ kind: 'function', name: 'UDim2.fromOffset', insertText: 'UDim2.fromOffset(${1:100}, ${2:100})', doc: 'Только пиксельные размеры.' },
|
||||||
|
{ kind: 'class', name: 'Vector2', insertText: 'Vector2', doc: '2D-вектор.' },
|
||||||
|
{ kind: 'function', name: 'Vector2.new', insertText: 'Vector2.new(${1:0}, ${2:0})', doc: '`Vector2(x, y)`.' },
|
||||||
|
{ kind: 'class', name: 'UDim', insertText: 'UDim', doc: 'Одномерная UDim (scale + offset).' },
|
||||||
|
{ kind: 'function', name: 'UDim.new', insertText: 'UDim.new(${1:0}, ${2:0})', doc: '`UDim.new(scale, offset)`.' },
|
||||||
|
|
||||||
|
// === Instance ===
|
||||||
|
{ kind: 'class', name: 'Instance', insertText: 'Instance', doc: 'Базовый класс всех объектов Roblox.' },
|
||||||
|
{ kind: 'function', name: 'Instance.new', insertText: 'Instance.new("${1:Part}", ${2:workspace})', doc: 'Создаёт новый объект указанного класса.\n```lua\nlocal part = Instance.new("Part", workspace)\npart.Size = Vector3.new(4, 1, 4)\npart.Position = Vector3.new(0, 10, 0)\n```' },
|
||||||
|
|
||||||
|
// === game / services ===
|
||||||
|
{ kind: 'variable', name: 'game', insertText: 'game', doc: 'Корень DataModel. `game:GetService("Players")` — доступ к сервисам.' },
|
||||||
|
{ kind: 'variable', name: 'workspace', insertText: 'workspace', doc: 'Сокращение для `game.Workspace`. Содержит все Part-объекты сцены.' },
|
||||||
|
{ kind: 'variable', name: 'script', insertText: 'script', doc: 'Текущий скрипт. `script.Parent` — объект-носитель.\n```lua\nlocal part = script.Parent\npart.Touched:Connect(function(hit) ... end)\n```' },
|
||||||
|
|
||||||
|
// === Enum ===
|
||||||
|
{ kind: 'enum', name: 'Enum', insertText: 'Enum', doc: 'Перечисления Roblox: KeyCode, Material, UserInputType, EasingStyle, EasingDirection, HumanoidStateType.' },
|
||||||
|
{ kind: 'enum', name: 'Enum.KeyCode', insertText: 'Enum.KeyCode.${1:W}', doc: 'Клавиши клавиатуры: W, A, S, D, Space, LeftShift, Q, E, F, R, T, ..., One, Two, ..., Up, Down.' },
|
||||||
|
{ kind: 'enum', name: 'Enum.UserInputType', insertText: 'Enum.UserInputType.${1:MouseButton1}', doc: 'Типы ввода: MouseButton1/2/3, Keyboard, Touch, MouseMovement, MouseWheel.' },
|
||||||
|
{ kind: 'enum', name: 'Enum.Material', insertText: 'Enum.Material.${1:Plastic}', doc: 'Материалы: Plastic, Wood, Metal, Neon, Glass, Sand, Ice, Grass, Concrete.' },
|
||||||
|
{ kind: 'enum', name: 'Enum.HumanoidStateType', insertText: 'Enum.HumanoidStateType.${1:Running}', doc: 'Состояния Humanoid: Running, Jumping, Freefall, Landed, Dead, Climbing, Swimming, Seated.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// === Сниппеты быстрого старта (готовые шаблоны) ===
|
||||||
|
const ROBLOX_LUA_SNIPPETS = [
|
||||||
|
{
|
||||||
|
label: 'killbrick',
|
||||||
|
documentation: 'KillBrick — убивает игрока при касании.',
|
||||||
|
insertText: [
|
||||||
|
'local part = script.Parent',
|
||||||
|
'part.Touched:Connect(function(hit)',
|
||||||
|
'\tlocal humanoid = hit.Parent:FindFirstChildOfClass("Humanoid")',
|
||||||
|
'\tif humanoid then',
|
||||||
|
'\t\thumanoid.Health = 0',
|
||||||
|
'\tend',
|
||||||
|
'end)',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'teleportpad',
|
||||||
|
documentation: 'TeleportPad — телепортирует игрока в указанную точку.',
|
||||||
|
insertText: [
|
||||||
|
'local destination = Vector3.new(${1:0}, ${2:50}, ${3:0})',
|
||||||
|
'local pad = script.Parent',
|
||||||
|
'pad.Touched:Connect(function(hit)',
|
||||||
|
'\tlocal root = hit.Parent:FindFirstChild("HumanoidRootPart")',
|
||||||
|
'\tif root then',
|
||||||
|
'\t\troot.CFrame = CFrame.new(destination)',
|
||||||
|
'\tend',
|
||||||
|
'end)',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'coin',
|
||||||
|
documentation: 'Coin — даёт игроку монету при касании, потом исчезает.',
|
||||||
|
insertText: [
|
||||||
|
'local coin = script.Parent',
|
||||||
|
'local collected = false',
|
||||||
|
'coin.Touched:Connect(function(hit)',
|
||||||
|
'\tif collected then return end',
|
||||||
|
'\tif hit.Parent:FindFirstChildOfClass("Humanoid") then',
|
||||||
|
'\t\tcollected = true',
|
||||||
|
'\t\tprint("Монета собрана!")',
|
||||||
|
'\t\tcoin:Destroy()',
|
||||||
|
'\tend',
|
||||||
|
'end)',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'heartbeat',
|
||||||
|
documentation: 'RunService.Heartbeat — кадровый callback.',
|
||||||
|
insertText: [
|
||||||
|
'local RunService = game:GetService("RunService")',
|
||||||
|
'RunService.Heartbeat:Connect(function(dt)',
|
||||||
|
'\t${0:-- код, выполняется каждый кадр}',
|
||||||
|
'end)',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'playeradded',
|
||||||
|
documentation: 'PlayerAdded — реакция на захождение игрока.',
|
||||||
|
insertText: [
|
||||||
|
'local Players = game:GetService("Players")',
|
||||||
|
'Players.PlayerAdded:Connect(function(player)',
|
||||||
|
'\tprint("Игрок зашёл:", player.Name)',
|
||||||
|
'\t${0:}',
|
||||||
|
'end)',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'spinpart',
|
||||||
|
documentation: 'SpinPart — вращающаяся платформа.',
|
||||||
|
insertText: [
|
||||||
|
'local RunService = game:GetService("RunService")',
|
||||||
|
'local part = script.Parent',
|
||||||
|
'local speed = ${1:2} -- радиан/сек',
|
||||||
|
'RunService.Heartbeat:Connect(function(dt)',
|
||||||
|
'\tpart.CFrame = part.CFrame * CFrame.Angles(0, speed * dt, 0)',
|
||||||
|
'end)',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function registerLuaInMonaco(monaco) {
|
||||||
|
if (monaco.__rbxLuaRegistered) return;
|
||||||
|
monaco.__rbxLuaRegistered = true;
|
||||||
|
|
||||||
|
// 1. CompletionProvider — автодополнение
|
||||||
|
monaco.languages.registerCompletionItemProvider('lua', {
|
||||||
|
triggerCharacters: ['.', ':', '"', "'"],
|
||||||
|
provideCompletionItems: (model, position) => {
|
||||||
|
const word = model.getWordUntilPosition(position);
|
||||||
|
const range = {
|
||||||
|
startLineNumber: position.lineNumber,
|
||||||
|
endLineNumber: position.lineNumber,
|
||||||
|
startColumn: word.startColumn,
|
||||||
|
endColumn: word.endColumn,
|
||||||
|
};
|
||||||
|
const suggestions = [];
|
||||||
|
const kindMap = {
|
||||||
|
'function': monaco.languages.CompletionItemKind.Function,
|
||||||
|
'class': monaco.languages.CompletionItemKind.Class,
|
||||||
|
'module': monaco.languages.CompletionItemKind.Module,
|
||||||
|
'enum': monaco.languages.CompletionItemKind.Enum,
|
||||||
|
'variable': monaco.languages.CompletionItemKind.Variable,
|
||||||
|
};
|
||||||
|
for (const item of ROBLOX_LUA_API) {
|
||||||
|
suggestions.push({
|
||||||
|
label: item.name,
|
||||||
|
kind: kindMap[item.kind] || monaco.languages.CompletionItemKind.Text,
|
||||||
|
insertText: item.insertText,
|
||||||
|
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||||
|
documentation: { value: item.doc, isTrusted: true },
|
||||||
|
range,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const snip of ROBLOX_LUA_SNIPPETS) {
|
||||||
|
suggestions.push({
|
||||||
|
label: snip.label,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||||
|
insertText: snip.insertText,
|
||||||
|
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||||
|
documentation: { value: snip.documentation, isTrusted: true },
|
||||||
|
detail: 'Сниппет Roblox-Lua',
|
||||||
|
range,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { suggestions };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. HoverProvider — подсказки при наведении
|
||||||
|
const lookupTable = new Map();
|
||||||
|
for (const item of ROBLOX_LUA_API) lookupTable.set(item.name, item);
|
||||||
|
monaco.languages.registerHoverProvider('lua', {
|
||||||
|
provideHover: (model, position) => {
|
||||||
|
const word = model.getWordAtPosition(position);
|
||||||
|
if (!word) return null;
|
||||||
|
// Пробуем найти точное совпадение или с префиксом (Vector3.new)
|
||||||
|
let found = lookupTable.get(word.word);
|
||||||
|
if (!found) {
|
||||||
|
// Возможно курсор на середине A.B — попробуем собрать всю цепочку
|
||||||
|
const line = model.getLineContent(position.lineNumber);
|
||||||
|
// Ищем имя.имя.имя на позиции
|
||||||
|
const left = line.slice(0, word.endColumn - 1);
|
||||||
|
const m = left.match(/[A-Za-z_][\w.]*$/);
|
||||||
|
if (m) found = lookupTable.get(m[0]);
|
||||||
|
}
|
||||||
|
if (!found) return null;
|
||||||
|
return {
|
||||||
|
range: new monaco.Range(
|
||||||
|
position.lineNumber, word.startColumn,
|
||||||
|
position.lineNumber, word.endColumn,
|
||||||
|
),
|
||||||
|
contents: [
|
||||||
|
{ value: `**${found.name}**` },
|
||||||
|
{ value: found.doc },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user