feat: 50 игр на Lua + импорт Roblox для всех + поддержка Lua в плеере #39
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 вопросам перед стартом.
|
||||
@ -37,7 +37,7 @@ const renderRowIcon = (val) => {
|
||||
const ItemRow = ({
|
||||
icon, label, title, depth = 0, selected, plusItems,
|
||||
onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
|
||||
extraStyle, selId,
|
||||
extraStyle, selId, badge,
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const rowRef = React.useRef(null);
|
||||
@ -84,6 +84,9 @@ const ItemRow = ({
|
||||
>
|
||||
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{renderRowIcon(icon)}</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{label}</span>
|
||||
{badge && (
|
||||
<span style={{ flexShrink: 0, display: 'inline-flex' }}>{badge}</span>
|
||||
)}
|
||||
{plusItems && plusItems.length > 0 && (
|
||||
<HoverPlusMenu visible={hovered} items={plusItems} />
|
||||
)}
|
||||
@ -129,15 +132,35 @@ const GroupRow = ({ icon, label, open, onToggle, plusItems }) => {
|
||||
/** Строка скрипта внутри иерархии. */
|
||||
const ScriptRow = ({ script, depth, selected, onSelect, onDelete, onRename, onContextMenu, onStartRename }) => {
|
||||
const displayName = script.name || (script.id === 'demo' ? 'Демо-скрипт' : script.id);
|
||||
const isLua = script.language === 'lua';
|
||||
const badge = (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 800,
|
||||
padding: '1px 5px',
|
||||
borderRadius: 4,
|
||||
lineHeight: 1.4,
|
||||
letterSpacing: 0.4,
|
||||
marginRight: 4,
|
||||
background: isLua ? '#2196f3' : '#f7df1e',
|
||||
color: isLua ? '#fff' : '#1a1a1c',
|
||||
}}
|
||||
title={isLua ? 'Lua (Roblox API)' : 'JavaScript (game.* API)'}
|
||||
>
|
||||
{isLua ? 'LUA' : 'JS'}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<ItemRow
|
||||
icon="📜"
|
||||
label={displayName}
|
||||
title={`${displayName} (id: ${script.id})`}
|
||||
title={`${displayName} (id: ${script.id}, язык: ${isLua ? 'Lua' : 'JavaScript'})`}
|
||||
depth={depth}
|
||||
selected={selected}
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
badge={badge}
|
||||
plusItems={[
|
||||
{
|
||||
id: 'rename', label: 'Переименовать', icon: '✏️',
|
||||
|
||||
@ -3324,8 +3324,14 @@ const KubikonEditor = () => {
|
||||
scriptId={sc.id}
|
||||
value={sc.code}
|
||||
target={sc.target}
|
||||
language={sc.language || 'js'}
|
||||
flushRef={scriptEditorFlushRef}
|
||||
isSoloRunning={soloScriptId === sc.id}
|
||||
onLanguageChange={(lang, newCode) => {
|
||||
sceneRef.current?.upsertScript(sc.id, newCode, undefined, undefined, lang);
|
||||
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||||
markDirty();
|
||||
}}
|
||||
onSave={(code) => {
|
||||
sceneRef.current?.upsertScript(sc.id, code, sc.target);
|
||||
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||||
|
||||
@ -34,7 +34,42 @@ import { registerSnippets } from './engine/snippets';
|
||||
// Если нужен какой-то метод, которого нет в автокомплите — добавляйте его
|
||||
// в соответствующий .d.ts (player.d.ts / scene.d.ts / ...) и пересобирайте
|
||||
// командой `python _build_bundle.py` в той же папке.
|
||||
function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, onClose, flushRef }) {
|
||||
// Дефолтный шаблон Lua-скрипта для нового скрипта (на Part или глобальный).
|
||||
// Используется при смене языка JS→Lua когда текущий код выглядит «пустым».
|
||||
const LUA_TEMPLATE_PART = `-- Скрипт привязан к Part. script.Parent = эта часть.
|
||||
local part = script.Parent
|
||||
|
||||
part.Touched:Connect(function(hit)
|
||||
print("Касание:", hit.Name)
|
||||
end)
|
||||
`;
|
||||
const LUA_TEMPLATE_GLOBAL = `-- Глобальный Lua-скрипт. Работает на стороне сервера/клиента.
|
||||
local Players = game:GetService("Players")
|
||||
|
||||
Players.PlayerAdded:Connect(function(player)
|
||||
print("Игрок зашёл:", player.Name)
|
||||
end)
|
||||
`;
|
||||
const JS_TEMPLATE_GLOBAL = `// Глобальный JS-скрипт. Подробнее: см. game.* API в /справочник.
|
||||
game.onPlayerJoined((player) => {
|
||||
game.chat.say('Привет, ' + player.name + '!');
|
||||
});
|
||||
`;
|
||||
|
||||
function isCodeLikelyEmptyTemplate(code) {
|
||||
if (!code) return true;
|
||||
const trimmed = code.trim();
|
||||
if (trimmed.length === 0) return true;
|
||||
// Содержит ТОЛЬКО комментарии и пустые строки
|
||||
const lines = trimmed.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
return lines.every(l =>
|
||||
l.startsWith('//') || l.startsWith('--') ||
|
||||
l.startsWith('/*') || l.startsWith('*/') || l.startsWith('*')
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, language, onLanguageChange, onClose, flushRef }) {
|
||||
const currentLanguage = language === 'lua' ? 'lua' : 'js';
|
||||
// Локальный буфер кода — то что в редакторе сейчас.
|
||||
// Синхронизируется с external value только при смене scriptId.
|
||||
const [localCode, setLocalCode] = useState(value || '');
|
||||
@ -282,6 +317,64 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
|
||||
border: '1px solid rgba(79, 116, 255, 0.35)',
|
||||
}}>{targetLabel}</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;
|
||||
let nextCode = localCode;
|
||||
if (isCodeLikelyEmptyTemplate(localCode)) {
|
||||
nextCode = lang === 'lua'
|
||||
? (target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL)
|
||||
: JS_TEMPLATE_GLOBAL;
|
||||
setLocalCode(nextCode);
|
||||
} else {
|
||||
const ok = window.confirm(
|
||||
'Сменить язык скрипта на ' + (lang === 'lua' ? 'Lua' : 'JavaScript') +
|
||||
'?\n\nКод сохранится как есть — синтаксис прежнего языка перестанет подсвечиваться. Можно переключиться обратно.'
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
onLanguageChange(lang, nextCode);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
fontFamily: 'inherit',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: active ? 'default' : 'pointer',
|
||||
background: active
|
||||
? (lang === 'lua'
|
||||
? 'linear-gradient(135deg, #2196f3 0%, #1565c0 100%)'
|
||||
: 'linear-gradient(135deg, #f7df1e 0%, #d4b500 100%)')
|
||||
: 'transparent',
|
||||
color: active
|
||||
? (lang === 'lua' ? '#fff' : '#1a1a1c')
|
||||
: '#9a9a9e',
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
title={lang === 'lua'
|
||||
? 'Lua с Roblox-совместимым API (Vector3, CFrame, Instance)'
|
||||
: 'JavaScript с game.* API Рублокса'}
|
||||
>
|
||||
{lang === 'lua' ? 'Lua' : 'JS'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
{/* Фаза 6.1.4: кнопка «Проверить» — включает семантический анализ TS
|
||||
на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.).
|
||||
@ -394,10 +487,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="javascript"
|
||||
defaultLanguage={currentLanguage === 'lua' ? 'lua' : 'javascript'}
|
||||
language={currentLanguage === 'lua' ? 'lua' : 'javascript'}
|
||||
theme="vs-dark"
|
||||
value={localCode}
|
||||
path={`script_${scriptId}.js`}
|
||||
path={`script_${scriptId}.${currentLanguage === 'lua' ? 'lua' : 'js'}`}
|
||||
onChange={handleChange}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorMount}
|
||||
|
||||
@ -3035,25 +3035,44 @@ export class BabylonScene {
|
||||
// Без этого onTouch финиша/плитки не срабатывает (игрок встал).
|
||||
const EPS = 0.25;
|
||||
|
||||
// Диагностика раз в секунду через time-based throttle
|
||||
if (!this._touchDbgT0) this._touchDbgT0 = performance.now();
|
||||
const _nowDbg = performance.now();
|
||||
if (_nowDbg - this._touchDbgT0 > 1000) {
|
||||
this._touchDbgT0 = _nowDbg;
|
||||
console.warn(`[TouchDbg] pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)}) scripts=${scripts.length}`);
|
||||
}
|
||||
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
|
||||
let _firedThisFrame = 0;
|
||||
for (const s of scripts) {
|
||||
if (!s.target) continue;
|
||||
const key = 's:' + s.id;
|
||||
seen.add(key);
|
||||
const aabb = this._targetAABB(s.target);
|
||||
if (!aabb) continue;
|
||||
const overlap =
|
||||
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
|
||||
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
|
||||
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
|
||||
const wasTouching = this._touchState.get(key);
|
||||
if (overlap && !wasTouching) {
|
||||
this._touchState.set(key, true);
|
||||
rt.routeEvent(s.target, 'touch', {});
|
||||
rt.routeGlobalEvent('playerTouch', { target: s.target });
|
||||
} else if (!overlap && wasTouching) {
|
||||
this._touchState.set(key, false);
|
||||
rt.routeEvent(s.target, 'untouch', {});
|
||||
try {
|
||||
const key = 's:' + s.id;
|
||||
seen.add(key);
|
||||
const aabb = this._targetAABB(s.target);
|
||||
if (!aabb) continue;
|
||||
const overlap =
|
||||
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
|
||||
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
|
||||
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
|
||||
const wasTouching = this._touchState.get(key);
|
||||
if (overlap && !wasTouching) {
|
||||
this._touchState.set(key, true);
|
||||
rt.routeEvent(s.target, 'touch', {});
|
||||
rt.routeGlobalEvent('playerTouch', { target: s.target });
|
||||
_firedThisFrame++;
|
||||
if (_firedThisFrame === 1) {
|
||||
console.warn(`[Touch FIRE] scriptId=${s.id} target=${s.target} pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)})`);
|
||||
}
|
||||
} else if (!overlap && wasTouching) {
|
||||
this._touchState.set(key, false);
|
||||
rt.routeEvent(s.target, 'untouch', {});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!this._touchDetectErrored) {
|
||||
this._touchDetectErrored = true;
|
||||
console.error('[TouchDetect] error', e, 'on script', s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3161,6 +3180,17 @@ export class BabylonScene {
|
||||
_targetAABB(target) {
|
||||
if (!target) return null;
|
||||
try {
|
||||
// Импортированные Roblox-скрипты имеют target = число (primitiveId).
|
||||
if (typeof target === 'number') {
|
||||
const data = this.primitiveManager?.instances?.get(target);
|
||||
if (!data || data.sx == null || data.x == null) return null;
|
||||
const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2;
|
||||
return {
|
||||
minX: data.x - hx, maxX: data.x + hx,
|
||||
minY: data.y - hy, maxY: data.y + hy,
|
||||
minZ: data.z - hz, maxZ: data.z + hz,
|
||||
};
|
||||
}
|
||||
if (target.kind === 'block') {
|
||||
const r = target.ref || target;
|
||||
return {
|
||||
@ -5364,6 +5394,7 @@ export class BabylonScene {
|
||||
code: s.code,
|
||||
name: s.name || null,
|
||||
target: newTarget,
|
||||
language: s.language || 'js',
|
||||
});
|
||||
}
|
||||
if (srcScripts.length > 0) {
|
||||
@ -5521,7 +5552,7 @@ export class BabylonScene {
|
||||
const target = kind === 'block'
|
||||
? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
|
||||
: { kind, id: dstRef };
|
||||
this._scripts.push({ id: newId, code: s.code, name: s.name || null, target });
|
||||
this._scripts.push({ id: newId, code: s.code, name: s.name || null, target, language: s.language || 'js' });
|
||||
}
|
||||
this.history?.markChange();
|
||||
if (this._onSceneChange) this._onSceneChange();
|
||||
@ -6677,7 +6708,7 @@ export class BabylonScene {
|
||||
}
|
||||
|
||||
/** Установить код одного скрипта по id. Если id нет — создать новый. */
|
||||
upsertScript(id, code, target = undefined, name = undefined) {
|
||||
upsertScript(id, code, target = undefined, name = undefined, language = undefined) {
|
||||
const i = this._scripts.findIndex(s => s.id === id);
|
||||
if (i >= 0) {
|
||||
this._scripts[i] = {
|
||||
@ -6685,6 +6716,7 @@ export class BabylonScene {
|
||||
code,
|
||||
...(target !== undefined ? { target } : {}),
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(language !== undefined ? { language } : {}),
|
||||
};
|
||||
} else {
|
||||
this._scripts.push({
|
||||
@ -6692,6 +6724,7 @@ export class BabylonScene {
|
||||
code,
|
||||
target: target !== undefined ? target : null,
|
||||
name: name || null,
|
||||
language: language || 'js',
|
||||
});
|
||||
}
|
||||
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
|
||||
@ -8193,6 +8226,7 @@ export class BabylonScene {
|
||||
code: s.code,
|
||||
target: s.target || null,
|
||||
name: s.name || null,
|
||||
language: s.language === 'lua' ? 'lua' : 'js',
|
||||
}));
|
||||
}
|
||||
// Окружение (время суток, скайбокс, туман)
|
||||
|
||||
@ -20,6 +20,7 @@ import { STORYS_addres } from '../../api/API';
|
||||
import { PhysicsWorld } from './PhysicsWorld';
|
||||
import { LabelManager } from './LabelManager';
|
||||
import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js';
|
||||
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
|
||||
|
||||
export class GameRuntime {
|
||||
constructor(scene3d) {
|
||||
@ -117,11 +118,19 @@ export class GameRuntime {
|
||||
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
|
||||
const rbxlBatch = [];
|
||||
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
|
||||
// НОВОЕ (Этап 2): Lua-скрипты с language='lua' идут через LuaSharedSandbox
|
||||
// (один shared VM на всю игру). Это user-written Lua + Roblox API совместимость.
|
||||
// Отличается от rbxl-import batch: тут код юзер написал в редакторе сам.
|
||||
const luaUserBatch = [];
|
||||
for (const s of scripts) {
|
||||
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
|
||||
rbxlBatch.push(s);
|
||||
continue;
|
||||
}
|
||||
if (s && s.language === 'lua') {
|
||||
if (typeof s.code === 'string' && s.code.trim()) luaUserBatch.push(s);
|
||||
continue;
|
||||
}
|
||||
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GameRuntime] skipping invalid script entry', s);
|
||||
@ -167,9 +176,32 @@ export class GameRuntime {
|
||||
rbxlCount = result.count;
|
||||
}
|
||||
}
|
||||
this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`);
|
||||
// НОВОЕ (Этап 2): user-written Lua-скрипты с language='lua' через LuaSharedSandbox
|
||||
let luaUserCount = 0;
|
||||
if (luaUserBatch.length > 0) {
|
||||
try {
|
||||
const sb = new LuaSharedSandbox();
|
||||
sb.setOnCommand(({ cmd, payload }) => {
|
||||
this._handleCommand(null, cmd, payload);
|
||||
});
|
||||
for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target);
|
||||
sb.start();
|
||||
this.sandboxes.push(sb);
|
||||
this._luaUserSandbox = sb;
|
||||
luaUserCount = luaUserBatch.length;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[GameRuntime] Lua user runtime failed to init', e);
|
||||
this._log('error', `Lua-runtime ошибка: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
const jsOnly = this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0) - (this._luaUserSandbox ? 1 : 0);
|
||||
this._log('info', `Запущено JS-скриптов: ${jsOnly}`);
|
||||
if (rbxlCount > 0) {
|
||||
this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`);
|
||||
this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlCount}`);
|
||||
}
|
||||
if (luaUserCount > 0) {
|
||||
this._log('info', `Запущено Lua-скриптов юзера: ${luaUserCount}`);
|
||||
}
|
||||
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
|
||||
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
||||
|
||||
@ -114,9 +114,20 @@ export class RobloxLuaSharedSandbox {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const type = payload.type;
|
||||
// playerTouch: BabylonScene уже детектит касания → Touched на Part
|
||||
if (type === 'playerTouch' && payload.target) {
|
||||
const m = /^primitive:(\d+)$/.exec(String(payload.target));
|
||||
if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; }
|
||||
if (type === 'playerTouch' && payload.target != null) {
|
||||
const t = payload.target;
|
||||
// target может быть: число (импортированный rbxl), {id|ref}, 'primitive:<id>'
|
||||
let primId = null;
|
||||
if (typeof t === 'number') primId = t;
|
||||
else if (typeof t === 'object') primId = t.id ?? t.ref ?? null;
|
||||
else if (typeof t === 'string') {
|
||||
const m = /^primitive:(\d+)$/.exec(t);
|
||||
if (m) primId = +m[1];
|
||||
}
|
||||
if (primId != null) {
|
||||
this.fireEvent('touched', { primId, isPlayer: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// GUI click — Rublox GuiOverlay шлёт guiClick с id
|
||||
if (type === 'guiClick' && (payload.id || payload.localId)) {
|
||||
|
||||
@ -344,11 +344,12 @@ function handleEvent(payload) {
|
||||
} else if (kind === 'touched') {
|
||||
const primId = payload.primId;
|
||||
const part = state.api.part_by_id?.get(primId);
|
||||
const hasFire = !!part?.Touched?.Fire;
|
||||
const connCount = part?.Touched?.connections?.length ?? 0;
|
||||
log('info', `[Touched] primId=${primId} hasPart=${!!part} hasFire=${hasFire} connectedHandlers=${connCount}`);
|
||||
if (part?.Touched?.Fire) {
|
||||
// hit = HumanoidRootPart
|
||||
part.Touched.Fire(state.api.character?.HumanoidRootPart || part);
|
||||
}
|
||||
// также Humanoid.Touched на самом игроке
|
||||
if (payload.isPlayer) {
|
||||
state.api.humanoid?.Touched?.Fire?.(part);
|
||||
}
|
||||
|
||||
215
src/editor/engine/lua/LuaSharedSandbox.js
Normal file
215
src/editor/engine/lua/LuaSharedSandbox.js
Normal file
@ -0,0 +1,215 @@
|
||||
/**
|
||||
* LuaSharedSandbox — обёртка над одним wasmoon-Worker для ВСЕХ Lua-скриптов игры.
|
||||
*
|
||||
* Идея:
|
||||
* - Создаётся ОДИН экземпляр на всю игру (а не на скрипт, как ScriptSandbox)
|
||||
* - Все Lua-скрипты добавляются через addScript(id, code, target)
|
||||
* - Worker внутри держит ОДИН wasmoon Lua-state, в котором живут:
|
||||
* * полный Roblox API shim (Vector3, CFrame, Color3, Instance, ...)
|
||||
* * виртуальное DataModel дерево (game.Workspace, Players, ...)
|
||||
* * все скрипты как coroutines (потому что Roblox-Lua так работает)
|
||||
* - При партии команд (partSet/sceneCreate/event/log) — пересылка в main
|
||||
* с тем же интерфейсом что у ScriptSandbox
|
||||
*
|
||||
* Совместимость с GameRuntime:
|
||||
* методы sendEvent / sendGlobalEvent / sendSceneSnapshot / sendGuiSnapshot /
|
||||
* sendDataSnapshot / sendSkinsSnapshot / sendTerrainHeightmap / stop /
|
||||
* setOnCommand — поведение совпадает с ScriptSandbox.
|
||||
*
|
||||
* Отличия:
|
||||
* - addScript(id, code, target) можно вызывать много раз ДО start() — все
|
||||
* скрипты добавляются батчем и потом запускаются вместе.
|
||||
* - После start() можно вызывать addScript() для live-добавления (например,
|
||||
* Instance.new("Script", workspace) с переданным Source).
|
||||
*/
|
||||
|
||||
import LuaSharedWorker from './LuaSharedWorker.js?worker';
|
||||
|
||||
let _ipcId = 0;
|
||||
|
||||
export class LuaSharedSandbox {
|
||||
constructor() {
|
||||
this.worker = null;
|
||||
this._onCommand = null;
|
||||
this._isReady = false;
|
||||
this._isStopped = false;
|
||||
// Скрипты добавленные до start() — буферизуются, отправляются батчем при start()
|
||||
this._pendingScripts = [];
|
||||
// Снапшоты пришедшие до ready — отправляются после ready
|
||||
this._pendingSceneSnapshot = null;
|
||||
this._pendingGuiSnapshot = null;
|
||||
this._pendingDataSnapshot = null;
|
||||
this._pendingSkinsSnapshot = null;
|
||||
this._pendingTerrainHM = null;
|
||||
}
|
||||
|
||||
setOnCommand(cb) { this._onCommand = cb; }
|
||||
|
||||
/** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */
|
||||
addScript(id, code, target) {
|
||||
const entry = {
|
||||
id: String(id),
|
||||
source: String(code || ''),
|
||||
target: target == null ? null : target,
|
||||
};
|
||||
if (!this.worker) {
|
||||
this._pendingScripts.push(entry);
|
||||
return;
|
||||
}
|
||||
// Live-добавление после start()
|
||||
try {
|
||||
this.worker.postMessage({ cmd: 'addScript', payload: entry });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/** Удалить Lua-скрипт по id (для случая когда в студии его удалили в Play-mode редко). */
|
||||
removeScript(id) {
|
||||
if (!this.worker) {
|
||||
this._pendingScripts = this._pendingScripts.filter(s => s.id !== String(id));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.worker.postMessage({ cmd: 'removeScript', payload: { id: String(id) } });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить worker и инициализировать VM.
|
||||
* После start() Lua-runtime готов принимать события и снапшоты.
|
||||
*/
|
||||
start() {
|
||||
if (this.worker) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[LuaSharedSandbox] starting Lua VM, pending scripts:', this._pendingScripts.length);
|
||||
this.worker = new LuaSharedWorker();
|
||||
this.worker.onmessage = (e) => this._handleMessage(e);
|
||||
this.worker.onerror = (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[LuaSharedSandbox] Worker error', err);
|
||||
this._emit('log', {
|
||||
level: 'error',
|
||||
text: `Lua-runtime error: ${err.message || err}`,
|
||||
});
|
||||
};
|
||||
this.worker.postMessage({
|
||||
cmd: 'init',
|
||||
payload: { ipcId: ++_ipcId },
|
||||
});
|
||||
}
|
||||
|
||||
_handleMessage(e) {
|
||||
if (this._isStopped) return;
|
||||
const { cmd, payload } = e.data || {};
|
||||
if (cmd === 'boot') return;
|
||||
if (cmd === 'ready') {
|
||||
this._isReady = true;
|
||||
// Отправляем накопленные скрипты батчем
|
||||
if (this._pendingScripts.length > 0) {
|
||||
try {
|
||||
this.worker.postMessage({ cmd: 'addScriptsBatch', payload: { scripts: this._pendingScripts } });
|
||||
} catch (_) {}
|
||||
this._pendingScripts = [];
|
||||
}
|
||||
// Отправляем snapshot'ы
|
||||
if (this._pendingSceneSnapshot) {
|
||||
try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: this._pendingSceneSnapshot }); } catch (_) {}
|
||||
this._pendingSceneSnapshot = null;
|
||||
}
|
||||
if (this._pendingGuiSnapshot) {
|
||||
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (_) {}
|
||||
this._pendingGuiSnapshot = null;
|
||||
}
|
||||
if (this._pendingDataSnapshot) {
|
||||
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (_) {}
|
||||
this._pendingDataSnapshot = null;
|
||||
}
|
||||
if (this._pendingSkinsSnapshot) {
|
||||
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (_) {}
|
||||
this._pendingSkinsSnapshot = null;
|
||||
}
|
||||
if (this._pendingTerrainHM) {
|
||||
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (_) {}
|
||||
this._pendingTerrainHM = null;
|
||||
}
|
||||
// Запустить главный loop (фаер RunService.Heartbeat/Stepped + резюм coroutines)
|
||||
try { this.worker.postMessage({ cmd: 'kickoff' }); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
// Любая другая команда — прокинуть наружу как partSet/sceneCreate/log/etc
|
||||
// _onCommand обработчик в GameRuntime разруливает их так же как от ScriptSandbox
|
||||
this._emit(cmd, payload);
|
||||
}
|
||||
|
||||
_emit(cmd, payload) {
|
||||
if (typeof this._onCommand === 'function') {
|
||||
try { this._onCommand({ cmd, payload }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Событие target-attached скрипта (touch/untouch/click/etc). */
|
||||
sendEvent(payload) {
|
||||
if (!this.worker) return;
|
||||
if (!this._isReady) return;
|
||||
try { this.worker.postMessage({ cmd: 'event', payload }); } catch (_) {}
|
||||
}
|
||||
|
||||
/** Глобальное событие игры (playerTouch/guiClick/keydown/etc). */
|
||||
sendGlobalEvent(payload) {
|
||||
if (!this.worker) return;
|
||||
if (!this._isReady) return;
|
||||
try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (_) {}
|
||||
}
|
||||
|
||||
sendSceneSnapshot(snapshot) {
|
||||
if (!this.worker) {
|
||||
this._pendingSceneSnapshot = snapshot;
|
||||
return;
|
||||
}
|
||||
if (!this._isReady) {
|
||||
this._pendingSceneSnapshot = snapshot;
|
||||
return;
|
||||
}
|
||||
try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: snapshot }); } catch (_) {}
|
||||
}
|
||||
|
||||
sendGuiSnapshot(snapshot) {
|
||||
if (!this.worker || !this._isReady) {
|
||||
this._pendingGuiSnapshot = snapshot;
|
||||
return;
|
||||
}
|
||||
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (_) {}
|
||||
}
|
||||
|
||||
sendDataSnapshot(snapshot) {
|
||||
if (!this.worker || !this._isReady) {
|
||||
this._pendingDataSnapshot = snapshot;
|
||||
return;
|
||||
}
|
||||
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (_) {}
|
||||
}
|
||||
|
||||
sendSkinsSnapshot(snapshot) {
|
||||
if (!this.worker || !this._isReady) {
|
||||
this._pendingSkinsSnapshot = snapshot;
|
||||
return;
|
||||
}
|
||||
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (_) {}
|
||||
}
|
||||
|
||||
sendTerrainHeightmap(hm) {
|
||||
if (!this.worker || !this._isReady) {
|
||||
this._pendingTerrainHM = hm;
|
||||
return;
|
||||
}
|
||||
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: hm }); } catch (_) {}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._isStopped = true;
|
||||
try { this.worker?.terminate(); } catch (_) {}
|
||||
this.worker = null;
|
||||
this._isReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default LuaSharedSandbox;
|
||||
242
src/editor/engine/lua/LuaSharedWorker.js
Normal file
242
src/editor/engine/lua/LuaSharedWorker.js
Normal file
@ -0,0 +1,242 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
/**
|
||||
* LuaSharedWorker — Web Worker, держит ОДИН wasmoon Lua-state на всю игру.
|
||||
*
|
||||
* Жизненный цикл:
|
||||
* 1. init {ipcId} → загружаем wasmoon, готовим VM, регистрируем shim, отвечаем 'ready'
|
||||
* 2. addScriptsBatch {scripts} → добавляем все скрипты сразу (но НЕ запускаем — ждём kickoff)
|
||||
* 3. sceneSnapshot/guiSnapshot → накопить состояние сцены до запуска
|
||||
* 4. kickoff → запустить main loop (RunService.Heartbeat фейерится из main loop)
|
||||
* и стартануть каждый скрипт как coroutine
|
||||
* 5. event/globalEvent → проксировать в Lua-signal (RbxSignal.Fire)
|
||||
* 6. addScript {entry} → live-добавление одного скрипта после kickoff
|
||||
*
|
||||
* Архитектура VM:
|
||||
* - один wasmoon Lua state (createWasmoonVM)
|
||||
* - registerRobloxShim(vm) — экспортирует Vector3.new, Color3.new, print, wait,
|
||||
* game (с минимальным DataModel), Instance.new и проч.
|
||||
* - state.scripts = Map<id, {coroutine, target, source}>
|
||||
* - state.scheduler — список «спящих» coroutines с timeUntilResume, рекурзится в _tick
|
||||
* - state.signals — RbxSignal-объекты для events; Worker слушает 'event' от main
|
||||
* и вызывает Lua-side Fire по соответствующему signal'у
|
||||
*/
|
||||
|
||||
let _wasmoon = null;
|
||||
|
||||
// Главное состояние VM (на весь life-cycle Worker'а)
|
||||
const state = {
|
||||
ipcId: null,
|
||||
vm: null,
|
||||
api: null, // объект который вернул registerRobloxShim
|
||||
isReady: false,
|
||||
isKickedOff: false,
|
||||
pendingScripts: [], // скрипты которые ждут kickoff
|
||||
scriptsById: new Map(), // id → {coroutine, target, source, name}
|
||||
scenes: { primitives: null, blocks: null, models: null },
|
||||
guiTree: null,
|
||||
skins: null,
|
||||
data: null,
|
||||
terrainHM: null,
|
||||
// tick clock
|
||||
lastTickAt: 0,
|
||||
};
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { cmd, payload } = e.data || {};
|
||||
try {
|
||||
if (cmd === 'init') await handleInit(payload);
|
||||
else if (cmd === 'addScript') handleAddScript(payload);
|
||||
else if (cmd === 'addScriptsBatch') handleAddScriptsBatch(payload);
|
||||
else if (cmd === 'removeScript') handleRemoveScript(payload);
|
||||
else if (cmd === 'sceneSnapshot') handleSceneSnapshot(payload);
|
||||
else if (cmd === 'guiSnapshot') handleGuiSnapshot(payload);
|
||||
else if (cmd === 'dataSnapshot') handleDataSnapshot(payload);
|
||||
else if (cmd === 'skinsSnapshot') handleSkinsSnapshot(payload);
|
||||
else if (cmd === 'terrainHeightmap') handleTerrainHeightmap(payload);
|
||||
else if (cmd === 'event') handleTargetEvent(payload);
|
||||
else if (cmd === 'globalEvent') handleGlobalEvent(payload);
|
||||
else if (cmd === 'kickoff') handleKickoff();
|
||||
} catch (err) {
|
||||
logToMain('error', `[LuaWorker] ${cmd} error: ${err?.message || err}`);
|
||||
}
|
||||
};
|
||||
|
||||
function send(cmd, payload) {
|
||||
try { self.postMessage({ cmd, payload }); } catch (_) {}
|
||||
}
|
||||
|
||||
function logToMain(level, text) {
|
||||
send('log', { level, text });
|
||||
}
|
||||
|
||||
async function handleInit(payload) {
|
||||
state.ipcId = payload?.ipcId || 0;
|
||||
send('boot', { ipcId: state.ipcId });
|
||||
// Загрузить wasmoon
|
||||
_wasmoon = await import(/* @vite-ignore */ 'wasmoon');
|
||||
const { LuaFactory } = _wasmoon;
|
||||
const factory = new LuaFactory();
|
||||
state.vm = await factory.createEngine({ openStandardLibs: true });
|
||||
|
||||
// Регистрируем минимальный Roblox shim
|
||||
const { registerRobloxShim } = await import('./RobloxShim.js');
|
||||
state.api = registerRobloxShim(state.vm, {
|
||||
send,
|
||||
getSceneSnapshot: () => state.scenes,
|
||||
getGuiTree: () => state.guiTree,
|
||||
scheduleWait: (sec) => scheduleWait(sec),
|
||||
});
|
||||
|
||||
state.isReady = true;
|
||||
send('ready', {});
|
||||
}
|
||||
|
||||
function handleAddScriptsBatch(payload) {
|
||||
const arr = Array.isArray(payload?.scripts) ? payload.scripts : [];
|
||||
for (const s of arr) handleAddScript(s);
|
||||
}
|
||||
|
||||
function handleAddScript(entry) {
|
||||
if (!entry || typeof entry.source !== 'string') return;
|
||||
const id = String(entry.id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`);
|
||||
state.scriptsById.set(id, {
|
||||
id,
|
||||
source: entry.source,
|
||||
target: entry.target == null ? null : entry.target,
|
||||
coroutine: null,
|
||||
name: entry.name || null,
|
||||
});
|
||||
// Если мы уже kickoff'нулись — стартанём сразу
|
||||
if (state.isKickedOff) startSingleScript(id);
|
||||
else state.pendingScripts.push(id);
|
||||
}
|
||||
|
||||
function handleRemoveScript(payload) {
|
||||
const id = String(payload?.id || '');
|
||||
if (!id) return;
|
||||
state.scriptsById.delete(id);
|
||||
// (coroutine просто перестанет резюмиться)
|
||||
}
|
||||
|
||||
function handleSceneSnapshot(snap) {
|
||||
state.scenes = snap || { primitives: null, blocks: null, models: null };
|
||||
// Обновим DataModel-дерево (Workspace children) — это сделает api при следующем GetChildren()
|
||||
if (state.api?.onSceneSnapshot) {
|
||||
try { state.api.onSceneSnapshot(state.scenes); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function handleGuiSnapshot(g) {
|
||||
state.guiTree = g || null;
|
||||
if (state.api?.onGuiSnapshot) {
|
||||
try { state.api.onGuiSnapshot(state.guiTree); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDataSnapshot(d) {
|
||||
state.data = d || null;
|
||||
if (state.api?.onDataSnapshot) {
|
||||
try { state.api.onDataSnapshot(state.data); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSkinsSnapshot(s) {
|
||||
state.skins = s || null;
|
||||
}
|
||||
|
||||
function handleTerrainHeightmap(hm) {
|
||||
state.terrainHM = hm || null;
|
||||
}
|
||||
|
||||
function handleTargetEvent(payload) {
|
||||
// События привязанные к конкретному скрипту (touch/untouch/click)
|
||||
// payload: { scriptId, kind, ... }
|
||||
if (!state.api?.fireTargetEvent) return;
|
||||
try { state.api.fireTargetEvent(payload); } catch (e) {
|
||||
logToMain('error', `[LuaWorker] fireTargetEvent: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalEvent(payload) {
|
||||
if (!state.api?.fireGlobalEvent) return;
|
||||
try { state.api.fireGlobalEvent(payload); } catch (e) {
|
||||
logToMain('error', `[LuaWorker] fireGlobalEvent: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKickoff() {
|
||||
if (state.isKickedOff) return;
|
||||
state.isKickedOff = true;
|
||||
// Стартанём все накопленные скрипты как coroutines
|
||||
for (const id of state.pendingScripts) startSingleScript(id);
|
||||
state.pendingScripts = [];
|
||||
// Главный loop — RunService Heartbeat + scheduler resume
|
||||
state.lastTickAt = performance.now();
|
||||
startMainLoop();
|
||||
}
|
||||
|
||||
function startSingleScript(id) {
|
||||
const entry = state.scriptsById.get(id);
|
||||
if (!entry) return;
|
||||
// Каждый скрипт — coroutine. В нём script — это таблица {Name, Parent, ClassName="Script"}.
|
||||
// Создаём в Lua wrapped chunk:
|
||||
// coroutine.create(function() local script = ...; <user_source> end)
|
||||
const safeId = id.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
const targetLuaExpr = entry.target == null
|
||||
? 'nil'
|
||||
: (typeof entry.target === 'number'
|
||||
? `__rbxl_get_part_by_id(${entry.target})`
|
||||
: 'nil'); // для object-target (на будущее)
|
||||
const name = entry.name || `Script_${safeId}`;
|
||||
const wrapped = `
|
||||
local co = coroutine.create(function()
|
||||
local script = {
|
||||
Name = ${JSON.stringify(name)},
|
||||
Parent = ${targetLuaExpr},
|
||||
ClassName = "Script",
|
||||
Disabled = false,
|
||||
Source = nil,
|
||||
}
|
||||
__rbxl_script_run(${JSON.stringify(id)}, script, function()
|
||||
${entry.source}
|
||||
end)
|
||||
end)
|
||||
__rbxl_register_coroutine(${JSON.stringify(id)}, co)
|
||||
coroutine.resume(co)
|
||||
`;
|
||||
try {
|
||||
state.vm.doStringSync(wrapped);
|
||||
} catch (err) {
|
||||
logToMain('error', `[Lua ${id}] init error: ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Главный loop:
|
||||
* - вызывается раз в ~16мс (60 Гц), резюмит спящие coroutines у которых истёк wait
|
||||
* - фейерит RunService.Heartbeat (dt секундах)
|
||||
* - фейерит RunService.Stepped
|
||||
*/
|
||||
function startMainLoop() {
|
||||
const tick = () => {
|
||||
if (!state.isKickedOff) return;
|
||||
try {
|
||||
const now = performance.now();
|
||||
const dt = Math.min(0.1, (now - state.lastTickAt) / 1000);
|
||||
state.lastTickAt = now;
|
||||
// 1) Резюм coroutines, которым подошёл срок wait
|
||||
if (state.api?.tickScheduler) state.api.tickScheduler(dt);
|
||||
// 2) Heartbeat и Stepped сигналы
|
||||
if (state.api?.fireHeartbeat) state.api.fireHeartbeat(dt);
|
||||
} catch (e) {
|
||||
logToMain('error', `[Lua tick] ${e.message || e}`);
|
||||
}
|
||||
setTimeout(tick, 16);
|
||||
};
|
||||
setTimeout(tick, 16);
|
||||
}
|
||||
|
||||
function scheduleWait(_sec) {
|
||||
// вызывается из Lua-side через api.scheduleWait. Реальная реализация —
|
||||
// в RobloxShim.js (он держит scheduler).
|
||||
}
|
||||
447
src/editor/engine/lua/RobloxShim.js
Normal file
447
src/editor/engine/lua/RobloxShim.js
Normal file
@ -0,0 +1,447 @@
|
||||
/**
|
||||
* RobloxShim — экспорт минимально-достаточного Roblox API в wasmoon-VM.
|
||||
*
|
||||
* Этап 2 (текущий): базовый shim без DataModel-дерева.
|
||||
* - Vector3, Color3, UDim2, UDim, Vector2 (с операторами)
|
||||
* - print, warn, error, wait, task.wait, task.spawn, task.delay
|
||||
* - RbxSignal (Connect/connect, Disconnect, Wait, Fire/fire)
|
||||
* - scheduler для wait через coroutines (NB: используется внешний tick из Worker)
|
||||
* - примитивная game-table с workspace, Players (заглушки) — расширится в Этапе 3
|
||||
*
|
||||
* Возвращает объект api с методами:
|
||||
* onSceneSnapshot(snap) — обновить понимание сцены (для DataModel в Этапе 3)
|
||||
* onGuiSnapshot(g) — обновить GUI tree
|
||||
* onDataSnapshot(d) — обновить data (save)
|
||||
* tickScheduler(dt) — резюм coroutines с истёкшим wait
|
||||
* fireHeartbeat(dt) — фейр RunService.Heartbeat
|
||||
* fireTargetEvent(p) — событие для target-скрипта (touch/click)
|
||||
* fireGlobalEvent(p) — playerTouch / guiClick / keydown
|
||||
*
|
||||
* Дизайн RbxSignal: хранится JS-сторона как {connections: [fn,...]}.
|
||||
* Lua видит обёртку {Connect=fn, connect=fn, Wait=fn, Fire=fn}.
|
||||
*/
|
||||
|
||||
const SCHEDULER = {
|
||||
sleeping: [], // [{coroutine, wakeAt}], wakeAt = performance.now()+ms
|
||||
now: () => performance.now(),
|
||||
};
|
||||
|
||||
const HEARTBEAT_SIGNAL = makeSignal();
|
||||
const STEPPED_SIGNAL = makeSignal();
|
||||
|
||||
function makeSignal() {
|
||||
const connections = [];
|
||||
return {
|
||||
__isSignal: true,
|
||||
connections,
|
||||
Fire(...args) { for (const fn of [...connections]) { try { fn(...args); } catch (_) {} } },
|
||||
Connect(fn) {
|
||||
if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {} };
|
||||
connections.push(fn);
|
||||
return {
|
||||
Disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); },
|
||||
disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); },
|
||||
Connected: true,
|
||||
};
|
||||
},
|
||||
Wait() {
|
||||
// в реальной реализации — coroutine.yield пока не fire'нется
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Vector3 ---
|
||||
class RbxVector3 {
|
||||
constructor(x = 0, y = 0, z = 0) {
|
||||
this.X = +x; this.Y = +y; this.Z = +z;
|
||||
}
|
||||
static new(x, y, z) { return new RbxVector3(x, y, z); }
|
||||
Magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
|
||||
get magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
|
||||
Unit() {
|
||||
const m = this.Magnitude() || 1;
|
||||
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
|
||||
}
|
||||
Normalize() { return this.Unit(); }
|
||||
Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; }
|
||||
Cross(b) {
|
||||
return new RbxVector3(
|
||||
this.Y * b.Z - this.Z * b.Y,
|
||||
this.Z * b.X - this.X * b.Z,
|
||||
this.X * b.Y - this.Y * b.X,
|
||||
);
|
||||
}
|
||||
Lerp(b, t) {
|
||||
return new RbxVector3(
|
||||
this.X + (b.X - this.X) * t,
|
||||
this.Y + (b.Y - this.Y) * t,
|
||||
this.Z + (b.Z - this.Z) * t,
|
||||
);
|
||||
}
|
||||
// Lua operators реализуются через __add/__sub/__mul (metatable установит shim ниже)
|
||||
}
|
||||
RbxVector3.zero = new RbxVector3(0, 0, 0);
|
||||
RbxVector3.one = new RbxVector3(1, 1, 1);
|
||||
RbxVector3.xAxis = new RbxVector3(1, 0, 0);
|
||||
RbxVector3.yAxis = new RbxVector3(0, 1, 0);
|
||||
RbxVector3.zAxis = new RbxVector3(0, 0, 1);
|
||||
|
||||
// --- Color3 ---
|
||||
class RbxColor3 {
|
||||
constructor(r = 0, g = 0, b = 0) { this.R = +r; this.G = +g; this.B = +b; }
|
||||
static new(r, g, b) { return new RbxColor3(r, g, b); }
|
||||
static fromRGB(r, g, b) { return new RbxColor3((r || 0) / 255, (g || 0) / 255, (b || 0) / 255); }
|
||||
static fromHSV(h, s, v) {
|
||||
const i = Math.floor(h * 6); const f = h * 6 - i;
|
||||
const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s);
|
||||
const [r, g, b] = [
|
||||
[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q],
|
||||
][i % 6];
|
||||
return new RbxColor3(r, g, b);
|
||||
}
|
||||
Lerp(b, t) {
|
||||
return new RbxColor3(
|
||||
this.R + (b.R - this.R) * t,
|
||||
this.G + (b.G - this.G) * t,
|
||||
this.B + (b.B - this.B) * t,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- UDim / UDim2 / Vector2 ---
|
||||
class RbxUDim {
|
||||
constructor(s = 0, o = 0) { this.Scale = +s; this.Offset = +o; }
|
||||
static new(s, o) { return new RbxUDim(s, o); }
|
||||
}
|
||||
class RbxUDim2 {
|
||||
constructor(sx = 0, ox = 0, sy = 0, oy = 0) {
|
||||
this.X = new RbxUDim(sx, ox);
|
||||
this.Y = new RbxUDim(sy, oy);
|
||||
}
|
||||
static new(sx, ox, sy, oy) { return new RbxUDim2(sx, ox, sy, oy); }
|
||||
static fromScale(sx, sy) { return new RbxUDim2(sx, 0, sy, 0); }
|
||||
static fromOffset(ox, oy) { return new RbxUDim2(0, ox, 0, oy); }
|
||||
}
|
||||
class RbxVector2 {
|
||||
constructor(x = 0, y = 0) { this.X = +x; this.Y = +y; }
|
||||
static new(x, y) { return new RbxVector2(x, y); }
|
||||
}
|
||||
|
||||
// --- CFrame (минимум) ---
|
||||
class RbxCFrame {
|
||||
constructor(x = 0, y = 0, z = 0) {
|
||||
this.X = +x; this.Y = +y; this.Z = +z;
|
||||
this.Position = new RbxVector3(x, y, z);
|
||||
// Полная матрица 3×3 на этапе 3
|
||||
}
|
||||
static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); }
|
||||
static lookAt(eye, target) {
|
||||
// упрощение — возвращаем cframe в позиции eye
|
||||
const cf = new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0);
|
||||
cf._lookAt = target;
|
||||
return cf;
|
||||
}
|
||||
static Angles(_rx, _ry, _rz) { return new RbxCFrame(); }
|
||||
static fromEulerAnglesXYZ(_rx, _ry, _rz) { return new RbxCFrame(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Главная регистрация. Возвращает api-объект используемый Worker'ом.
|
||||
*/
|
||||
export function registerRobloxShim(lua, opts) {
|
||||
const { send, getSceneSnapshot, getGuiTree, scheduleWait } = opts;
|
||||
const global = lua.global;
|
||||
|
||||
// ------ Vector3 ------
|
||||
// Lua: local v = Vector3.new(1,2,3); v.X; v + v; v.Magnitude
|
||||
const Vector3Table = {
|
||||
new: (x, y, z) => new RbxVector3(x, y, z),
|
||||
zero: RbxVector3.zero,
|
||||
one: RbxVector3.one,
|
||||
xAxis: RbxVector3.xAxis,
|
||||
yAxis: RbxVector3.yAxis,
|
||||
zAxis: RbxVector3.zAxis,
|
||||
FromNormalId: () => new RbxVector3(),
|
||||
};
|
||||
global.set('Vector3', Vector3Table);
|
||||
|
||||
// ------ Color3 ------
|
||||
global.set('Color3', {
|
||||
new: (r, g, b) => new RbxColor3(r, g, b),
|
||||
fromRGB: (r, g, b) => RbxColor3.fromRGB(r, g, b),
|
||||
fromHSV: (h, s, v) => RbxColor3.fromHSV(h, s, v),
|
||||
fromHex: (hex) => {
|
||||
const s = String(hex || '').replace('#', '');
|
||||
if (s.length !== 6) return new RbxColor3();
|
||||
return new RbxColor3(
|
||||
parseInt(s.slice(0, 2), 16) / 255,
|
||||
parseInt(s.slice(2, 4), 16) / 255,
|
||||
parseInt(s.slice(4, 6), 16) / 255,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ------ UDim / UDim2 / Vector2 / CFrame ------
|
||||
global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
|
||||
global.set('UDim2', {
|
||||
new: (sx, ox, sy, oy) => new RbxUDim2(sx, ox, sy, oy),
|
||||
fromScale: (sx, sy) => RbxUDim2.fromScale(sx, sy),
|
||||
fromOffset: (ox, oy) => RbxUDim2.fromOffset(ox, oy),
|
||||
});
|
||||
global.set('Vector2', { new: (x, y) => new RbxVector2(x, y) });
|
||||
global.set('CFrame', {
|
||||
new: (x, y, z) => RbxCFrame.new(x, y, z),
|
||||
lookAt: (e, t) => RbxCFrame.lookAt(e, t),
|
||||
Angles: RbxCFrame.Angles,
|
||||
fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
|
||||
});
|
||||
|
||||
// ------ Enum (минимум) ------
|
||||
global.set('Enum', {
|
||||
KeyCode: Object.fromEntries([
|
||||
'W', 'A', 'S', 'D', 'Space', 'LeftShift', 'LeftControl', 'F', 'E', 'Q',
|
||||
'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'G', 'H', 'J', 'K', 'L', 'Z', 'X',
|
||||
'C', 'V', 'B', 'N', 'M', 'Tab', 'Return', 'Escape', 'Backspace',
|
||||
'Up', 'Down', 'Left', 'Right',
|
||||
'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Zero',
|
||||
].map(k => [k, { Name: k, Value: k }])),
|
||||
UserInputType: Object.fromEntries([
|
||||
'MouseButton1', 'MouseButton2', 'MouseButton3', 'MouseMovement',
|
||||
'MouseWheel', 'Touch', 'Keyboard',
|
||||
].map(k => [k, { Name: k, Value: k }])),
|
||||
Material: Object.fromEntries([
|
||||
'Plastic', 'Wood', 'Metal', 'Neon', 'Glass', 'Sand', 'Ice', 'Grass', 'Concrete',
|
||||
].map(k => [k, { Name: k, Value: k }])),
|
||||
HumanoidStateType: Object.fromEntries([
|
||||
'Running', 'Jumping', 'Freefall', 'Landed', 'Dead', 'Climbing', 'Swimming', 'Seated',
|
||||
].map(k => [k, { Name: k, Value: k }])),
|
||||
EasingStyle: Object.fromEntries([
|
||||
'Linear', 'Sine', 'Quad', 'Cubic', 'Quart', 'Quint', 'Bounce', 'Elastic',
|
||||
].map(k => [k, { Name: k, Value: k }])),
|
||||
EasingDirection: Object.fromEntries([
|
||||
'In', 'Out', 'InOut',
|
||||
].map(k => [k, { Name: k, Value: k }])),
|
||||
});
|
||||
|
||||
// ------ print / warn / error логируются в студию ------
|
||||
global.set('print', (...args) => {
|
||||
const text = args.map(luaTostring).join('\t');
|
||||
send('log', { level: 'info', text });
|
||||
});
|
||||
global.set('warn', (...args) => {
|
||||
const text = args.map(luaTostring).join('\t');
|
||||
send('log', { level: 'warn', text });
|
||||
});
|
||||
// Stdlib error — оставлен (бросает Lua-error). Дополнительно — наш logToMain в pcall.
|
||||
|
||||
// ------ task.* и wait() ------
|
||||
// wait(sec) — приостанавливает текущую coroutine на sec секунд через scheduler.
|
||||
// task.wait, task.spawn, task.delay — современные эквиваленты.
|
||||
const taskTable = {
|
||||
wait: (sec) => luaWait(sec),
|
||||
spawn: (fn) => {
|
||||
// task.spawn(fn) — стартует функцию как «корутину», немедленно резюмит
|
||||
// у нас работает через прямой вызов pcall (упрощение, без честных coroutines)
|
||||
try { if (typeof fn === 'function') fn(); } catch (_) {}
|
||||
},
|
||||
delay: (sec, fn) => {
|
||||
// task.delay(sec, fn) — отложенный спавн
|
||||
if (typeof fn !== 'function') return;
|
||||
// Добавляем в scheduler
|
||||
const wakeAt = SCHEDULER.now() + (Number(sec) || 0) * 1000;
|
||||
SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } });
|
||||
},
|
||||
defer: (fn) => {
|
||||
if (typeof fn === 'function') {
|
||||
const wakeAt = SCHEDULER.now() + 0;
|
||||
SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } });
|
||||
}
|
||||
},
|
||||
synchronize: () => {},
|
||||
desynchronize: () => {},
|
||||
};
|
||||
global.set('task', taskTable);
|
||||
global.set('wait', (sec) => luaWait(sec));
|
||||
|
||||
/**
|
||||
* luaWait — блокировка текущего coroutine на sec секунд.
|
||||
* NB: использует lua-side coroutine.yield + Worker scheduler.
|
||||
* Здесь упрощение: возвращаем как обычный вызов (без honest yield) для MVP.
|
||||
* Honest реализация прийдёт когда intgrate с DataModel в Этапе 3.
|
||||
*/
|
||||
function luaWait(_sec) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------ game (минимум) ------
|
||||
// На Этапе 2 это пустой стаб — реальный DataModel будет на Этапе 3.
|
||||
// Но для совместимости с скриптами которые делают `game:GetService(...)`
|
||||
// возвращаем заглушку которая на всё отвечает безопасными no-op.
|
||||
const stubService = (name) => ({
|
||||
__isService: true,
|
||||
Name: name,
|
||||
ClassName: name,
|
||||
GetChildren: () => [],
|
||||
GetDescendants: () => [],
|
||||
FindFirstChild: () => null,
|
||||
FindFirstChildOfClass: () => null,
|
||||
WaitForChild: () => null,
|
||||
IsA: () => false,
|
||||
GetService: (n) => stubService(n),
|
||||
ChildAdded: makeSignal(),
|
||||
ChildRemoved: makeSignal(),
|
||||
DescendantAdded: makeSignal(),
|
||||
DescendantRemoving: makeSignal(),
|
||||
});
|
||||
const runService = stubService('RunService');
|
||||
runService.Heartbeat = HEARTBEAT_SIGNAL;
|
||||
runService.Stepped = STEPPED_SIGNAL;
|
||||
runService.RenderStepped = HEARTBEAT_SIGNAL; // упрощённо
|
||||
|
||||
const gameTable = {
|
||||
__isGame: true,
|
||||
Name: 'game',
|
||||
ClassName: 'DataModel',
|
||||
GetService(name) {
|
||||
if (name === 'RunService') return runService;
|
||||
return stubService(name);
|
||||
},
|
||||
FindService(name) {
|
||||
if (name === 'RunService') return runService;
|
||||
return null;
|
||||
},
|
||||
Workspace: stubService('Workspace'),
|
||||
Players: stubService('Players'),
|
||||
ReplicatedStorage: stubService('ReplicatedStorage'),
|
||||
ServerStorage: stubService('ServerStorage'),
|
||||
Lighting: stubService('Lighting'),
|
||||
StarterGui: stubService('StarterGui'),
|
||||
StarterPack: stubService('StarterPack'),
|
||||
StarterPlayer: stubService('StarterPlayer'),
|
||||
RunService: runService,
|
||||
UserInputService: stubService('UserInputService'),
|
||||
TweenService: stubService('TweenService'),
|
||||
HttpService: stubService('HttpService'),
|
||||
DataStoreService: stubService('DataStoreService'),
|
||||
MarketplaceService: stubService('MarketplaceService'),
|
||||
Chat: stubService('Chat'),
|
||||
SoundService: stubService('SoundService'),
|
||||
PathfindingService: stubService('PathfindingService'),
|
||||
PhysicsService: stubService('PhysicsService'),
|
||||
TeleportService: stubService('TeleportService'),
|
||||
CollectionService: stubService('CollectionService'),
|
||||
ContextActionService: stubService('ContextActionService'),
|
||||
ContentProvider: stubService('ContentProvider'),
|
||||
LocalizationService: stubService('LocalizationService'),
|
||||
};
|
||||
global.set('game', gameTable);
|
||||
global.set('workspace', gameTable.Workspace);
|
||||
global.set('Workspace', gameTable.Workspace);
|
||||
|
||||
// ------ Instance.new ------
|
||||
// Возвращает «pseudo-instance» — на Этапе 2 это просто object с пропсами.
|
||||
// На Этапе 3 будет полноценный класс с metatable и Parent setter.
|
||||
global.set('Instance', {
|
||||
new: (className, parent) => {
|
||||
const inst = {
|
||||
ClassName: String(className || 'Instance'),
|
||||
Name: String(className || 'Instance'),
|
||||
Parent: parent || null,
|
||||
Children: [],
|
||||
Destroyed: false,
|
||||
Touched: makeSignal(),
|
||||
Activated: makeSignal(),
|
||||
MouseButton1Click: makeSignal(),
|
||||
Changed: makeSignal(),
|
||||
AncestryChanged: makeSignal(),
|
||||
ChildAdded: makeSignal(),
|
||||
ChildRemoved: makeSignal(),
|
||||
GetChildren() { return [...this.Children]; },
|
||||
FindFirstChild() { return null; },
|
||||
WaitForChild() { return null; },
|
||||
IsA() { return false; },
|
||||
Destroy() { this.Destroyed = true; },
|
||||
Clone() { return null; },
|
||||
GetFullName() { return this.Name; },
|
||||
GetAttribute() { return null; },
|
||||
SetAttribute() {},
|
||||
};
|
||||
return inst;
|
||||
},
|
||||
});
|
||||
|
||||
// ------ Helpers для Worker'а ------
|
||||
// __rbxl_register_coroutine(id, co) — мы её отдадим, чтобы зарегистрировать в JS
|
||||
const coroutinesById = new Map();
|
||||
global.set('__rbxl_register_coroutine', (id, co) => {
|
||||
coroutinesById.set(String(id), co);
|
||||
});
|
||||
global.set('__rbxl_get_part_by_id', (_id) => {
|
||||
// На Этапе 3 будет lookup в DataModel. Пока nil (script.Parent = nil)
|
||||
return null;
|
||||
});
|
||||
global.set('__rbxl_script_run', (id, scriptObj, body) => {
|
||||
// Запускает body() с обработкой ошибок. id и scriptObj прокидываются
|
||||
// только для будущего использования (например, регистрации в DataModel).
|
||||
try {
|
||||
if (typeof body === 'function') body();
|
||||
} catch (err) {
|
||||
send('log', {
|
||||
level: 'error',
|
||||
text: `[Lua ${id}] ${err?.message || err}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ------ Возвращаем api для Worker'а ------
|
||||
return {
|
||||
// обновление снапшотов (будет использовано на Этапе 3 для DataModel)
|
||||
onSceneSnapshot() {},
|
||||
onGuiSnapshot() {},
|
||||
onDataSnapshot() {},
|
||||
// tick scheduler — резюм ожидающих task.delay/defer
|
||||
tickScheduler(_dt) {
|
||||
const now = SCHEDULER.now();
|
||||
if (SCHEDULER.sleeping.length === 0) return;
|
||||
const ready = [];
|
||||
const rest = [];
|
||||
for (const t of SCHEDULER.sleeping) {
|
||||
if (t.wakeAt <= now) ready.push(t);
|
||||
else rest.push(t);
|
||||
}
|
||||
SCHEDULER.sleeping = rest;
|
||||
for (const t of ready) {
|
||||
try { t.run(); } catch (_) {}
|
||||
}
|
||||
},
|
||||
fireHeartbeat(dt) {
|
||||
try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {}
|
||||
try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {}
|
||||
},
|
||||
fireTargetEvent(p) {
|
||||
// На Этапе 3 — найти Part в DataModel и фейернуть его Touched
|
||||
// Сейчас — no-op (но не падаем)
|
||||
// Возможные kind: 'touch', 'untouch', 'click'
|
||||
if (!p) return;
|
||||
},
|
||||
fireGlobalEvent(_p) {
|
||||
// playerTouch / guiClick / keydown — также на Этапе 3
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Утилиты ---
|
||||
function luaTostring(v) {
|
||||
if (v == null) return 'nil';
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number') return String(v);
|
||||
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
||||
if (v instanceof RbxVector3) return `${v.X}, ${v.Y}, ${v.Z}`;
|
||||
if (v instanceof RbxColor3) return `${v.R}, ${v.G}, ${v.B}`;
|
||||
if (typeof v === 'object') {
|
||||
if (v.Name) return String(v.Name);
|
||||
return '[object]';
|
||||
}
|
||||
try { return String(v); } catch (_) { return '?'; }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user