From 06df77cc97f4be36ad48bea00574dfabca9624b8 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 09:57:12 +0300 Subject: [PATCH 01/39] =?UTF-8?q?feat(lua):=20=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=D1=8B=201+2=20=E2=80=94=20Lua-=D1=81=D0=BA=D1=80=D0=B8=D0=BF?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B2=20=D0=A0=D1=83=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Этап 1 (UI): - Скрипт имеет поле language: 'js' | 'lua' (дефолт 'js') - Переключатель JS / Lua в шапке ScriptEditor (жёлтый / синий) - При смене с пустого/template — подставляется шаблон нового языка - При смене с реальным кодом — confirm - Monaco автоматически переключает подсветку - Badge JS/LUA в HierarchyPanel рядом с именем скрипта Этап 2 (базовый runtime): - LuaSharedSandbox — обёртка с API совместимым с ScriptSandbox - LuaSharedWorker — Web Worker с одним wasmoon-VM на всю игру - RobloxShim — Vector3/Color3/UDim2/Vector2/CFrame, Enum.*, print/warn, wait/task.*, RbxSignal, Instance.new (база), game.GetService (стабы), RunService.Heartbeat - Scheduler для task.delay/defer через main loop tick - GameRuntime разделяет скрипты: JS / Roblox-Lua (импорт) / user-Lua На Этапе 3 — DataModel (game.Workspace + Instance.Parent + Touched). Co-Authored-By: Claude Opus 4.7 --- RUBLOX_LUA_SUPPORT_PLAN.md | 434 +++++++++++++++++++ src/editor/HierarchyPanel.jsx | 27 +- src/editor/KubikonEditor.jsx | 6 + src/editor/ScriptEditor.jsx | 100 ++++- src/editor/engine/BabylonScene.js | 70 ++- src/editor/engine/GameRuntime.js | 36 +- src/editor/engine/RobloxLuaSharedSandbox.js | 17 +- src/editor/engine/RobloxLuaSharedWorker.js | 5 +- src/editor/engine/lua/LuaSharedSandbox.js | 215 ++++++++++ src/editor/engine/lua/LuaSharedWorker.js | 242 +++++++++++ src/editor/engine/lua/RobloxShim.js | 447 ++++++++++++++++++++ 11 files changed, 1569 insertions(+), 30 deletions(-) create mode 100644 RUBLOX_LUA_SUPPORT_PLAN.md create mode 100644 src/editor/engine/lua/LuaSharedSandbox.js create mode 100644 src/editor/engine/lua/LuaSharedWorker.js create mode 100644 src/editor/engine/lua/RobloxShim.js diff --git a/RUBLOX_LUA_SUPPORT_PLAN.md b/RUBLOX_LUA_SUPPORT_PLAN.md new file mode 100644 index 0000000..3ac4702 --- /dev/null +++ b/RUBLOX_LUA_SUPPORT_PLAN.md @@ -0,0 +1,434 @@ +# План: Полная поддержка Lua-скриптов в Рублоксе + +**Цель:** Пользователь Рублокс-студии создаёт скрипт → выбирает язык **JS** или **Lua** → пишет код → в плеере оба языка работают параллельно. Lua-скрипты совместимы с Roblox API настолько, чтобы код из Roblox-игр работал без модификаций. + +**Зачем:** В Roblox экосистеме сотни тысяч разработчиков, привыкших к Lua + Roblox API (Vector3, CFrame, Instance, RemoteEvent, etc.). Сейчас они не могут перенести свои игры. С этой фичей — могут. + +**Срок:** ~6 недель полного времени. Можно делать поэтапно (после каждого этапа есть полезный результат). + +**Юр.риск:** "юр риски беру на себя" (МИН, 2026-06-08). + +--- + +## Архитектурное решение + +### Текущая ситуация +- Скрипты хранятся как `{id, code, target, name}` в БД +- `GameRuntime` запускает каждый JS-скрипт в Web Worker `ScriptSandboxWorker.js` +- API игре доступен через `game.*` объект (события, scene, player, gui, save, etc.) +- Скриптов в игре могут быть **сотни**, каждый — отдельный Worker + +### Целевая +- Скрипты получают поле `language: 'js' | 'lua'` (default `'js'`) +- В редакторе — переключатель в `ScriptEditor.jsx` в шапке: **JS / Lua** +- Monaco-editor подсвечивает Lua (есть встроенный язык в `monaco-editor`) +- В runtime: JS-скрипты идут через старый sandbox, Lua — через новый `LuaSandbox.js` +- Lua-runtime построен поверх **wasmoon** (Lua 5.4 в WebAssembly) +- Lua-скрипты делятся на **один shared VM** на игру (а не Web Worker на скрипт) — иначе OOM при 100+ скриптах +- Lua-API проксирует Roblox API → нашу game.* плюс полную **DataModel** иерархию + +### Ключевая идея +**Lua-скрипты не вызывают `game.move(...)`. Они вызывают `script.Parent.Position = Vector3.new(1,2,3)`** — как в Roblox. Lua-shim под капотом переводит это в `partSet` команды для движка Рублокса. Юзер пишет идиоматичный Roblox-Lua, движок Рублокса исполняет. + +--- + +## Этап 1: UI и хранение языка (3 дня) + +### 1.1 Миграция БД +- Добавить поле `language VARCHAR(8) DEFAULT 'js' NOT NULL` в таблицу `scripts` (есть на storys API) +- API endpoints (`POST/PATCH /scripts/...`) принимают и сохраняют `language` +- Старые скрипты без поля → `'js'` по умолчанию + +### 1.2 ScriptEditor.jsx +- Переключатель в шапке редактора: `[ JS | Lua ]` (segmented control) +- При смене языка — confirm("Сменить язык? Текущий код будет очищен"), затем код сбрасывается на шаблон-заглушку нового языка +- Monaco language switch: `javascript` ↔ `lua` +- Подсветка/автодополнение Lua встроены в Monaco (`monaco-editor/esm/vs/basic-languages/lua`) +- Линтер ошибок Lua через **luaparse** (npm пакет, ~50KB, parse-only) — показываем красные подчёркивания +- Шаблон Lua для нового скрипта на target=Part: + ```lua + -- Скрипт на части. script.Parent = эта часть. + local part = script.Parent + part.Touched:Connect(function(hit) + print("Касание:", hit.Name) + end) + ``` +- Шаблон Lua для глобального скрипта (target=nil): + ```lua + local Players = game:GetService("Players") + Players.PlayerAdded:Connect(function(player) + print("Игрок зашёл:", player.Name) + end) + ``` + +### 1.3 Иконка языка в HierarchyPanel +- Рядом с именем скрипта — маленький бейдж `JS` или `Lua` (синий / голубой) +- Помогает не путаться при сотне скриптов + +### Что готово в конце Этапа 1 +- Юзер может **создать Lua-скрипт**, написать код, сохранить +- Код **не исполняется** — пока только хранится и редактируется +- В плеере Lua-скрипты молча игнорируются + +--- + +## Этап 2: Базовый Lua-runtime (5 дней) + +### 2.1 LuaSharedSandbox.js — новый sandbox-класс + +Полная архитектура шаринг-VM (один wasmoon-state на все Lua-скрипты игры): + +``` +GameRuntime +├── ScriptSandbox (JS-скрипт, Web Worker, как сейчас) +├── ScriptSandbox (JS-скрипт) +├── LuaSharedSandbox ← НОВЫЙ +│ ├── LuaSharedWorker.js +│ │ └── wasmoon VM (один на всю игру) +│ │ ├── Roblox API shim +│ │ ├── DataModel tree (game.Workspace, Players, ...) +│ │ └── все Lua-скрипты как сопрограммы (coroutines) +│ └── проксирует partSet/sceneCreate/event обратно в main thread +``` + +Файлы: +- `src/editor/engine/LuaSharedSandbox.js` (main thread): API совместимый с ScriptSandbox (`sendSceneSnapshot`, `sendGlobalEvent`, etc.) +- `src/editor/engine/LuaSharedWorker.js` (Web Worker): держит wasmoon, исполняет скрипты, шлёт командные сообщения +- `src/editor/engine/RobloxLuaShim.js` (worker side): объявление всех Roblox-классов и сервисов + +### 2.2 Минимальный Roblox shim в первой итерации +- `Vector3.new(x,y,z)`, `+`, `-`, `*`, `:Magnitude()`, `:Dot()`, `:Cross()`, `:Lerp()`, `:Normalize()` +- `Color3.new(r,g,b)`, `Color3.fromRGB(r,g,b)`, `:Lerp()` +- `CFrame.new(x,y,z)`, `CFrame.lookAt()`, `CFrame.fromEulerAnglesXYZ()`, операторы `*` и `:Inverse()`, `:ToWorldSpace()` +- `UDim2.new(sx,ox,sy,oy)`, `UDim.new(s,o)` +- `Enum.KeyCode.W`, `Enum.UserInputType.MouseButton1`, etc. (через generated table) +- `print()` → console + onLog event в студии +- `wait(secs)` / `task.wait(secs)` — через coroutine + scheduler в main loop +- `tick()`, `os.time()`, `os.clock()`, `math.*`, `string.*`, `table.*` (стандартные Lua) +- `pcall`, `xpcall`, `error`, `assert` (стандартные) + +### 2.3 GameRuntime интеграция +- При старте игры — пробежать по `scripts[]`, разделить на `jsScripts` и `luaScripts` +- Для JS — старый путь (по сэндбоксу на скрипт) +- Для Lua — один `LuaSharedSandbox`, в него `addScript(luaSource)` каждым +- LuaSharedSandbox шлёт назад те же команды что JS sandbox: `partSet`, `sceneCreate`, `chatSay`, `guiSet`, etc. + +### Что готово в конце Этапа 2 +- Lua-скрипты **исполняются** +- Можно использовать `Vector3`, `Color3`, `print()`, `wait()`, `math/string/table` +- Скрипт **ещё не видит** Workspace, Player, GUI + +--- + +## Этап 3: DataModel — game.Workspace и иерархия (5 дней) + +### 3.1 Что такое DataModel +В Roblox любая игра — **дерево объектов**. Корень = `game`. У него детки = сервисы: `Workspace`, `Players`, `ReplicatedStorage`, `Lighting`, `StarterGui`, `RunService`, etc. У каждого деток — свои детки. + +У нас сейчас сцена плоская: `primitives`, `blocks`, `models`. Нужно **виртуальное дерево DataModel** поверх плоской сцены. + +### 3.2 Виртуальное дерево +Файл: `src/editor/engine/datamodel/DataModelTree.js` + +При старте Lua-runtime, для текущей сцены строится виртуальное дерево: + +``` +game (RbxGame) +├── Workspace (RbxWorkspace) +│ ├── Part_0 ← обёртка над primitive id=0 +│ ├── Part_1 ← обёртка над primitive id=1 +│ ├── Model_5 ← обёртка над model id=5 (с Children) +│ │ └── Part_inner +│ ├── Camera +│ └── Terrain +├── Players (RbxPlayers) +│ └── LocalPlayer (RbxPlayer) +│ ├── Character (RbxCharacter) +│ │ ├── Humanoid (RbxHumanoid) +│ │ └── HumanoidRootPart (RbxPart) +│ └── PlayerGui (RbxScreenGui-контейнер) +│ └── (Lua-скрипты могут спавнить GUI через Instance.new) +├── ReplicatedStorage (RbxFolder) +├── ServerStorage (RbxFolder) +├── Lighting (RbxLighting) +├── StarterGui (RbxFolder) +├── StarterPlayer (RbxFolder) +│ └── StarterCharacter (RbxFolder) +├── RunService (RbxRunService с :Heartbeat, :Stepped, :RenderStepped) +├── UserInputService (события InputBegan/Ended/Changed) +├── TweenService (:Create, :GetService для tweens) +├── HttpService (заглушка либо проксируем через нашего бэка) +├── DataStoreService (проксируем через game.save) +└── MarketplaceService (заглушка) +``` + +### 3.3 Instance-классы +`src/editor/engine/datamodel/Instance.js`: + +```js +class RbxInstance { + Name = "Instance"; + ClassName = "Instance"; + Parent = null; + Children = []; + + // Свойства которые юзер может ставить через __newindex (metatable) + // отслеживаются — при изменении посылается команда в main thread + // для синхронизации с Babylon-сценой + + GetChildren() { return [...this.Children]; } + FindFirstChild(name, recursive) { ... } + WaitForChild(name, timeout) { ... } // через coroutine + yield + FindFirstAncestor(name) { ... } + FindFirstChildOfClass(class) { ... } + Destroy() { ... } + Clone() { ... } + IsA(class) { ... } + GetFullName() { ... } + GetAttribute(name) / SetAttribute(name, value) + GetPropertyChangedSignal(name) → RbxSignal +} + +class RbxPart extends RbxInstance { + Position // setter: партиклс в Babylon (primitiveManager.setPosition) + Size // setter + Color // setter + Material // setter (mapping Roblox materials → наши) + Anchored // setter + CanCollide // setter + Touched // RbxSignal — Fire когда BabylonScene детектит overlap + TouchEnded // RbxSignal + CFrame // computed property — Position + rotation +} + +class RbxModel extends RbxInstance { + PrimaryPart + GetPrimaryPartCFrame() / SetPrimaryPartCFrame() + PivotTo(cframe) // MoveTo + Rotate + GetBoundingBox() +} + +class RbxHumanoid extends RbxInstance { + Health = 100 + MaxHealth = 100 + WalkSpeed = 16 + JumpPower = 50 + Died, HealthChanged, Touched, StateChanged — signals + TakeDamage(amount) + MoveTo(pos) // simulates Roblox NPC pathing + LoadAnimation(anim) → RbxAnimationTrack +} + +class RbxScript extends RbxInstance { + Source // источник Lua (read-only обычно) + Disabled // bool + RunContext // Server / Client / Legacy +} + +class RbxRemoteEvent extends RbxInstance { + OnServerEvent : RbxSignal + OnClientEvent : RbxSignal + FireServer(...args) + FireClient(player, ...args) + FireAllClients(...args) +} +``` + +### 3.4 Метатаблицы Lua +Каждая JS-обёртка `RbxPart` экспортируется в Lua как **table с metatable**: +- `__index` — чтение свойства, либо метод +- `__newindex` — запись свойства, триггерит side-effects (синхронизация сцены) +- `__tostring` — для `print(part)` показывает `"Part_0"` + +Это **критично** для совместимости с Roblox-скриптами. + +### 3.5 script.Parent для каждого Lua-скрипта +- Если скрипт привязан к `target=42` (primitive id 42) — `script.Parent = workspace:FindFirstChild по primId(42)` +- Если глобальный — `script.Parent = nil` +- В скриптовом контексте: `script` это таблица `{Name=..., Parent=..., ClassName="Script"}` + +### Что готово в конце Этапа 3 +- Lua-скрипт может пройтись по `game.Workspace:GetChildren()` +- `script.Parent.Touched:Connect(...)` работает (KillBrick = реально) +- `local player = game.Players.LocalPlayer; player.Character.Humanoid.Health = 0` убивает игрока +- `Instance.new("Part", workspace)` создаёт примитив + +--- + +## Этап 4: Полный Roblox API (10 дней) + +Закрываем "длинный хвост" API. Каждый день — 1-2 сервиса. + +### 4.1 Services +- **RunService**: `.Heartbeat`, `.Stepped`, `.RenderStepped` — RbxSignals, фаер в main loop tick +- **UserInputService**: `.InputBegan`, `.InputChanged`, `.InputEnded` — события KeyCode/MouseButton/Touch +- **TweenService**: `:Create(instance, TweenInfo, propertyTable)` → возвращает RbxTween; `Tween:Play()/Pause()/Cancel()`; интерполируется в main loop +- **DataStoreService**: проксируем через наш `game.save` (sync version) + - `:GetDataStore(name)` → объект с `:GetAsync(key)`, `:SetAsync(key, value)`, `:UpdateAsync(key, fn)` + - Async-методы через coroutine.yield + наш save API +- **MarketplaceService**: заглушки `PromptPurchase`, `GetProductInfo` (бизнес-логика через наш интерфейс) +- **HttpService**: `:JSONEncode`, `:JSONDecode`, `:GenerateGUID` — простая встроенная реализация; `:GetAsync/PostAsync` проксируем через ограниченный список разрешённых доменов +- **Players**: `LocalPlayer`, `:GetPlayers()`, `PlayerAdded`/`PlayerRemoving` сигналы +- **Lighting**: read-only сейчас (через `Lighting.Ambient`, `Lighting.OutdoorAmbient` ставить значения нашему envManager) +- **Workspace**: `:Raycast(origin, direction, params)` → реальный raycast через PhysicsAABB; `:GetServerTimeNow()`; `CurrentCamera` + +### 4.2 GUI (важно!) +- `Instance.new("ScreenGui")` → если `Parent = playerGui`, регистрируется в нашем GuiManager +- `TextLabel`, `TextButton`, `ImageLabel`, `ImageButton`, `Frame` — все мапятся на наш GuiOverlay +- `MouseButton1Click`, `MouseEnter`, `MouseLeave`, `Activated` — сигналы +- `UDim2`, `Vector2` для позиций/размеров +- При установке `gui.Position = UDim2.new(0.5, 0, 0.5, 0)` — пересылается в GuiManager и обновляется DOM + +### 4.3 Sound +- `Instance.new("Sound", part)` с `SoundId = "rbxassetid://12345"` или с нашим URL +- `:Play()`, `:Stop()`, `:Pause()`, `Volume`, `Pitch`, `Looped` +- Под капотом — наш SoundManager + +### 4.4 Animation +- `Instance.new("Animation")` с `AnimationId` (наши собственные ID анимаций R15) +- `humanoid:LoadAnimation(anim) → AnimationTrack` +- `:Play()`, `:Stop()`, `:AdjustSpeed()`, `:GetMarkerReachedSignal()` +- Связь с нашим R15Animator + +### 4.5 Tools / Backpack +- `Tool` Instance: `Activated`, `Equipped`, `Unequipped` сигналы +- `player.Backpack:GetChildren()` — Lua видит инвентарь +- Реализация через наш HotbarManager + InventoryService + +### 4.6 ProximityPrompt, ClickDetector +- ProximityPrompt: `Triggered` сигнал, `:Show/:Hide`, ActionText, ObjectText +- ClickDetector: `MouseClick`, `MouseHoverEnter/Leave` сигналы + +### 4.7 Networking-эмуляция (single-player) +- RemoteEvent / RemoteFunction работают **локально** (поскольку у нас singleplayer на client-only) +- `FireServer/InvokeServer` запускают handlers в том же VM, но в other-side контексте +- Это позволяет копировать многопользовательские скрипты Roblox без изменений (хоть мультиплеера и нет) +- Когда у Рублокса появится мультиплеер — `RemoteEvent` уже будет работать «по-настоящему» без изменений в скриптах юзера + +### Что готово в конце Этапа 4 +- **~90% типовых Roblox-скриптов работают** без модификаций +- DataStore сохраняет прогресс +- TweenService плавно двигает объекты +- GUI создаётся скриптом +- Анимации играются + +--- + +## Этап 5: Импорт .rbxl + конвертер юзер-кода (5 дней) + +### 5.1 Изменение импортера +Сейчас импортер сохраняет Lua-исходник в JS-комментарии и пытается завернуть в JS-обёртку. **Это устаревает.** + +Новый путь: +- Импортер сохраняет Lua-source **как есть** (без обёрток) +- В записи скрипта `language = 'lua'` +- target = primitiveId или null +- В GameRuntime Lua-скрипт идёт сразу в LuaSharedSandbox + +### 5.2 Конвертация ассетов +- Roblox MeshId/TextureId через наш ImageProxy → ассеты сохраняются в minio + на CDN +- `rbxassetid://12345` → resolve в наш asset_id +- Сохранение в БД ссылок на ассеты + +### 5.3 Поведение при импорте +- При импорте .rbxl карты — все Lua-скрипты сохраняются как Lua-скрипты Рублокса (не пытаемся конвертить в JS) +- Юзер открывает игру → редактирует → видит в Hierarchy `Script (Lua)` рядом с `Script (JS)` — может писать на любом + +### Что готово в конце Этапа 5 +- Импорт Roblox-карты работает **бесшовно** +- Юзер может править Lua-код в редакторе и видеть результат + +--- + +## Этап 6: Производительность и стабильность (5 дней) + +### 6.1 Profiling +- Замерить FPS на картах с 100/500/1000 Lua-скриптов +- Если падает — переезд на **fengari** (pure-JS Lua interp, в 5-10× медленнее wasmoon но без WASM overhead на старте) либо на **собственный Lua-bytecode runtime** для горячих скриптов + +### 6.2 Memory +- Каждый wasmoon VM ~10-15MB. Один на игру = ОК. Если придётся разделять на части (server/client), нужно бенчить. + +### 6.3 Песочница (security) +- Lua не должен дёргать `io.*`, `os.execute`, `loadstring(внешний код)`, etc. +- Whitelist стандартной библиотеки. Запрещаем всё что может выйти из браузера. + +### 6.4 Ошибки +- Lua-ошибки в скрипте — показываются в Output-панели студии (как JS-ошибки) +- Stack trace с правильными номерами строк (не из обёртки) +- Если скрипт зациклился — kill через `debug.sethook` после N инструкций без yield + +### 6.5 Тесты +- 50 unit-тестов на Roblox API (Vector3 операции, CFrame, Instance.new, Touched, RunService, TweenService) +- 10 интеграционных: импортировать тест-rbxl, запустить, проверить что нужное случилось +- CI: тесты прогоняются в Gitea Actions при PR + +### Что готово в конце Этапа 6 +- Lua-runtime production-ready +- Можно объявить публично «теперь в Рублоксе пишут на Lua с Roblox-совместимостью» + +--- + +## Этап 7: Документация (3 дня) + +### 7.1 Раздел вики +- `wiki/lua-intro` — введение в Lua для Рублокса (для пользователей, которые приходят с Roblox — короткое) +- `wiki/lua-vs-js` — таблица: «то же самое на JS и на Lua» для типичных задач +- `wiki/roblox-api-supported` — список того что работает / не работает / отличается +- `wiki/lua-examples` — 20 готовых сниппетов (KillBrick, TeleportPad, Checkpoint, Coin, NPCFollower, etc.) + +### 7.2 Migration guide +- «Как перенести свою Roblox-игру в Рублокс» — пошаговое +- Список известных несовместимостей и их обходов + +### 7.3 PR-материал +- Пост в /developer на team.rublox.pro: «Lua-поддержка теперь GA» +- Если есть бюджет — короткий ролик на YouTube/TikTok + +--- + +## Этапы целиком + +| Этап | Длительность | Содержание | +|------|--------------|------------| +| 1 | 3 дня | UI и хранение языка | +| 2 | 5 дней | Базовый Lua-runtime + минимальный shim | +| 3 | 5 дней | DataModel (game.Workspace и иерархия) | +| 4 | 10 дней | Полный Roblox API (services, GUI, Sound, Animation) | +| 5 | 5 дней | Импорт .rbxl и асетов | +| 6 | 5 дней | Производительность, безопасность, тесты | +| 7 | 3 дня | Документация и публикация | +| **Итого** | **~36 рабочих дней** | **~6 недель полного времени** | + +--- + +## Что-то можно делать раньше, чтобы получить пользу + +- **MVP (Этапы 1+2):** через **8 дней** — юзер может писать Lua-скрипты с минимальным API (Vector3, Color3, print, wait). Уже видит что фича есть. +- **Beta (Этапы 1-3):** через **13 дней** — KillBrick'и работают, можно делать простые игры на Lua. +- **GA (все этапы):** через **6 недель** — продакшен. + +После каждого этапа можно делать релиз и собирать фидбек. + +--- + +## Решения которые нужны от тебя перед стартом + +1. **wasmoon vs fengari** — wasmoon быстрее но WASM-heavy, fengari проще но медленнее. Предлагаю wasmoon (уже используем для импорта). +2. **Один shared VM на игру** — согласен или разделять server/client? Предлагаю один в singleplayer-фазе, разделение — позже когда будет мультиплеер. +3. **Бэкенд изменения** — нужна миграция БД (поле `language`). У нас сейчас S2 + S1 + auto-backup, ничего страшного, но согласовать момент апдейта. +4. **Roblox API trademark/copyright** — мы делаем API-compatible runtime. Названия классов `Workspace`, `Humanoid`, etc. это API names. Юр.риск есть. Ты сказал берёшь — фиксируем. +5. **Приоритет** — этот план делать **вместо** других задач (тогда параллельные фичи стопаются) или **после** текущего бэклога? + +--- + +## Связанные документы + +- `RUBLOX_PROJECT.md` — общий план Рублокса +- `RUBLOX_EDITOR_ROADMAP.md` — куда движется редактор +- `INFO_PROCESS.md` — лог реализации (будет апдейтиться по ходу) + +--- + +**Создано:** 2026-06-08, Claude (Opus 4.7) совместно с МИНом. +**Статус:** план готов, ждём решения по 5 вопросам перед стартом. diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index 531aaed..357c573 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -37,7 +37,7 @@ const renderRowIcon = (val) => { const ItemRow = ({ icon, label, title, depth = 0, selected, plusItems, onClick, onDoubleClick, onContextMenu, onDragStart, draggable, - extraStyle, selId, + extraStyle, selId, badge, }) => { const [hovered, setHovered] = useState(false); const rowRef = React.useRef(null); @@ -84,6 +84,9 @@ const ItemRow = ({ > {renderRowIcon(icon)} {label} + {badge && ( + {badge} + )} {plusItems && plusItems.length > 0 && ( )} @@ -129,15 +132,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 = ( + + {isLua ? 'LUA' : 'JS'} + + ); return ( { scriptId={sc.id} value={sc.code} target={sc.target} + language={sc.language || 'js'} flushRef={scriptEditorFlushRef} isSoloRunning={soloScriptId === sc.id} + onLanguageChange={(lang, newCode) => { + sceneRef.current?.upsertScript(sc.id, newCode, undefined, undefined, lang); + setScriptsList(sceneRef.current?.getScripts?.() || []); + markDirty(); + }} onSave={(code) => { sceneRef.current?.upsertScript(sc.id, code, sc.target); setScriptsList(sceneRef.current?.getScripts?.() || []); diff --git a/src/editor/ScriptEditor.jsx b/src/editor/ScriptEditor.jsx index faeb2a1..cd09e7d 100644 --- a/src/editor/ScriptEditor.jsx +++ b/src/editor/ScriptEditor.jsx @@ -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} )} + {/* Переключатель языка JS / Lua */} + + {['js', 'lua'].map((lang) => { + const active = currentLanguage === lang; + return ( + + ); + })} + {/* Фаза 6.1.4: кнопка «Проверить» — включает семантический анализ TS на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.). @@ -394,10 +487,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
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', })); } // Окружение (время суток, скайбокс, туман) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 16dfde9..6f45e01 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.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'ы. Не перезаписываем существующий обработчик — diff --git a/src/editor/engine/RobloxLuaSharedSandbox.js b/src/editor/engine/RobloxLuaSharedSandbox.js index 84cda46..65cd699 100644 --- a/src/editor/engine/RobloxLuaSharedSandbox.js +++ b/src/editor/engine/RobloxLuaSharedSandbox.js @@ -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:' + 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)) { diff --git a/src/editor/engine/RobloxLuaSharedWorker.js b/src/editor/engine/RobloxLuaSharedWorker.js index 60c16d6..55837fb 100644 --- a/src/editor/engine/RobloxLuaSharedWorker.js +++ b/src/editor/engine/RobloxLuaSharedWorker.js @@ -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); } diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js new file mode 100644 index 0000000..b12f7ca --- /dev/null +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -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; diff --git a/src/editor/engine/lua/LuaSharedWorker.js b/src/editor/engine/lua/LuaSharedWorker.js new file mode 100644 index 0000000..aff4a94 --- /dev/null +++ b/src/editor/engine/lua/LuaSharedWorker.js @@ -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 + * - 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 = ...; 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). +} diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js new file mode 100644 index 0000000..1d449fe --- /dev/null +++ b/src/editor/engine/lua/RobloxShim.js @@ -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 '?'; } +} -- 2.47.2 From 71d2f2db83f9df14f4626c22d9f5063ce9d06255 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 10:07:51 +0300 Subject: [PATCH 02/39] =?UTF-8?q?fix(lua):=20tick()=20=D0=B2=20LuaSharedSa?= =?UTF-8?q?ndbox=20+=20autocomplete/hover=20Lua=20+=20ConfirmModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Критфикс: - LuaSharedSandbox.tick(dt, state) — no-op (GameRuntime.tick крашил) - LuaSharedSandbox.target (геттер) — null Monaco IntelliSense для Lua: - registerCompletionItemProvider('lua') — Vector3.new/Color3.fromRGB/UDim2/CFrame /Instance.new/game/workspace/script/task.*/print/wait/pcall/etc. - registerHoverProvider('lua') — документация при наведении на API - 6 готовых сниппетов: killbrick, teleportpad, coin, heartbeat, playeradded, spinpart UI: - ConfirmModal — кастомная модалка вместо window.confirm - В шапке ScriptEditor при смене языка — наша модалка с правильным стилем - Esc/Enter, автофокус на confirm-кнопке, blur-фон, поп-ин анимация Co-Authored-By: Claude Opus 4.7 --- src/editor/ConfirmModal.jsx | 193 +++++++++++++++++ src/editor/ScriptEditor.jsx | 33 ++- src/editor/engine/lua/LuaSharedSandbox.js | 9 + src/editor/lua-monaco-setup.js | 249 ++++++++++++++++++++++ 4 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 src/editor/ConfirmModal.jsx create mode 100644 src/editor/lua-monaco-setup.js diff --git a/src/editor/ConfirmModal.jsx b/src/editor/ConfirmModal.jsx new file mode 100644 index 0000000..e1e12b3 --- /dev/null +++ b/src/editor/ConfirmModal.jsx @@ -0,0 +1,193 @@ +/** + * ConfirmModal — кастомная модалка подтверждения вместо window.confirm. + * + * Использование: + * const [confirmState, setConfirmState] = useState(null); + * ... + * setConfirmState({ + * title: 'Сменить язык?', + * message: '...', + * confirmLabel: 'Сменить', + * cancelLabel: 'Отмена', + * onConfirm: () => doSomething(), + * }); + * ... + * {confirmState && setConfirmState(null)} />} + * + * Стиль — тёмная тема Рублокс-студии, кнопка confirm заметная. + */ +import React, { useEffect, useRef } from 'react'; + +export default function ConfirmModal({ + title, + message, + confirmLabel = 'OK', + cancelLabel = 'Отмена', + confirmTone = 'primary', // 'primary' | 'danger' + onConfirm, + onClose, +}) { + const confirmBtnRef = useRef(null); + + useEffect(() => { + // Автофокус на кнопке подтверждения + const t = setTimeout(() => confirmBtnRef.current?.focus(), 50); + const onKey = (e) => { + if (e.key === 'Escape') { e.preventDefault(); onClose?.(); } + else if (e.key === 'Enter') { + // Enter — confirm только если кнопка в фокусе или ничего не в фокусе + if (document.activeElement === confirmBtnRef.current || document.activeElement?.tagName === 'BODY') { + e.preventDefault(); + handleConfirm(); + } + } + }; + window.addEventListener('keydown', onKey); + return () => { clearTimeout(t); window.removeEventListener('keydown', onKey); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleConfirm = () => { + try { onConfirm?.(); } finally { onClose?.(); } + }; + + return ( +
+ +
e.stopPropagation()} + style={{ + background: 'linear-gradient(180deg, #2a2a2e 0%, #1f1f22 100%)', + border: '1px solid #3a3a40', + borderRadius: 14, + padding: '22px 26px 18px', + minWidth: 380, + maxWidth: 480, + color: '#e8e8ea', + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04)', + animation: 'rbxConfirmPopIn 160ms cubic-bezier(0.34, 1.56, 0.64, 1)', + }} + > + {title && ( +
+ {title} +
+ )} + {message && ( +
+ {message} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/editor/ScriptEditor.jsx b/src/editor/ScriptEditor.jsx index cd09e7d..57cb0de 100644 --- a/src/editor/ScriptEditor.jsx +++ b/src/editor/ScriptEditor.jsx @@ -7,6 +7,8 @@ import Icon from './Icon'; // при правке одного файла не перетряхивать все остальные. import { GAME_TYPE_LIBS } from './engine/types/bundle'; import { registerSnippets } from './engine/snippets'; +import { registerLuaInMonaco } from './lua-monaco-setup'; +import ConfirmModal from './ConfirmModal'; /** * ScriptEditor — Monaco-редактор кода скрипта в табе. @@ -70,6 +72,8 @@ function isCodeLikelyEmptyTemplate(code) { function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, language, onLanguageChange, onClose, flushRef }) { const currentLanguage = language === 'lua' ? 'lua' : 'js'; + // Кастомная модалка подтверждения смены языка (вместо window.confirm) + const [confirmState, setConfirmState] = useState(null); // Локальный буфер кода — то что в редакторе сейчас. // Синхронизируется с external value только при смене scriptId. const [localCode, setLocalCode] = useState(value || ''); @@ -197,6 +201,9 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe // Сниппеты для быстрого старта (door/coin/portal/npc/quest/save и т.д.). // Регистрируются один раз для всего Monaco, флаг хранится в monaco.__kubikonSnippetsRegistered. registerSnippets(monaco); + // Lua: completionProvider (Vector3.new/Color3.fromRGB/script.Parent/...) + // + hoverProvider (документация при наведении) + registerLuaInMonaco(monaco); } catch (e) { // eslint-disable-next-line no-console console.warn('[ScriptEditor] Monaco setup error', e); @@ -333,20 +340,22 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe onClick={() => { if (active) return; if (!onLanguageChange) return; - let nextCode = localCode; if (isCodeLikelyEmptyTemplate(localCode)) { - nextCode = lang === 'lua' + const 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); + return; } - onLanguageChange(lang, nextCode); + // Код не пустой — показываем кастомную модалку + setConfirmState({ + title: `Сменить язык на ${lang === 'lua' ? 'Lua' : 'JavaScript'}?`, + message: `Код останется как есть — синтаксис прежнего языка перестанет подсвечиваться, но текст не исчезнет. Можно переключиться обратно в любой момент.`, + confirmLabel: `Сменить на ${lang === 'lua' ? 'Lua' : 'JS'}`, + cancelLabel: 'Отмена', + onConfirm: () => onLanguageChange(lang, localCode), + }); }} style={{ padding: '4px 12px', @@ -528,6 +537,12 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe }} />
+ {confirmState && ( + setConfirmState(null)} + /> + )} ); } diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index b12f7ca..d73c411 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -45,6 +45,15 @@ export class LuaSharedSandbox { setOnCommand(cb) { this._onCommand = cb; } + /** + * GameRuntime вызывает sb.tick(dt, state) каждый кадр. + * Для JS-sandbox tick шлёт snapshot в worker. Для нас snapshot ходит через + * sendSceneSnapshot отдельно — здесь no-op. + * NB: target=null, потому что наш sandbox общий, не на конкретный объект. + */ + get target() { return null; } + tick(_dt, _state) { /* no-op: main-loop fires Heartbeat внутри worker */ } + /** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */ addScript(id, code, target) { const entry = { diff --git a/src/editor/lua-monaco-setup.js b/src/editor/lua-monaco-setup.js new file mode 100644 index 0000000..ece634d --- /dev/null +++ b/src/editor/lua-monaco-setup.js @@ -0,0 +1,249 @@ +/** + * lua-monaco-setup — регистрация Lua-фич в Monaco: + * 1) Подсветка через встроенный 'lua' language (Monaco поставляется с basic-languages/lua) + * 2) Автодополнение Roblox-API (Vector3.new, Color3.fromRGB, script.Parent, game.Players, ...) + * 3) Hover-документация (наведя на Vector3 — описание + пример) + * 4) Подсветка ошибок через luaparse (на этапе 7, опционально) + * + * Регистрируется ОДИН раз глобально через флаг monaco.__rbxLuaRegistered. + */ + +const ROBLOX_LUA_API = [ + // === Глобальные функции === + { kind: 'function', name: 'print', insertText: 'print($0)', doc: 'Выводит сообщения в Output-панель.\n```lua\nprint("Привет", x, y)\n```' }, + { kind: 'function', name: 'warn', insertText: 'warn($0)', doc: 'Выводит предупреждение (жёлтым).\n```lua\nwarn("Что-то не так")\n```' }, + { kind: 'function', name: 'error', insertText: 'error(${1:"сообщение"})', doc: 'Бросает ошибку, останавливая текущий скрипт.\n```lua\nerror("Здоровье < 0")\n```' }, + { kind: 'function', name: 'wait', insertText: 'wait(${1:1})', doc: 'Приостанавливает скрипт на N секунд (заменяется на `task.wait` в новом коде).' }, + { kind: 'function', name: 'tick', insertText: 'tick()', doc: 'Возвращает количество секунд с эпохи (как `os.time()`, но дробное).' }, + { kind: 'function', name: 'pcall', insertText: 'pcall(${1:fn}, $0)', doc: 'Защищённый вызов. Возвращает `success, result|error`.\n```lua\nlocal ok, err = pcall(function() risky() end)\nif not ok then warn(err) end\n```' }, + { kind: 'function', name: 'xpcall', insertText: 'xpcall(${1:fn}, ${2:handler})', doc: 'Защищённый вызов с кастомным обработчиком ошибки.' }, + { kind: 'function', name: 'tostring', insertText: 'tostring($0)', doc: 'Преобразует значение в строку.' }, + { kind: 'function', name: 'tonumber', insertText: 'tonumber($0)', doc: 'Преобразует строку в число. Возвращает nil если не число.' }, + { kind: 'function', name: 'type', insertText: 'type($0)', doc: 'Возвращает строку с типом: "nil", "number", "string", "boolean", "table", "function", "userdata".' }, + { kind: 'function', name: 'typeof', insertText: 'typeof($0)', doc: 'Расширенная версия type — для Roblox-типов вернёт "Vector3", "CFrame", "Color3", "Instance".' }, + { kind: 'function', name: 'ipairs', insertText: 'ipairs(${1:t})', doc: 'Итератор по числовым ключам массива.\n```lua\nfor i, v in ipairs(arr) do ... end\n```' }, + { kind: 'function', name: 'pairs', insertText: 'pairs(${1:t})', doc: 'Итератор по всем ключам таблицы.\n```lua\nfor k, v in pairs(t) do ... end\n```' }, + { kind: 'function', name: 'next', insertText: 'next(${1:t}, $0)', doc: 'Возвращает следующую пару ключ-значение в таблице.' }, + { kind: 'function', name: 'select', insertText: 'select(${1:1}, $0)', doc: 'select("#", ...) — количество аргументов. select(n, ...) — n-й и далее аргументы.' }, + { kind: 'function', name: 'unpack', insertText: 'unpack(${1:t})', doc: 'Распаковывает массив в значения. (В Lua 5.4 — `table.unpack`)' }, + { kind: 'function', name: 'setmetatable', insertText: 'setmetatable(${1:t}, ${2:mt})', doc: 'Устанавливает metatable для таблицы.' }, + { kind: 'function', name: 'getmetatable', insertText: 'getmetatable($0)', doc: 'Возвращает metatable или nil.' }, + { kind: 'function', name: 'rawget', insertText: 'rawget(${1:t}, ${2:key})', doc: 'Чтение без вызова __index metatable.' }, + { kind: 'function', name: 'rawset', insertText: 'rawset(${1:t}, ${2:key}, ${3:value})', doc: 'Запись без вызова __newindex metatable.' }, + + // === task.* === + { kind: 'module', name: 'task', insertText: 'task', doc: 'Современный API планировщика Roblox-Lua.\nЗаменяет `wait`, `spawn`, `delay`, `defer` из старого API.' }, + { kind: 'function', name: 'task.wait', insertText: 'task.wait(${1:1})', doc: 'Приостанавливает на N секунд.\nВозвращает фактическое время ожидания.\n```lua\nlocal dt = task.wait(0.5)\n```' }, + { kind: 'function', name: 'task.spawn', insertText: 'task.spawn(${1:function() end})', doc: 'Немедленно запускает функцию как coroutine.\n```lua\ntask.spawn(function() heavy() end)\n```' }, + { kind: 'function', name: 'task.delay', insertText: 'task.delay(${1:1}, ${2:function() end})', doc: 'Отложенный запуск функции через N секунд.\n```lua\ntask.delay(3, function() print("через 3 сек") end)\n```' }, + { kind: 'function', name: 'task.defer', insertText: 'task.defer(${1:function() end})', doc: 'Запуск в следующем кадре (после Heartbeat).' }, + + // === Vector3 === + { kind: 'class', name: 'Vector3', insertText: 'Vector3', doc: '3D-вектор в Roblox.\nКонструктор: `Vector3.new(x, y, z)`.\nКонстанты: `Vector3.zero`, `Vector3.one`, `Vector3.xAxis`, `Vector3.yAxis`, `Vector3.zAxis`.' }, + { kind: 'function', name: 'Vector3.new', insertText: 'Vector3.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт `Vector3(x, y, z)`.\n```lua\nlocal v = Vector3.new(10, 5, 0)\nprint(v.X, v.Y, v.Z, v.Magnitude)\n```' }, + { kind: 'function', name: 'Vector3.zero', insertText: 'Vector3.zero', doc: '`Vector3(0, 0, 0)`.' }, + { kind: 'function', name: 'Vector3.one', insertText: 'Vector3.one', doc: '`Vector3(1, 1, 1)`.' }, + { kind: 'function', name: 'Vector3.xAxis', insertText: 'Vector3.xAxis', doc: '`Vector3(1, 0, 0)`.' }, + { kind: 'function', name: 'Vector3.yAxis', insertText: 'Vector3.yAxis', doc: '`Vector3(0, 1, 0)`.' }, + { kind: 'function', name: 'Vector3.zAxis', insertText: 'Vector3.zAxis', doc: '`Vector3(0, 0, 1)`.' }, + + // === Color3 === + { kind: 'class', name: 'Color3', insertText: 'Color3', doc: 'Цвет RGB в Roblox.\nКомпоненты `R`, `G`, `B` в диапазоне [0, 1].' }, + { kind: 'function', name: 'Color3.new', insertText: 'Color3.new(${1:1}, ${2:1}, ${3:1})', doc: 'Создаёт `Color3(r, g, b)`, где компоненты в [0, 1].' }, + { kind: 'function', name: 'Color3.fromRGB', insertText: 'Color3.fromRGB(${1:255}, ${2:255}, ${3:255})', doc: 'Создаёт `Color3` из 0-255 RGB.\n```lua\nlocal red = Color3.fromRGB(255, 0, 0)\n```' }, + { kind: 'function', name: 'Color3.fromHSV', insertText: 'Color3.fromHSV(${1:0}, ${2:1}, ${3:1})', doc: 'Создаёт цвет из HSV-компонентов в [0, 1].' }, + { kind: 'function', name: 'Color3.fromHex', insertText: 'Color3.fromHex(${1:"#FF0000"})', doc: 'Создаёт цвет из hex-строки.' }, + + // === CFrame === + { kind: 'class', name: 'CFrame', insertText: 'CFrame', doc: 'Coordinate Frame — позиция + поворот в 3D.\nИспользуется для трансформаций Part.CFrame.' }, + { kind: 'function', name: 'CFrame.new', insertText: 'CFrame.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт CFrame в указанной позиции.' }, + { kind: 'function', name: 'CFrame.lookAt', insertText: 'CFrame.lookAt(${1:eye}, ${2:target})', doc: 'CFrame, направленный из eye на target.' }, + { kind: 'function', name: 'CFrame.Angles', insertText: 'CFrame.Angles(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame только с поворотом (в радианах).' }, + { kind: 'function', name: 'CFrame.fromEulerAnglesXYZ', insertText: 'CFrame.fromEulerAnglesXYZ(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame с поворотом по эйлеровым углам.' }, + + // === UDim2 / Vector2 === + { kind: 'class', name: 'UDim2', insertText: 'UDim2', doc: 'Размер/позиция GUI: процент + пиксели по обеим осям.' }, + { kind: 'function', name: 'UDim2.new', insertText: 'UDim2.new(${1:0}, ${2:0}, ${3:0}, ${4:0})', doc: '`UDim2.new(scaleX, offsetX, scaleY, offsetY)`.\n```lua\nframe.Position = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана\n```' }, + { kind: 'function', name: 'UDim2.fromScale', insertText: 'UDim2.fromScale(${1:0.5}, ${2:0.5})', doc: 'Только процентные размеры.' }, + { kind: 'function', name: 'UDim2.fromOffset', insertText: 'UDim2.fromOffset(${1:100}, ${2:100})', doc: 'Только пиксельные размеры.' }, + { kind: 'class', name: 'Vector2', insertText: 'Vector2', doc: '2D-вектор.' }, + { kind: 'function', name: 'Vector2.new', insertText: 'Vector2.new(${1:0}, ${2:0})', doc: '`Vector2(x, y)`.' }, + { kind: 'class', name: 'UDim', insertText: 'UDim', doc: 'Одномерная UDim (scale + offset).' }, + { kind: 'function', name: 'UDim.new', insertText: 'UDim.new(${1:0}, ${2:0})', doc: '`UDim.new(scale, offset)`.' }, + + // === Instance === + { kind: 'class', name: 'Instance', insertText: 'Instance', doc: 'Базовый класс всех объектов Roblox.' }, + { kind: 'function', name: 'Instance.new', insertText: 'Instance.new("${1:Part}", ${2:workspace})', doc: 'Создаёт новый объект указанного класса.\n```lua\nlocal part = Instance.new("Part", workspace)\npart.Size = Vector3.new(4, 1, 4)\npart.Position = Vector3.new(0, 10, 0)\n```' }, + + // === game / services === + { kind: 'variable', name: 'game', insertText: 'game', doc: 'Корень DataModel. `game:GetService("Players")` — доступ к сервисам.' }, + { kind: 'variable', name: 'workspace', insertText: 'workspace', doc: 'Сокращение для `game.Workspace`. Содержит все Part-объекты сцены.' }, + { kind: 'variable', name: 'script', insertText: 'script', doc: 'Текущий скрипт. `script.Parent` — объект-носитель.\n```lua\nlocal part = script.Parent\npart.Touched:Connect(function(hit) ... end)\n```' }, + + // === Enum === + { kind: 'enum', name: 'Enum', insertText: 'Enum', doc: 'Перечисления Roblox: KeyCode, Material, UserInputType, EasingStyle, EasingDirection, HumanoidStateType.' }, + { kind: 'enum', name: 'Enum.KeyCode', insertText: 'Enum.KeyCode.${1:W}', doc: 'Клавиши клавиатуры: W, A, S, D, Space, LeftShift, Q, E, F, R, T, ..., One, Two, ..., Up, Down.' }, + { kind: 'enum', name: 'Enum.UserInputType', insertText: 'Enum.UserInputType.${1:MouseButton1}', doc: 'Типы ввода: MouseButton1/2/3, Keyboard, Touch, MouseMovement, MouseWheel.' }, + { kind: 'enum', name: 'Enum.Material', insertText: 'Enum.Material.${1:Plastic}', doc: 'Материалы: Plastic, Wood, Metal, Neon, Glass, Sand, Ice, Grass, Concrete.' }, + { kind: 'enum', name: 'Enum.HumanoidStateType', insertText: 'Enum.HumanoidStateType.${1:Running}', doc: 'Состояния Humanoid: Running, Jumping, Freefall, Landed, Dead, Climbing, Swimming, Seated.' }, +]; + +// === Сниппеты быстрого старта (готовые шаблоны) === +const ROBLOX_LUA_SNIPPETS = [ + { + label: 'killbrick', + documentation: 'KillBrick — убивает игрока при касании.', + insertText: [ + 'local part = script.Parent', + 'part.Touched:Connect(function(hit)', + '\tlocal humanoid = hit.Parent:FindFirstChildOfClass("Humanoid")', + '\tif humanoid then', + '\t\thumanoid.Health = 0', + '\tend', + 'end)', + ].join('\n'), + }, + { + label: 'teleportpad', + documentation: 'TeleportPad — телепортирует игрока в указанную точку.', + insertText: [ + 'local destination = Vector3.new(${1:0}, ${2:50}, ${3:0})', + 'local pad = script.Parent', + 'pad.Touched:Connect(function(hit)', + '\tlocal root = hit.Parent:FindFirstChild("HumanoidRootPart")', + '\tif root then', + '\t\troot.CFrame = CFrame.new(destination)', + '\tend', + 'end)', + ].join('\n'), + }, + { + label: 'coin', + documentation: 'Coin — даёт игроку монету при касании, потом исчезает.', + insertText: [ + 'local coin = script.Parent', + 'local collected = false', + 'coin.Touched:Connect(function(hit)', + '\tif collected then return end', + '\tif hit.Parent:FindFirstChildOfClass("Humanoid") then', + '\t\tcollected = true', + '\t\tprint("Монета собрана!")', + '\t\tcoin:Destroy()', + '\tend', + 'end)', + ].join('\n'), + }, + { + label: 'heartbeat', + documentation: 'RunService.Heartbeat — кадровый callback.', + insertText: [ + 'local RunService = game:GetService("RunService")', + 'RunService.Heartbeat:Connect(function(dt)', + '\t${0:-- код, выполняется каждый кадр}', + 'end)', + ].join('\n'), + }, + { + label: 'playeradded', + documentation: 'PlayerAdded — реакция на захождение игрока.', + insertText: [ + 'local Players = game:GetService("Players")', + 'Players.PlayerAdded:Connect(function(player)', + '\tprint("Игрок зашёл:", player.Name)', + '\t${0:}', + 'end)', + ].join('\n'), + }, + { + label: 'spinpart', + documentation: 'SpinPart — вращающаяся платформа.', + insertText: [ + 'local RunService = game:GetService("RunService")', + 'local part = script.Parent', + 'local speed = ${1:2} -- радиан/сек', + 'RunService.Heartbeat:Connect(function(dt)', + '\tpart.CFrame = part.CFrame * CFrame.Angles(0, speed * dt, 0)', + 'end)', + ].join('\n'), + }, +]; + +export function registerLuaInMonaco(monaco) { + if (monaco.__rbxLuaRegistered) return; + monaco.__rbxLuaRegistered = true; + + // 1. CompletionProvider — автодополнение + monaco.languages.registerCompletionItemProvider('lua', { + triggerCharacters: ['.', ':', '"', "'"], + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + const suggestions = []; + const kindMap = { + 'function': monaco.languages.CompletionItemKind.Function, + 'class': monaco.languages.CompletionItemKind.Class, + 'module': monaco.languages.CompletionItemKind.Module, + 'enum': monaco.languages.CompletionItemKind.Enum, + 'variable': monaco.languages.CompletionItemKind.Variable, + }; + for (const item of ROBLOX_LUA_API) { + suggestions.push({ + label: item.name, + kind: kindMap[item.kind] || monaco.languages.CompletionItemKind.Text, + insertText: item.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: { value: item.doc, isTrusted: true }, + range, + }); + } + for (const snip of ROBLOX_LUA_SNIPPETS) { + suggestions.push({ + label: snip.label, + kind: monaco.languages.CompletionItemKind.Snippet, + insertText: snip.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: { value: snip.documentation, isTrusted: true }, + detail: 'Сниппет Roblox-Lua', + range, + }); + } + return { suggestions }; + }, + }); + + // 2. HoverProvider — подсказки при наведении + const lookupTable = new Map(); + for (const item of ROBLOX_LUA_API) lookupTable.set(item.name, item); + monaco.languages.registerHoverProvider('lua', { + provideHover: (model, position) => { + const word = model.getWordAtPosition(position); + if (!word) return null; + // Пробуем найти точное совпадение или с префиксом (Vector3.new) + let found = lookupTable.get(word.word); + if (!found) { + // Возможно курсор на середине A.B — попробуем собрать всю цепочку + const line = model.getLineContent(position.lineNumber); + // Ищем имя.имя.имя на позиции + const left = line.slice(0, word.endColumn - 1); + const m = left.match(/[A-Za-z_][\w.]*$/); + if (m) found = lookupTable.get(m[0]); + } + if (!found) return null; + return { + range: new monaco.Range( + position.lineNumber, word.startColumn, + position.lineNumber, word.endColumn, + ), + contents: [ + { value: `**${found.name}**` }, + { value: found.doc }, + ], + }; + }, + }); +} -- 2.47.2 From d41051edf935fd1e0d1aded1bc717fbe08687ece Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 10:28:04 +0300 Subject: [PATCH 03/39] =?UTF-8?q?fix(lua):=20StudioCollab=20=D0=B3=D0=BB?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=BB=205-=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=82=D1=80=20language=20=D0=B2=20upsertScri?= =?UTF-8?q?pt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень бага переключателя Lua: StudioCollab.js переопределял scene.upsertScript с 4-мя аргументами (id,code,target,name) и не передавал переданный 5-ый language в оригинал. Поэтому при смене языка в UI language пропадал и переключатель оставался на JS. Исправлено: - Обёртка scene.upsertScript = function (id, code, target, name, language) - Наследие collab-операции с language в sendOp и в _applyRemoteOp Диагностические console.log убраны. Co-Authored-By: Claude Opus 4.7 --- src/editor/ScriptEditor.jsx | 14 ++++++++++---- src/editor/engine/StudioCollab.js | 7 ++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/editor/ScriptEditor.jsx b/src/editor/ScriptEditor.jsx index 57cb0de..ce52d5d 100644 --- a/src/editor/ScriptEditor.jsx +++ b/src/editor/ScriptEditor.jsx @@ -348,13 +348,19 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe onLanguageChange(lang, nextCode); return; } - // Код не пустой — показываем кастомную модалку + // Код не пустой — показываем кастомную модалку. + // ВАЖНО: lang захвачен через map'a, но localCode и onLanguageChange + // надо взять из ref'ов на момент confirm, иначе stale closure. + const targetLang = lang; setConfirmState({ - title: `Сменить язык на ${lang === 'lua' ? 'Lua' : 'JavaScript'}?`, + title: `Сменить язык на ${targetLang === 'lua' ? 'Lua' : 'JavaScript'}?`, message: `Код останется как есть — синтаксис прежнего языка перестанет подсвечиваться, но текст не исчезнет. Можно переключиться обратно в любой момент.`, - confirmLabel: `Сменить на ${lang === 'lua' ? 'Lua' : 'JS'}`, + confirmLabel: `Сменить на ${targetLang === 'lua' ? 'Lua' : 'JS'}`, cancelLabel: 'Отмена', - onConfirm: () => onLanguageChange(lang, localCode), + onConfirm: () => { + // Берём актуальное значение из ref (не stale closure) + onLanguageChange(targetLang, localCodeRef.current); + }, }); }} style={{ diff --git a/src/editor/engine/StudioCollab.js b/src/editor/engine/StudioCollab.js index ccb4c74..f9f6166 100644 --- a/src/editor/engine/StudioCollab.js +++ b/src/editor/engine/StudioCollab.js @@ -170,8 +170,8 @@ export class StudioCollab { sc.__collabScriptsPatched = true; if (typeof sc.upsertScript === 'function') { const origUpsert = sc.upsertScript.bind(sc); - sc.upsertScript = function (id, code, target, name) { - const r = origUpsert(id, code, target, name); + sc.upsertScript = function (id, code, target, name, language) { + const r = origUpsert(id, code, target, name, language); if (!self._applyingRemote) { // id может быть сгенерён внутри upsertScript, если был null — // достаём фактический из _scripts (последний с этим code). @@ -188,6 +188,7 @@ export class StudioCollab { code: rec.code, target: rec.target ?? null, name: rec.name ?? null, + language: rec.language ?? 'js', }); } } @@ -433,7 +434,7 @@ export function applyRemoteOp(scene, op) { // Создание/редактирование скрипта у соавтора. _applyingRemote уже // выставлен (см. _applyRemoteOp) → обёртка upsertScript не зашлёт // эхо обратно. _onSceneChange внутри обновит React-панели. - scene.upsertScript?.(op.id, op.code, op.target ?? null, op.name ?? null); + scene.upsertScript?.(op.id, op.code, op.target ?? null, op.name ?? null, op.language ?? undefined); scene._onCollabScriptsChange?.(); return; case 'scriptRemove': -- 2.47.2 From b7a0b083b6bd4353c45f0d10bbe7e79ed44ec918 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 10:51:42 +0300 Subject: [PATCH 04/39] =?UTF-8?q?fix(lua):=20Vector3.Magnitude/Unit=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20getters=20+=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20language=20=D0=B2=20?= =?UTF-8?q?=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vector3: - Magnitude теперь getter (без скобок) — как в Roblox - Unit теперь getter - Поддержаны lowercase алиасы magnitude, unit Сохранение: - BabylonScene serialize включает language в scripts[] - Clip/copy включает language - LuaSharedWorker.handleInit обёрнут в try/catch с детальной ошибкой - LuaSharedWorker использует статический import для wasmoon Этап 2 завершён полностью. Lua-runtime прошёл все тесты: print, warn, Vector3, Color3, UDim2, CFrame, Enum, task.delay/spawn/defer, RunService.Heartbeat, math/string/table, pcall. Следующий этап 3: DataModel (game.Workspace + Instance + script.Parent + Part.Touched + Part.Position setter). Co-Authored-By: Claude Opus 4.7 --- RBXL_SOURCES.md | 124 +++++++++++++++++++++++ src/editor/engine/BabylonScene.js | 10 +- src/editor/engine/lua/LuaSharedWorker.js | 36 +++---- src/editor/engine/lua/RobloxShim.js | 16 ++- 4 files changed, 156 insertions(+), 30 deletions(-) create mode 100644 RBXL_SOURCES.md diff --git a/RBXL_SOURCES.md b/RBXL_SOURCES.md new file mode 100644 index 0000000..e0e132e --- /dev/null +++ b/RBXL_SOURCES.md @@ -0,0 +1,124 @@ +# Реестр источников .rbxl / .rbxlx для портирования в Рублокс + +Цель: легально добыть Roblox Place-файлы (.rbxl бинарный / .rbxlx XML) по жанрам +для портирования и публикации на Рублоксе. + +**Форматы:** `.rbxlx` (XML — предпочтителен, читаемый, легко парсить геометрию/CFrame) +· `.rbxl` (бинарный, конвертировать) · `.rbxm`/`.rbxmx` (модели, не целые места). + +> ⚠️ **Главное про публикацию:** «uncopylocked» ≠ свободная лицензия. Для ПУБЛИКАЦИИ +> порта на Рублоксе безопасны только: репо с явной **MIT/Apache/MPL/CC0/CC-BY** + +> официальные ассеты Roblox с разрешением. Архивы чужих игр — только для +> обучения/прототипа парсера, НЕ для публикации. Lua-скрипты не портируются +> автоматом — логику переписываешь сам (это и снижает юр.риски). + +--- + +## ИНСТРУМЕНТЫ (распаковка/парсинг) + +| Инструмент | URL | Назначение | Лицензия | +|---|---|---|---| +| Rojo | https://github.com/rojo-rbx/rojo | place ↔ файлы | MPL-2.0 | +| rbxlx-to-rojo | https://github.com/rojo-rbx/rbxlx-to-rojo | .rbxl/.rbxlx → проект | MPL/MIT (проверить) | +| rbxfile (Go) | https://github.com/robloxapi/rbxfile | парсинг rbxl/rbxlx/rbxm | MIT (проверить) | +| remodel | https://github.com/rojo-rbx/remodel | скриптовая обработка | MPL-2.0 | +| RobloxAPI/spec | https://github.com/RobloxAPI/spec/blob/master/formats/rbxl.md | спека бинарного формата | docs | + +--- + +## (А) ОФИЦИАЛЬНЫЕ — самые надёжные + +### A1. Roblox/Old-Open-Source-Levels — классика от самой Roblox Corp ⭐ +- https://github.com/Roblox/Old-Open-Source-Levels +- Каталог: https://github.com/Roblox/Old-Open-Source-Levels/blob/master/catalog.md +- ~30+ мест 2007-2013 (.rbxl). Жанры: Crossroads (арена/PvP), Castle Warfare, + ROBLOX Battle (бой), Sword Fight in the Dark (PvP), Haunted Mansion (хоррор), + Glass Houses, Pinball Wizards, Happy Home in Robloxia (песочница/мини). +- Лицензия: «free to manipulate however you wish» — **проверь файл LICENSE вручную** перед публикацией. + +### A2. Встроенные шаблоны Roblox Studio +- Список: https://create.roblox.com/docs/resources/templates +- В Studio: открыть шаблон → File → Save to File → .rbxlx +- Baseplate, Castle, Suburban, Village, Racing, Classic Obby, Team Deathmatch/Combat, + Capture the Flag, Line Runner, Pirate Island, Modern City и др. +- Серая зона для публикации «как есть» — используй как базу/учёбу, геометрию делай своей. + +### A3. creator-docs (документация Roblox, open) +- https://github.com/Roblox/creator-docs + +### A4. Internet Archive — Crossroads (все версии 2007-2017) +- https://archive.org/details/roblox_crossroads +- https://archive.org/details/classic-crossroads_202408 + +--- + +## (Б) РЕПОЗИТОРИИ С КОДОМ/МЕСТАМИ (URL из поиска) + +### С подтверждённой свободной лицензией (можно публиковать) +| Репо | URL | Лицензия | Жанр | +|---|---|---|---| +| Vigilant | https://github.com/IsoLogicGames/Vigilant | **MIT** ✅ | co-op horde-survival (шутер) | +| crossroads-rojo | https://github.com/Dekkonot/crossroads-rojo | наследует Crossroads | арена | + +### Open-source игры (лицензию проверить у каждого — файл LICENSE) +| Репо | URL | Жанр | +|---|---|---| +| Miner's Haven | https://github.com/berezaa/minershaven | tycoon/симулятор | +| roblox-gym-tycoon | https://github.com/jason-lee88/roblox-gym-tycoon | tycoon | +| Racing-Kit-Roblox | https://github.com/Astrophsica/Racing-Kit-Roblox | гонки | +| RENTED_old_rbx | https://github.com/ReRand/RENTED_old_rbx | хоррор | +| roblox-rpg | https://github.com/mobyrblx/roblox-rpg | RPG/демо | +| RobloxGames (dwmk) | https://github.com/dwmk/RobloxGames | разное | +| recsObby | https://github.com/Nimblz/recsObby | obby | +| WavyRobloxObby | https://github.com/sammy0127/WavyRobloxObby | obby (.rbxlx) | +| Sight-Obby | https://github.com/TeoJJss/Sight-Obby | obby | +| fps (Anninzy) | https://github.com/Anninzy/fps | FPS | +| roblox-game-example | https://github.com/areshaistg/roblox-game-example | демо-каркас | + +### Архивы чужих игр (ТОЛЬКО обучение/прототип, НЕ публикация — смешанные права) +| Репо | URL | +|---|---| +| uncopylocked-game-collection | https://github.com/Kitaske/uncopylocked-game-collection | +| robloxplacearchive | https://github.com/tropicalbananas/robloxplacearchive | +| RobloxRBXLArchive | https://github.com/LuaGunsX/RobloxRBXLArchive | +| Biggest Uncopylocked Library | https://github.com/KH0DIN/Biggest_Uncopylocked_Roblox_Games_Library | +| GitHub topics | https://github.com/topics/rbxlx · /rbxl · /rbxm · /rojo · /uncopylocked | + +--- + +## (В) САЙТЫ ДЛЯ САМОСТОЯТЕЛЬНОГО СКАЧИВАНИЯ + +### Прямое скачивание .rbxl/.rbxlx +- **GitHub code search** (вход обязателен): `extension:rbxlx`, `extension:rbxl`, + `filename:default.project.json` (корень Rojo-проекта рядом с местом) + https://github.com/search?q=extension%3Arbxlx&type=code +- **GitHub Topics:** https://github.com/topics/rbxlx · https://github.com/topics/rojo +- **Internet Archive:** https://archive.org/ — поиск «roblox place», «rbxl», «crossroads» + +### CC0/CC-BY геометрия для воссоздания (юридически чистейший путь, не .rbxl но low-poly близко к Roblox) +- **Kenney** (CC0): https://kenney.nl/assets — Platformer/Nature/Car/Pirate/City/Prototype Kit, Blocky Characters +- **OpenGameArt** (CC0/CC-BY): https://opengameart.org/ — voxel/low-poly паки +- **itch.io** (фильтр assets+CC0): https://itch.io/game-assets/free/tag-low-poly +- **Poly Pizza** (CC0/CC-BY low-poly): https://poly.pizza/ +- **Quaternius** (CC0 low-poly паки): https://quaternius.com/ + +### Сообщества с открытыми играми (часто прямые ссылки + лицензия) +- DevForum «free & open-sourced games»: https://devforum.roblox.com/t/lots-of-free-open-sourced-games/525670 +- DevForum «Open Source Arena FPS»: https://devforum.roblox.com/t/open-source-arena-fps/1034576 +- Uplift Games open source: https://www.uplift.games/open-source + +--- + +## ЮРИДИЧЕСКИЕ ПРАВИЛА (коротко) + +- ✅ Публиковать можно: **MIT / Apache-2.0 / MPL-2.0 / CC0 / CC-BY** (CC-BY — с атрибуцией). +- ❌ Нельзя: **GPL/AGPL** (заразные), **CC-BY-NC** (некоммерч.), **без лицензии** (= all rights reserved), + чужие игры через game-savers/декомпиляторы (нарушение DMCA/ToS). +- ⚠️ «Uncopylocked» = только разрешение копировать в Studio, НЕ передача прав. +- ⚠️ Официальные шаблоны Studio — учиться ОК, публиковать «как есть» — серая зона. + +**Рекомендация для наполнения Рублокса легально:** +1. Геометрия под чистую публикацию → Kenney/OpenGameArt CC0. +2. Классика Roblox-стиля → Roblox/Old-Open-Source-Levels (проверить LICENSE) + Crossroads. +3. Полная игра с кодом → Vigilant (MIT). +4. Масса .rbxl для теста парсера → архивы из (Б) + GitHub topics. diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index f54f66c..175c430 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -3035,13 +3035,6 @@ 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) { @@ -5537,7 +5530,7 @@ export class BabylonScene { }; clip.scripts = (this._scripts || []) .filter(s => matchTarget(s.target)) - .map(s => ({ code: s.code, name: s.name || null })); + .map(s => ({ code: s.code, name: s.name || null, language: s.language || 'js' })); } catch (e) { clip.scripts = []; } try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); } catch (e) { /* ignore — приватный режим / переполнение */ } @@ -7769,6 +7762,7 @@ export class BabylonScene { code: s.code, target: s.target || null, name: s.name || null, + language: s.language === 'lua' ? 'lua' : 'js', })), }, editorCamera: this.camera ? { diff --git a/src/editor/engine/lua/LuaSharedWorker.js b/src/editor/engine/lua/LuaSharedWorker.js index aff4a94..b6ef2fc 100644 --- a/src/editor/engine/lua/LuaSharedWorker.js +++ b/src/editor/engine/lua/LuaSharedWorker.js @@ -21,7 +21,9 @@ * и вызывает Lua-side Fire по соответствующему signal'у */ -let _wasmoon = null; +// Статический импорт — Vite корректно бандлит wasmoon в worker +import { LuaFactory } from 'wasmoon'; +import { registerRobloxShim } from './RobloxShim.js'; // Главное состояние VM (на весь life-cycle Worker'а) const state = { @@ -72,23 +74,21 @@ function logToMain(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', {}); + try { + const factory = new LuaFactory(); + state.vm = await factory.createEngine({ openStandardLibs: true }); + state.api = registerRobloxShim(state.vm, { + send, + getSceneSnapshot: () => state.scenes, + getGuiTree: () => state.guiTree, + scheduleWait: (sec) => scheduleWait(sec), + }); + state.isReady = true; + send('ready', {}); + } catch (err) { + // Это самое важное — без этого юзер не видит почему ничего не работает + logToMain('error', `[LuaWorker init FATAL] ${err?.message || err}\nstack: ${err?.stack || '?'}`); + } } function handleAddScriptsBatch(payload) { diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 1d449fe..79f7d3d 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -57,13 +57,21 @@ class RbxVector3 { 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); } + // В Roblox Magnitude/Unit это PROPERTY (без скобок), а не методы. + get 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; + get Unit() { + const m = Math.hypot(this.X, this.Y, this.Z) || 1; + return new RbxVector3(this.X / m, this.Y / m, this.Z / m); + } + get unit() { + const m = Math.hypot(this.X, this.Y, this.Z) || 1; + return new RbxVector3(this.X / m, this.Y / m, this.Z / m); + } + Normalize() { + const m = Math.hypot(this.X, this.Y, this.Z) || 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( -- 2.47.2 From 2b15ec821a809a7597a410691dee74fea705e496 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 11:32:31 +0300 Subject: [PATCH 05/39] =?UTF-8?q?feat(lua):=20=D0=AD=D1=82=D0=B0=D0=BF=203?= =?UTF-8?q?=20=E2=80=94=20DataModel=20+=20Touched=20+=20Humanoid=20(main-t?= =?UTF-8?q?hread=20wasmoon)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Главное достижение: KillBrick работает. script.Parent.Touched:Connect(fn) фейерится когда игрок касается куба, humanoid:TakeDamage(100) → playerSet команда → BabylonScene.player.hp=0 → respawn + playerDied event. Архитектурные изменения: - LuaSharedSandbox v3: wasmoon в MAIN потоке вместо Worker'а. DevTools видит точные ошибки, breakpoints работают, console.log в RobloxShim виден сразу. - LuaSharedWorker.js удалён (больше не нужен). - RobloxShim добавляет полное DataModel дерево: game / Workspace / Players / LocalPlayer / Character / Humanoid / HumanoidRootPart / 15 services (RunService.Heartbeat, TweenService, HttpService, DataStoreService, etc). - newPart создаёт RbxPart-обёртку вокруг каждого primitive в сцене, Touched/TouchEnded signals. Wasmoon-quirk: - TypeError: Cannot read properties of null (reading 'then') возникает когда JS-функция возвращает null в Lua-контекст. PromiseTypeExtension делает .then без guard. Везде заменили null → undefined (push'ится как nil). - _rbxl_get_part_by_id возвращает undefined если не нашёл, FindFirstChild и прочие тоже undefined вместо null. GameRuntime.js: - _buildSceneSnapshot теперь даёт id (для partById), color, anchored, canCollide, opacity полей у primitives. - partSet/sceneCreate user-Lua → handleLuaCommand (rbxl интеграция). - playerSet handler: humanoid.Health=0 → respawn + hpChange event. Co-Authored-By: Claude Opus 4.7 --- src/editor/engine/GameRuntime.js | 39 +- src/editor/engine/lua/LuaSharedSandbox.js | 320 ++++----- src/editor/engine/lua/LuaSharedWorker.js | 242 ------- src/editor/engine/lua/RobloxShim.js | 759 ++++++++++++++-------- 4 files changed, 676 insertions(+), 684 deletions(-) delete mode 100644 src/editor/engine/lua/LuaSharedWorker.js diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 6f45e01..9261130 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -181,9 +181,20 @@ export class GameRuntime { if (luaUserBatch.length > 0) { try { const sb = new LuaSharedSandbox(); + // partSet/sceneCreate — переиспользуем обработчик rbxl sb.setOnCommand(({ cmd, payload }) => { - this._handleCommand(null, cmd, payload); + if (cmd === 'partSet' || cmd === 'partVel' || + cmd === 'sceneCreate' || cmd === 'sceneDelete') { + try { handleLuaCommand(null, cmd, payload, this); } 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); sb.start(); this.sandboxes.push(sb); @@ -3967,6 +3978,25 @@ export class GameRuntime { } return; } + if (cmd === 'playerSet' && payload) { + // Из Lua-runtime: humanoid.Health = 0 → шлёт {prop:'health', value:N}. + // Применяем к реальному игроку BabylonScene. + const player = this.scene3d?.player; + if (!player) return; + if (payload.prop === 'health') { + const v = Math.max(0, Number(payload.value) || 0); + player.hp = v; + if (v === 0) { + try { this.routeGlobalEvent('playerDied', {}); } catch (_) {} + // Перезагружаем игру (как при смерти) + try { + if (this.scene3d?.respawnPlayer) this.scene3d.respawnPlayer(); + } catch (_) {} + } + try { this.routeGlobalEvent('hpChange', { hp: v }); } catch (_) {} + } + return; + } // eslint-disable-next-line no-console console.warn('[GameRuntime] unknown cmd', cmd); } @@ -4245,6 +4275,7 @@ export class GameRuntime { if (s?.primitiveManager) { for (const data of s.primitiveManager.instances.values()) { primitives.push({ + id: data.id, ref: 'primitive:' + data.id, type: data.type, x: data.x, y: data.y, z: data.z, @@ -4254,7 +4285,11 @@ export class GameRuntime { sz: data.sz != null ? data.sz : 1, rotationY: data.rotationY || 0, visible: data.visible !== false, - name: data.name || null, + name: data.name || undefined, + color: data.color || undefined, + anchored: data.anchored !== false, + canCollide: data.canCollide !== false, + opacity: data.opacity != null ? data.opacity : 1, }); } } diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index d73c411..bbb0bf9 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -1,152 +1,174 @@ /** - * LuaSharedSandbox — обёртка над одним wasmoon-Worker для ВСЕХ Lua-скриптов игры. + * LuaSharedSandbox (v3, main-thread) — wasmoon-VM работает в MAIN потоке, + * без Web Worker. Это позволяет: + * - Видеть точные Lua-ошибки в DevTools (через console.error) + * - Использовать debugger / breakpoints прямо в RobloxShim.js + * - Не возиться с молчаливыми Worker-падениями * - * Идея: - * - Создаётся ОДИН экземпляр на всю игру (а не на скрипт, как 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 + * Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style + * скриптов это нестрашно — они быстрые. * - * Совместимость с GameRuntime: - * методы sendEvent / sendGlobalEvent / sendSceneSnapshot / sendGuiSnapshot / - * sendDataSnapshot / sendSkinsSnapshot / sendTerrainHeightmap / stop / - * setOnCommand — поведение совпадает с ScriptSandbox. + * API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent / + * sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot / + * sendTerrainHeightmap / stop / tick / target. * - * Отличия: - * - addScript(id, code, target) можно вызывать много раз ДО start() — все - * скрипты добавляются батчем и потом запускаются вместе. - * - После start() можно вызывать addScript() для live-добавления (например, - * Instance.new("Script", workspace) с переданным Source). + * Что добавлено сверх ScriptSandbox: + * - addScript(id, code, target) — добавить скрипт в общий VM. Можно + * до или после start(). + * - start() — асинхронен (createEngine), но возвращает сразу. После init + * стартует main loop (Heartbeat + scheduler). */ -import LuaSharedWorker from './LuaSharedWorker.js?worker'; - -let _ipcId = 0; +import { LuaFactory } from 'wasmoon'; +import { registerRobloxShim } from './RobloxShim.js'; export class LuaSharedSandbox { constructor() { - this.worker = null; + this.vm = null; + this.api = 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; + this._isKickedOff = false; + this._pendingScripts = []; // [{id, code, target, name}] + this._scriptsById = new Map(); + this._scenes = null; + this._guiTree = null; + this._loopHandle = null; + this._lastTickAt = 0; } setOnCommand(cb) { this._onCommand = cb; } - /** - * GameRuntime вызывает sb.tick(dt, state) каждый кадр. - * Для JS-sandbox tick шлёт snapshot в worker. Для нас snapshot ходит через - * sendSceneSnapshot отдельно — здесь no-op. - * NB: target=null, потому что наш sandbox общий, не на конкретный объект. - */ get target() { return null; } - tick(_dt, _state) { /* no-op: main-loop fires Heartbeat внутри worker */ } + tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ } - /** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */ - addScript(id, code, target) { + addScript(id, code, target, name) { const entry = { - id: String(id), - source: String(code || ''), + id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`), + code: String(code || ''), target: target == null ? null : target, + name: name || null, }; - if (!this.worker) { + this._scriptsById.set(entry.id, entry); + if (!this._isKickedOff) { this._pendingScripts.push(entry); - return; + } else { + this._startSingleScript(entry); } - // 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 (_) {} + this._scriptsById.delete(String(id)); } - /** - * Запустить worker и инициализировать VM. - * После start() Lua-runtime готов принимать события и снапшоты. - */ + /** Стартует VM, регистрирует shim, запускает main-loop. */ start() { - if (this.worker) return; + if (this.vm || this._isStopped) 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) => { + console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...'); + this._initAsync().catch((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 }, + console.error('[LuaSharedSandbox] FATAL init error:', err); + this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` }); }); } - _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 = []; + 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); } - // Отправляем 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); + + 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`); + for (const entry of this._pendingScripts) this._startSingleScript(entry); + this._pendingScripts = []; + this._lastTickAt = performance.now(); + this._startMainLoop(); + } + + _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}`; + // ВАЖНО: chunk_name прокидываем — wasmoon покажет его в traceback. + const wrapped = ` + do + local script = { + Name = ${JSON.stringify(scriptName)}, + Parent = ${primId != null ? `__rbxl_get_part_by_id(${Number(primId)})` : 'nil'}, + ClassName = "Script", + Disabled = false, + Source = nil, + } + local ok, err = pcall(function() + ${entry.code} + end) + if not ok then + __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err)) + 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) { @@ -155,69 +177,57 @@ export class LuaSharedSandbox { } } - /** Событие target-attached скрипта (touch/untouch/click/etc). */ + // ----- API совместимый с ScriptSandbox ----- sendEvent(payload) { - if (!this.worker) return; - if (!this._isReady) return; - try { this.worker.postMessage({ cmd: 'event', payload }); } catch (_) {} + if (!this.api?.fireTargetEvent || !this._isReady) return; + try { this.api.fireTargetEvent(payload); } catch (e) { + console.error('[LuaSharedSandbox] sendEvent:', e); + } } - /** Глобальное событие игры (playerTouch/guiClick/keydown/etc). */ sendGlobalEvent(payload) { - if (!this.worker) return; - if (!this._isReady) return; - try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (_) {} + if (!this.api?.fireGlobalEvent || !this._isReady) return; + try { this.api.fireGlobalEvent(payload); } catch (e) { + console.error('[LuaSharedSandbox] sendGlobalEvent:', e); + } } sendSceneSnapshot(snapshot) { - if (!this.worker) { - this._pendingSceneSnapshot = snapshot; - return; + this._scenes = snapshot; + if (this.api?.onSceneSnapshot && this._isReady) { + try { this.api.onSceneSnapshot(snapshot); } catch (e) { + console.error('[LuaSharedSandbox] onSceneSnapshot:', e); + } } - 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; + this._guiTree = snapshot; + if (this.api?.onGuiSnapshot && this._isReady) { + try { this.api.onGuiSnapshot(snapshot); } catch (_) {} } - try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (_) {} } sendDataSnapshot(snapshot) { - if (!this.worker || !this._isReady) { - this._pendingDataSnapshot = snapshot; - return; + if (this.api?.onDataSnapshot && this._isReady) { + try { this.api.onDataSnapshot(snapshot); } catch (_) {} } - 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 (_) {} - } + sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ } + sendTerrainHeightmap(_) { /* no-op */ } stop() { this._isStopped = true; - try { this.worker?.terminate(); } catch (_) {} - this.worker = null; - this._isReady = false; + if (this._loopHandle) { + clearTimeout(this._loopHandle); + this._loopHandle = null; + } + if (this.vm) { + try { this.vm.global.close(); } catch (_) {} + this.vm = null; + } + this.api = null; } } diff --git a/src/editor/engine/lua/LuaSharedWorker.js b/src/editor/engine/lua/LuaSharedWorker.js deleted file mode 100644 index b6ef2fc..0000000 --- a/src/editor/engine/lua/LuaSharedWorker.js +++ /dev/null @@ -1,242 +0,0 @@ -/* 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 - * - state.scheduler — список «спящих» coroutines с timeUntilResume, рекурзится в _tick - * - state.signals — RbxSignal-объекты для events; Worker слушает 'event' от main - * и вызывает Lua-side Fire по соответствующему signal'у - */ - -// Статический импорт — Vite корректно бандлит wasmoon в worker -import { LuaFactory } from 'wasmoon'; -import { registerRobloxShim } from './RobloxShim.js'; - -// Главное состояние 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 }); - try { - const factory = new LuaFactory(); - state.vm = await factory.createEngine({ openStandardLibs: true }); - state.api = registerRobloxShim(state.vm, { - send, - getSceneSnapshot: () => state.scenes, - getGuiTree: () => state.guiTree, - scheduleWait: (sec) => scheduleWait(sec), - }); - state.isReady = true; - send('ready', {}); - } catch (err) { - // Это самое важное — без этого юзер не видит почему ничего не работает - logToMain('error', `[LuaWorker init FATAL] ${err?.message || err}\nstack: ${err?.stack || '?'}`); - } -} - -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 = ...; 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). -} diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 79f7d3d..4d6637c 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1,77 +1,74 @@ /** - * RobloxShim — экспорт минимально-достаточного Roblox API в wasmoon-VM. + * RobloxShim v3 (для main-thread sandbox) — Roblox API + DataModel. * - * Этап 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 + * Этап 3: добавлено виртуальное дерево DataModel поверх плоской сцены. + * - game.Workspace.Children = массив RbxPart обёрток над примитивами + * - script.Parent для target-скриптов = реальный RbxPart + * - RbxPart.Touched — RbxSignal который фейерится из BabylonScene при overlap + * - RbxPart.Position/Size/Color/Anchored/CanCollide — пишутся через setProp(part, ...) + * методы, которые шлют partSet в main thread (применяется к Babylon-сцене) + * - Humanoid с Health setter → playerSet команда * - * Возвращает объект 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}. + * ВАЖНО про wasmoon: не используем Object.defineProperty getters на JS-объектах + * передаваемых в Lua — wasmoon их некорректно оборачивает (js_promise). Вместо + * этого — обычные поля, которые юзер читает напрямую. Запись свойств происходит + * через `__rbxl_part_set(part, prop, value)` — она шлёт partSet и обновляет поле. */ +// ---------- Scheduler (для task.delay/defer) ---------- const SCHEDULER = { - sleeping: [], // [{coroutine, wakeAt}], wakeAt = performance.now()+ms + sleeping: [], // [{wakeAt, run}] now: () => performance.now(), }; +// ---------- Базовые сигналы ---------- const HEARTBEAT_SIGNAL = makeSignal(); const STEPPED_SIGNAL = makeSignal(); function makeSignal() { - const connections = []; - return { + const sig = { __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; - }, + connections: [], }; + sig.Connect = function (fn) { + if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {}, Connected: false }; + sig.connections.push(fn); + const conn = { Connected: true }; + conn.Disconnect = function () { + const i = sig.connections.indexOf(fn); + if (i >= 0) sig.connections.splice(i, 1); + conn.Connected = false; + }; + conn.disconnect = conn.Disconnect; + return conn; + }; + sig.connect = sig.Connect; + sig.Fire = function (...args) { + for (const fn of [...sig.connections]) { + try { fn(...args); } catch (e) { + // eslint-disable-next-line no-console + console.error('[Signal handler]', e); + } + } + }; + sig.fire = sig.Fire; + sig.Wait = () => null; + sig.wait = sig.Wait; + return sig; } -// --- Vector3 --- +// ---------- Vector3 / Color3 / UDim2 / Vector2 / CFrame ---------- class RbxVector3 { - constructor(x = 0, y = 0, z = 0) { - this.X = +x; this.Y = +y; this.Z = +z; - } + 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); } - // В Roblox Magnitude/Unit это PROPERTY (без скобок), а не методы. get Magnitude() { return Math.hypot(this.X, this.Y, this.Z); } get magnitude() { return Math.hypot(this.X, this.Y, this.Z); } get Unit() { const m = Math.hypot(this.X, this.Y, this.Z) || 1; return new RbxVector3(this.X / m, this.Y / m, this.Z / m); } - get unit() { - const m = Math.hypot(this.X, this.Y, this.Z) || 1; - return new RbxVector3(this.X / m, this.Y / m, this.Z / m); - } - Normalize() { - const m = Math.hypot(this.X, this.Y, this.Z) || 1; - return new RbxVector3(this.X / m, this.Y / m, this.Z / m); - } + get unit() { return this.Unit; } + Normalize() { return this.Unit; } Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; } Cross(b) { return new RbxVector3( @@ -87,7 +84,6 @@ class RbxVector3 { 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); @@ -95,7 +91,6 @@ 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); } @@ -103,11 +98,18 @@ class RbxColor3 { 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]; + const [r, g, b] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i % 6]; return new RbxColor3(r, g, b); } + static fromHex(hex) { + const s = String(hex || '').replace('#', ''); + if (s.length !== 6) return new RbxColor3(); + return new RbxColor3( + parseInt(s.slice(0, 2), 16) / 255, + parseInt(s.slice(2, 4), 16) / 255, + parseInt(s.slice(4, 6), 16) / 255, + ); + } Lerp(b, t) { return new RbxColor3( this.R + (b.R - this.R) * t, @@ -115,17 +117,19 @@ class RbxColor3 { this.B + (b.B - this.B) * t, ); } + toHex() { + const h = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, '0'); + return '#' + h(this.R) + h(this.G) + h(this.B); + } } -// --- 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); + 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); } @@ -135,62 +139,159 @@ 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 + this.p = this.Position; } 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(); } + static lookAt(eye, _t) { return new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0); } + static Angles() { return new RbxCFrame(); } + static fromEulerAnglesXYZ() { return new RbxCFrame(); } +} + +// ---------- Instance / Part ---------- +let _instanceMethods = null; +function makeInstanceMethods() { + if (_instanceMethods) return _instanceMethods; + _instanceMethods = { + GetChildren: function () { return [...(this.Children || [])]; }, + GetDescendants: function () { + const out = []; + const visit = (n) => { + for (const c of n.Children || []) { out.push(c); visit(c); } + }; + visit(this); + return out; + }, + FindFirstChild: function (name, recursive) { + for (const c of this.Children || []) { + if (c.Name === name) return c; + if (recursive) { + const f = c.FindFirstChild && c.FindFirstChild(name, true); + if (f) return f; + } + } + return undefined; + }, + FindFirstChildOfClass: function (cls) { + for (const c of this.Children || []) { + if (c.ClassName === cls) return c; + } + return undefined; + }, + FindFirstAncestor: function (name) { + let p = this.Parent; + while (p) { if (p.Name === name) return p; p = p.Parent; } + return undefined; + }, + FindFirstAncestorOfClass: function (cls) { + let p = this.Parent; + while (p) { if (p.ClassName === cls) return p; p = p.Parent; } + return undefined; + }, + WaitForChild: function (name) { return this.FindFirstChild(name); }, + IsA: function (cls) { return this.ClassName === cls || cls === 'Instance'; }, + GetFullName: function () { + const parts = []; + let p = this; + while (p && p.ClassName !== 'DataModel') { + parts.unshift(p.Name); + p = p.Parent; + } + return parts.join('.'); + }, + Destroy: function () { + this.Destroyed = true; + if (this.Parent && this.Parent.Children) { + const i = this.Parent.Children.indexOf(this); + if (i >= 0) this.Parent.Children.splice(i, 1); + this.Parent = undefined; + } + }, + Clone: function () { return undefined; }, + GetAttribute: function (n) { return (this.Attributes || {})[n]; }, + SetAttribute: function (n, v) { + if (!this.Attributes) this.Attributes = {}; + this.Attributes[n] = v; + }, + GetPropertyChangedSignal: function () { return this.Changed; }, + }; + return _instanceMethods; +} + +function newInstance(className, name) { + const m = makeInstanceMethods(); + return { + ClassName: className || 'Instance', + Name: name || className || 'Instance', + Parent: undefined, + Children: [], + Destroyed: false, + Attributes: {}, + ChildAdded: makeSignal(), + ChildRemoved: makeSignal(), + AncestryChanged: makeSignal(), + Changed: makeSignal(), + GetChildren: m.GetChildren, + GetDescendants: m.GetDescendants, + FindFirstChild: m.FindFirstChild, + FindFirstChildOfClass: m.FindFirstChildOfClass, + FindFirstAncestor: m.FindFirstAncestor, + FindFirstAncestorOfClass: m.FindFirstAncestorOfClass, + WaitForChild: m.WaitForChild, + IsA: m.IsA, + GetFullName: m.GetFullName, + Destroy: m.Destroy, + Clone: m.Clone, + GetAttribute: m.GetAttribute, + SetAttribute: m.SetAttribute, + GetPropertyChangedSignal: m.GetPropertyChangedSignal, + }; } /** - * Главная регистрация. Возвращает api-объект используемый Worker'ом. + * Создать RbxPart-обёртку. Возвращает plain объект (без getter'ов) — + * запись свойств идёт через метод __SetProp, которое мы экспортируем + * глобально как `__rbxl_part_set(part, prop, value)`. */ +function newPart(primData, sendFn) { + const p = newInstance('Part', primData.name || `Part_${primData.id}`); + p.__primId = primData.id; + p.__sendFn = sendFn; + p.Touched = makeSignal(); + p.TouchEnded = makeSignal(); + p.Position = new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0); + p.Size = new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1); + p.Color = primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5); + p.Anchored = !!primData.anchored; + p.CanCollide = primData.canCollide !== false; + p.Transparency = primData.opacity != null ? (1 - primData.opacity) : 0; + p.Material = 'Plastic'; + p.BrickColor = { Color: p.Color, Name: 'Custom' }; + p.CFrame = new RbxCFrame(p.Position.X, p.Position.Y, p.Position.Z); + return p; +} + +// ---------- Регистрация в Lua ---------- export function registerRobloxShim(lua, opts) { - const { send, getSceneSnapshot, getGuiTree, scheduleWait } = opts; + const { send } = opts; const global = lua.global; - // ------ Vector3 ------ - // Lua: local v = Vector3.new(1,2,3); v.X; v + v; v.Magnitude - const Vector3Table = { + // === Базовые типы === + global.set('Vector3', { new: (x, y, z) => new RbxVector3(x, y, z), - zero: RbxVector3.zero, - one: RbxVector3.one, - xAxis: RbxVector3.xAxis, - yAxis: RbxVector3.yAxis, - zAxis: RbxVector3.zAxis, + 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, - ); - }, + fromHex: (hex) => RbxColor3.fromHex(hex), }); - - // ------ 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), @@ -205,218 +306,299 @@ export function registerRobloxShim(lua, opts) { fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ, }); - // ------ Enum (минимум) ------ + // === Enum === + const mkE = (arr) => Object.fromEntries(arr.map(k => [k, { Name: k, Value: k }])); 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 }])), + KeyCode: mkE(['W','A','S','D','Space','LeftShift','LeftControl','F','E','Q','R','T','Y','U','I','O','P','G','H','J','K','L','Z','X','C','V','B','N','M','Tab','Return','Escape','Backspace','Up','Down','Left','Right','One','Two','Three','Four','Five','Six','Seven','Eight','Nine','Zero']), + UserInputType: mkE(['MouseButton1','MouseButton2','MouseButton3','MouseMovement','MouseWheel','Touch','Keyboard']), + Material: mkE(['Plastic','Wood','Metal','Neon','Glass','Sand','Ice','Grass','Concrete']), + HumanoidStateType: mkE(['Running','Jumping','Freefall','Landed','Dead','Climbing','Swimming','Seated']), + EasingStyle: mkE(['Linear','Sine','Quad','Cubic','Quart','Quint','Bounce','Elastic']), + EasingDirection: mkE(['In','Out','InOut']), }); - // ------ print / warn / error логируются в студию ------ + // === print / warn === + const stringify = (v) => { + if (v == null) return 'nil'; + if (typeof v === 'string') return v; + if (typeof v === 'number') return String(v); + if (typeof v === 'boolean') return v ? 'true' : 'false'; + if (v instanceof RbxVector3) return `${v.X}, ${v.Y}, ${v.Z}`; + if (v instanceof RbxColor3) return `${v.R}, ${v.G}, ${v.B}`; + if (typeof v === 'object') { + if (v.Name) return String(v.Name); + return '[object]'; + } + try { return String(v); } catch (_) { return '?'; } + }; global.set('print', (...args) => { - const text = args.map(luaTostring).join('\t'); - send('log', { level: 'info', text }); + send('log', { level: 'info', text: args.map(stringify).join('\t') }); }); global.set('warn', (...args) => { - const text = args.map(luaTostring).join('\t'); - send('log', { level: 'warn', text }); + send('log', { level: 'warn', text: args.map(stringify).join('\t') }); }); - // 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), + // === task.* + wait === + global.set('task', { + wait: (_) => undefined, spawn: (fn) => { - // task.spawn(fn) — стартует функцию как «корутину», немедленно резюмит - // у нас работает через прямой вызов pcall (упрощение, без честных coroutines) - try { if (typeof fn === 'function') fn(); } catch (_) {} + try { if (typeof fn === 'function') fn(); } catch (e) { + send('log', { level: 'error', text: `[task.spawn] ${e?.message || e}` }); + } }, 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 (_) {} } }); + SCHEDULER.sleeping.push({ + wakeAt: SCHEDULER.now() + (Number(sec) || 0) * 1000, + run: () => { try { fn(); } catch (e) { + send('log', { level: 'error', text: `[task.delay] ${e?.message || e}` }); + } }, + }); }, defer: (fn) => { - if (typeof fn === 'function') { - const wakeAt = SCHEDULER.now() + 0; - SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } }); - } + if (typeof fn !== 'function') return; + SCHEDULER.sleeping.push({ + wakeAt: SCHEDULER.now(), + 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'); + global.set('wait', (_) => undefined); + + // === DataModel === + const game = newInstance('DataModel', 'game'); + const workspace = newInstance('Workspace', 'Workspace'); + workspace.Parent = game; + workspace.Gravity = 196.2; + workspace.CurrentCamera = newInstance('Camera', 'Camera'); + workspace.CurrentCamera.Parent = workspace; + workspace.Children.push(workspace.CurrentCamera); + workspace.Terrain = newInstance('Terrain', 'Terrain'); + workspace.Terrain.Parent = workspace; + workspace.Children.push(workspace.Terrain); + game.Children.push(workspace); + game.Workspace = workspace; + + const players = newInstance('Players', 'Players'); + players.Parent = game; + players.PlayerAdded = makeSignal(); + players.PlayerRemoving = makeSignal(); + game.Children.push(players); + game.Players = players; + + const localPlayer = newInstance('Player', 'Player'); + localPlayer.Parent = players; + localPlayer.UserId = 1; + localPlayer.DisplayName = 'Player'; + players.Children.push(localPlayer); + players.LocalPlayer = localPlayer; + + const character = newInstance('Model', 'Player'); + character.Parent = localPlayer; + localPlayer.Children.push(character); + localPlayer.Character = character; + + const humanoid = newInstance('Humanoid', 'Humanoid'); + humanoid.Parent = character; + humanoid.Health = 100; + humanoid.MaxHealth = 100; + humanoid.WalkSpeed = 16; + humanoid.JumpPower = 50; + humanoid.Died = makeSignal(); + humanoid.HealthChanged = makeSignal(); + humanoid.Touched = makeSignal(); + humanoid.StateChanged = makeSignal(); + humanoid.TakeDamage = function (n) { + const v = Math.max(0, (this.Health || 100) - (Number(n) || 0)); + this.Health = v; + this.HealthChanged.Fire(v); + if (v === 0) this.Died.Fire(); + send('playerSet', { prop: 'health', value: v }); + }; + humanoid.MoveTo = function () {}; + humanoid.LoadAnimation = function () { return { Play: () => {}, Stop: () => {}, AdjustSpeed: () => {} }; }; + character.Children.push(humanoid); + character.Humanoid = humanoid; + + const hrp = newInstance('Part', 'HumanoidRootPart'); + hrp.Parent = character; + hrp.Position = new RbxVector3(0, 5, 0); + hrp.Size = new RbxVector3(2, 2, 1); + character.Children.push(hrp); + character.HumanoidRootPart = hrp; + character.PrimaryPart = hrp; + + // === Сервисы === + const services = {}; + const makeService = (name) => { + if (services[name]) return services[name]; + const s = newInstance(name, name); + s.Parent = game; + game.Children.push(s); + services[name] = s; + game[name] = s; + return s; + }; + makeService('ReplicatedStorage'); + makeService('ServerStorage'); + makeService('StarterGui'); + makeService('StarterPack'); + makeService('StarterPlayer'); + + const uis = makeService('UserInputService'); + uis.InputBegan = makeSignal(); + uis.InputChanged = makeSignal(); + uis.InputEnded = makeSignal(); + + const tw = makeService('TweenService'); + tw.Create = function () { return { Play: () => {}, Pause: () => {}, Cancel: () => {} }; }; + + const http = makeService('HttpService'); + http.JSONEncode = function (v) { try { return JSON.stringify(v); } catch (_) { return '{}'; } }; + http.JSONDecode = function (s) { try { return JSON.parse(s); } catch (_) { return undefined; } }; + http.GenerateGUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 3 | 8)).toString(16); + }); + + makeService('Lighting').Ambient = new RbxColor3(0.5, 0.5, 0.5); + makeService('Chat'); + makeService('SoundService'); + makeService('PathfindingService'); + makeService('CollectionService'); + makeService('MarketplaceService'); + + const ds = makeService('DataStoreService'); + ds.GetDataStore = function () { + return { + GetAsync: () => undefined, SetAsync: () => {}, UpdateAsync: () => {}, + RemoveAsync: () => {}, IncrementAsync: () => {}, + }; + }; + + const ctx = makeService('ContextActionService'); + ctx.BindAction = () => {}; + ctx.UnbindAction = () => {}; + + const runService = makeService('RunService'); runService.Heartbeat = HEARTBEAT_SIGNAL; runService.Stepped = STEPPED_SIGNAL; - runService.RenderStepped = HEARTBEAT_SIGNAL; // упрощённо + runService.RenderStepped = HEARTBEAT_SIGNAL; + runService.IsClient = () => true; + runService.IsServer = () => true; + runService.IsRunning = () => true; + runService.IsStudio = () => false; - 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'), + game.GetService = function (name) { + if (name === 'Workspace') return workspace; + if (name === 'Players') return players; + return services[name] || makeService(name); }; - global.set('game', gameTable); - global.set('workspace', gameTable.Workspace); - global.set('Workspace', gameTable.Workspace); + game.FindService = function (name) { return services[name] || null; }; - // ------ Instance.new ------ - // Возвращает «pseudo-instance» — на Этапе 2 это просто object с пропсами. - // На Этапе 3 будет полноценный класс с metatable и Parent setter. + global.set('game', game); + global.set('Game', game); + global.set('workspace', workspace); + global.set('Workspace', workspace); + + // === Instance.new === 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() {}, - }; + let inst; + if (className === 'Part' || className === 'WedgePart' || className === 'MeshPart') { + inst = newInstance(className, className); + inst.Touched = makeSignal(); + inst.TouchEnded = makeSignal(); + inst.Position = new RbxVector3(); + inst.Size = new RbxVector3(4, 1, 2); + inst.Color = new RbxColor3(0.5, 0.5, 0.5); + inst.Anchored = false; + inst.CanCollide = true; + inst.Transparency = 0; + inst.Material = 'Plastic'; + inst.CFrame = new RbxCFrame(); + } else if (className === 'RemoteEvent') { + inst = newInstance('RemoteEvent', 'RemoteEvent'); + inst.OnServerEvent = makeSignal(); + inst.OnClientEvent = makeSignal(); + inst.FireServer = function (...a) { this.OnServerEvent.Fire(localPlayer, ...a); }; + inst.FireClient = function (_p, ...a) { this.OnClientEvent.Fire(...a); }; + inst.FireAllClients = function (...a) { this.OnClientEvent.Fire(...a); }; + } else if (className === 'BindableEvent') { + inst = newInstance('BindableEvent', 'BindableEvent'); + inst.Event = makeSignal(); + inst.Fire = function (...a) { this.Event.Fire(...a); }; + } else if (className === 'Humanoid') { + inst = newInstance('Humanoid', 'Humanoid'); + inst.Health = 100; inst.MaxHealth = 100; + inst.Died = makeSignal(); inst.HealthChanged = makeSignal(); + inst.TakeDamage = function (n) { this.Health = Math.max(0, this.Health - (n || 0)); }; + } else { + inst = newInstance(className, className); + } + if (parent) { + inst.Parent = parent; + if (parent.Children) { + parent.Children.push(inst); + if (parent.ChildAdded) parent.ChildAdded.Fire(inst); + } + } return inst; }, }); - // ------ 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}`, - }); - } + // === Helpers для скриптов === + const partById = new Map(); + global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined); + global.set('__rbxl_send_error', (id, errStr) => { + send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` }); }); - // ------ Возвращаем api для Worker'а ------ + // === Setter Part-свойств (Position/Size/Color/...) === + // Юзер пишет: part.Position = Vector3.new(0, 10, 0) + // В Lua-side это просто rawset. Но мы хотим, чтобы JS-сторона узнала и применила. + // Решение: каждые N секунд (или по требованию) — сканируем partById и сравниваем + // _state. Это дорого. ПРОСТЕЕ: научим юзера использовать part:SetPosition(v). + // + // Для Этапа 3 сделаем proxy через явный метод. В Этапе 4 — введём + // metatable на Lua-стороне (более чистый путь). + + // Возвращаем api для main-loop return { - // обновление снапшотов (будет использовано на Этапе 3 для DataModel) - onSceneSnapshot() {}, + onSceneSnapshot(snap) { + try { + const prims = snap?.primitives || []; + // Сохраняем Camera/Terrain + const kept = workspace.Children.filter(c => + c.ClassName === 'Camera' || c.ClassName === 'Terrain' + ); + workspace.Children.length = 0; + workspace.Children.push(...kept); + partById.clear(); + for (const p of prims) { + if (!p || p.id == null) continue; + const part = newPart(p, send); + part.Parent = workspace; + workspace.Children.push(part); + partById.set(Number(p.id), part); + } + // eslint-disable-next-line no-console + console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts`); + } catch (e) { + send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` }); + } + }, onGuiSnapshot() {}, onDataSnapshot() {}, - // 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); + if (t.wakeAt <= now) ready.push(t); else rest.push(t); } SCHEDULER.sleeping = rest; for (const t of ready) { @@ -428,28 +610,35 @@ export function registerRobloxShim(lua, opts) { try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {} }, fireTargetEvent(p) { - // На Этапе 3 — найти Part в DataModel и фейернуть его Touched - // Сейчас — no-op (но не падаем) - // Возможные kind: 'touch', 'untouch', 'click' if (!p) return; + const id = p.primId ?? p.target; + const part = partById.get(Number(id)); + if (!part) return; + if (p.kind === 'touch' || p.kind === 'touched') { + part.Touched.Fire(hrp); + } else if (p.kind === 'untouch' || p.kind === 'untouched') { + part.TouchEnded.Fire(hrp); + } }, - fireGlobalEvent(_p) { - // playerTouch / guiClick / keydown — также на Этапе 3 + fireGlobalEvent(p) { + if (!p) return; + if (p.type === 'playerTouch' && p.target != null) { + let primId = null; + if (typeof p.target === 'number') primId = p.target; + else if (typeof p.target === 'string') { + const m = /^primitive:(\d+)$/.exec(p.target); + if (m) primId = +m[1]; + } else if (typeof p.target === 'object') { + primId = p.target.id ?? p.target.ref ?? null; + } + if (primId != null) { + const part = partById.get(Number(primId)); + if (part?.Touched) part.Touched.Fire(hrp); + if (humanoid.Touched) humanoid.Touched.Fire(part); + } + } }, + // Доступ к ключевым объектам (для тестов и отладки) + partById, localPlayer, humanoid, character, workspace, players, game, }; } - -// --- Утилиты --- -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 '?'; } -} -- 2.47.2 From 30d472bf43c277b1693b5f14d8c38aa35388dfaa Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 11:35:11 +0300 Subject: [PATCH 06/39] =?UTF-8?q?feat(lua):=20=D0=B4=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20Lua=20print()=20?= =?UTF-8?q?=D0=B2=20DevTools=20Console=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/GameRuntime.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 9261130..7062b70 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -4426,6 +4426,13 @@ export class GameRuntime { } _log(level, text, scriptId = null, scriptName = null) { + // Дублируем в DevTools Console — удобно для отладки скриптов + try { + const fn = level === 'error' ? console.error + : level === 'warn' ? console.warn + : console.log; + fn(`[script${scriptName ? ' ' + scriptName : ''}] ${text}`); + } catch (_) {} if (this._onLog) { try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ } } -- 2.47.2 From 55f4a3fc381f110626b2e293a1c2152038844cba Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 11:38:03 +0300 Subject: [PATCH 07/39] =?UTF-8?q?fix(lua):=20humanoid.Health=3D0=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=83=D0=B1=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20PlayerController.takeDamage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/GameRuntime.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 7062b70..0c9c3a7 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -3979,21 +3979,21 @@ export class GameRuntime { return; } if (cmd === 'playerSet' && payload) { - // Из Lua-runtime: humanoid.Health = 0 → шлёт {prop:'health', value:N}. - // Применяем к реальному игроку BabylonScene. + // Из 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 v = Math.max(0, Number(payload.value) || 0); - player.hp = v; - if (v === 0) { - try { this.routeGlobalEvent('playerDied', {}); } catch (_) {} - // Перезагружаем игру (как при смерти) - try { - if (this.scene3d?.respawnPlayer) this.scene3d.respawnPlayer(); - } catch (_) {} + 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; } - try { this.routeGlobalEvent('hpChange', { hp: v }); } catch (_) {} } return; } -- 2.47.2 From 8ac26376152b9856e096b681bf7e632ac3a40d50 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 11:46:01 +0300 Subject: [PATCH 08/39] =?UTF-8?q?feat(lua):=20=D0=AD=D1=82=D0=B0=D0=BF=204?= =?UTF-8?q?=20=E2=80=94=20Part=20setters=20+=20task.wait=20+=20Instance.ne?= =?UTF-8?q?w=20+=20Destroy=20+=20TweenService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Этап 4.1: Position/Size/Color/Anchored/CanCollide/Transparency setters - Через Object.defineProperty с getter/setter - Setter шлёт partSet → handleLuaCommand → primitiveManager.applyPatch - Формат payload соответствует rbxl-lua-integration Этап 4.2: Instance.new('Part') - Создаёт реальный примитив через sceneCreate команду - Регистрирует в partById чтобы script.Parent.Touched работал - Автоинкремент id с базы 100000 Этап 4.3: part:Destroy() - sceneDelete → primitiveManager.removeInstance - Также удаляет из Workspace.Children Этап 4.4: task.wait(sec) через coroutine.yield - Каждый скрипт стартует как coroutine - task.wait → yield(sec); main-loop резюмирует через scheduleResume - В Lua: while true do part.Position = ...; task.wait(0.1) end теперь работает корректно (raw80и не зависает UI) Этап 4.5: BindableEvent + RemoteEvent - Уже было в Instance.new (.Event = makeSignal()). - Между Lua-скриптами работает через общий VM. Этап 4.6: TweenService:Create - Реальная интерполяция Vector3.Lerp / Color3.Lerp / number - Через _stepTweens в tickScheduler каждый кадр - tween.Completed signal фейерится по завершению Co-Authored-By: Claude Opus 4.7 --- src/editor/engine/lua/LuaSharedSandbox.js | 18 +- src/editor/engine/lua/RobloxShim.js | 300 +++++++++++++++++++--- src/editor/engine/rbxl-lua-integration.js | 32 +++ 3 files changed, 315 insertions(+), 35 deletions(-) diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index bbb0bf9..ead6242 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -124,7 +124,11 @@ export class LuaSharedSandbox { } const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_'); const scriptName = entry.name || `Script_${safeId}`; - // ВАЖНО: chunk_name прокидываем — wasmoon покажет его в traceback. + // Скрипт оборачиваем в coroutine — это позволяет task.wait через yield. + // Резюмим coroutine из main-loop когда наступило время. + // Регистрируем coroutine в __rbxl_coroutines с id для возобновления. + // Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает + // delay из resume → планируем следующий resume через scheduleResume. const wrapped = ` do local script = { @@ -134,11 +138,19 @@ export class LuaSharedSandbox { Disabled = false, Source = nil, } - local ok, err = pcall(function() + local co = coroutine.create(function() ${entry.code} 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(err)) + __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 `; diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 4d6637c..7f68d7d 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -204,6 +204,10 @@ function makeInstanceMethods() { }, Destroy: function () { this.Destroyed = true; + // Если это Part с примитивом — шлём sceneDelete + if (this.__primId != null && this.__sendDestroy) { + try { this.__sendDestroy(this.__primId); } catch (_) {} + } if (this.Parent && this.Parent.Children) { const i = this.Parent.Children.indexOf(this); if (i >= 0) this.Parent.Children.splice(i, 1); @@ -259,18 +263,99 @@ function newInstance(className, name) { function newPart(primData, sendFn) { const p = newInstance('Part', primData.name || `Part_${primData.id}`); p.__primId = primData.id; - p.__sendFn = sendFn; + p.__sendDestroy = (id) => sendFn('sceneDelete', { primId: id }); p.Touched = makeSignal(); p.TouchEnded = makeSignal(); - p.Position = new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0); - p.Size = new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1); - p.Color = primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5); - p.Anchored = !!primData.anchored; - p.CanCollide = primData.canCollide !== false; - p.Transparency = primData.opacity != null ? (1 - primData.opacity) : 0; p.Material = 'Plastic'; - p.BrickColor = { Color: p.Color, Name: 'Custom' }; - p.CFrame = new RbxCFrame(p.Position.X, p.Position.Y, p.Position.Z); + + // Внутренний state: реальные значения хранятся здесь, в Lua через getter/setter. + p._state = { + Position: new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0), + Size: new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1), + Color: primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5), + Anchored: !!primData.anchored, + CanCollide: primData.canCollide !== false, + Transparency: primData.opacity != null ? (1 - primData.opacity) : 0, + }; + + // Setter'ы шлют partSet → BabylonScene.primitiveManager через handleLuaCommand. + // Формат payload должен соответствовать rbxl-lua-integration.js#handleLuaCommand. + const send = (prop, value) => { + try { sendFn('partSet', { primId: p.__primId, prop, value }); } catch (_) {} + }; + Object.defineProperty(p, 'Position', { + get() { return p._state.Position; }, + set(v) { + if (!v) return; + const nv = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0); + p._state.Position = nv; + send('position', { x: nv.X, y: nv.Y, z: nv.Z }); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Size', { + get() { return p._state.Size; }, + set(v) { + if (!v) return; + const nv = new RbxVector3(v.X || 1, v.Y || 1, v.Z || 1); + p._state.Size = nv; + send('size', { sx: nv.X, sy: nv.Y, sz: nv.Z }); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Color', { + get() { return p._state.Color; }, + set(v) { + if (!v) return; + const nv = v instanceof RbxColor3 ? v : new RbxColor3(v.R || 0, v.G || 0, v.B || 0); + p._state.Color = nv; + // handleLuaCommand ожидает строку для color + send('color', nv.toHex()); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'BrickColor', { + get() { return { Color: p._state.Color, Name: 'Custom' }; }, + set(v) { if (v && v.Color) p.Color = v.Color; }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Anchored', { + get() { return p._state.Anchored; }, + set(v) { + p._state.Anchored = !!v; + send('anchored', !!v); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'CanCollide', { + get() { return p._state.CanCollide; }, + set(v) { + p._state.CanCollide = !!v; + send('canCollide', !!v); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Transparency', { + get() { return p._state.Transparency; }, + set(v) { + const nv = Math.max(0, Math.min(1, Number(v) || 0)); + p._state.Transparency = nv; + // handleLuaCommand ожидает number для opacity + send('opacity', 1 - nv); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'CFrame', { + get() { + const pos = p._state.Position; + return new RbxCFrame(pos.X, pos.Y, pos.Z); + }, + set(v) { + if (v && v.Position) p.Position = v.Position; + else if (v && v.X != null) p.Position = new RbxVector3(v.X, v.Y, v.Z); + }, + enumerable: true, configurable: true, + }); return p; } @@ -339,8 +424,13 @@ export function registerRobloxShim(lua, opts) { }); // === task.* + wait === + // task.wait/wait — реальный yield через coroutines. Юзер пишет: + // while true do part.Position = ... ; task.wait(0.1) end + // Это работает потому что **скрипт сам запускается как coroutine** + // (см. LuaSharedSandbox._startSingleScript → мы оборачиваем код в pcall, + // НО для yield нам нужно завернуть в coroutine.create). Делаем это + // через Lua-prelude: глобальная функция `_run_in_coroutine(fn)`. global.set('task', { - wait: (_) => undefined, spawn: (fn) => { try { if (typeof fn === 'function') fn(); } catch (e) { send('log', { level: 'error', text: `[task.spawn] ${e?.message || e}` }); @@ -365,7 +455,7 @@ export function registerRobloxShim(lua, opts) { synchronize: () => {}, desynchronize: () => {}, }); - global.set('wait', (_) => undefined); + // task.wait и wait определяются через Lua coroutine.yield в prelude (см. ниже) // === DataModel === const game = newInstance('DataModel', 'game'); @@ -452,8 +542,59 @@ export function registerRobloxShim(lua, opts) { uis.InputChanged = makeSignal(); uis.InputEnded = makeSignal(); + // TweenService — реальная интерполяция через Heartbeat const tw = makeService('TweenService'); - tw.Create = function () { return { Play: () => {}, Pause: () => {}, Cancel: () => {} }; }; + const activeTweens = []; // [{inst, props, duration, startAt, startVals, onDone}] + tw.Create = function (inst, info, propGoals) { + // info: TweenInfo (duration, EasingStyle, ...) — упрощённо берём только duration + const duration = (info && (info.Time || info.duration)) || 1; + const tween = { + __completed: makeSignal(), + Completed: undefined, + Play() { + if (!inst || !propGoals) return; + const startVals = {}; + for (const k of Object.keys(propGoals)) { + try { startVals[k] = inst[k]; } catch (_) {} + } + activeTweens.push({ + inst, props: propGoals, duration, + startAt: performance.now(), + startVals, + onDone: () => tween.__completed.Fire(), + }); + }, + Pause() {}, + Cancel() {}, + }; + tween.Completed = tween.__completed; + return tween; + }; + function _stepTweens(_dt) { + if (activeTweens.length === 0) return; + const now = performance.now(); + for (let i = activeTweens.length - 1; i >= 0; i--) { + const t = activeTweens[i]; + const elapsed = (now - t.startAt) / 1000; + const k = Math.min(1, elapsed / t.duration); + for (const prop of Object.keys(t.props)) { + const goal = t.props[prop]; + const start = t.startVals[prop]; + if (!start || !goal) continue; + if (start instanceof RbxVector3 && goal instanceof RbxVector3) { + try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {} + } else if (start instanceof RbxColor3 && goal instanceof RbxColor3) { + try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {} + } else if (typeof start === 'number' && typeof goal === 'number') { + try { t.inst[prop] = start + (goal - start) * k; } catch (_) {} + } + } + if (k >= 1) { + activeTweens.splice(i, 1); + try { t.onDone(); } catch (_) {} + } + } + } const http = makeService('HttpService'); http.JSONEncode = function (v) { try { return JSON.stringify(v); } catch (_) { return '{}'; } }; @@ -503,21 +644,40 @@ export function registerRobloxShim(lua, opts) { global.set('Workspace', workspace); // === Instance.new === + // Счётчик для новых Part'ов, создаваемых через Instance.new("Part"): + // primitiveManager.addInstance даст уникальный id, мы используем временный + // отрицательный id пока не пришёл sceneCreate ACK. Но проще: даём свой + // негативный id, primitiveManager заменит на свой. Для простоты — даём + // высокий positive id (10000+ random) и primitiveManager его использует + // если не занят. + let _nextNewPartId = 100000 + Math.floor(Math.random() * 10000); + global.set('Instance', { new: (className, parent) => { let inst; if (className === 'Part' || className === 'WedgePart' || className === 'MeshPart') { - inst = newInstance(className, className); - inst.Touched = makeSignal(); - inst.TouchEnded = makeSignal(); - inst.Position = new RbxVector3(); - inst.Size = new RbxVector3(4, 1, 2); - inst.Color = new RbxColor3(0.5, 0.5, 0.5); - inst.Anchored = false; - inst.CanCollide = true; - inst.Transparency = 0; - inst.Material = 'Plastic'; - inst.CFrame = new RbxCFrame(); + // Реальный примитив на сцене: шлём sceneCreate, регистрируем в partById + const newId = _nextNewPartId++; + const fakePrim = { + id: newId, + name: `Part_${newId}`, + x: 0, y: 0, z: 0, + sx: 4, sy: 1, sz: 2, + color: '#A0A0A0', + anchored: true, + canCollide: true, + }; + send('sceneCreate', { + primId: newId, + type: className === 'WedgePart' ? 'wedge' : 'cube', + x: 0, y: 0, z: 0, + sx: 4, sy: 1, sz: 2, + color: '#A0A0A0', + anchored: true, + canCollide: true, + }); + inst = newPart(fakePrim, send); + partById.set(newId, inst); } else if (className === 'RemoteEvent') { inst = newInstance('RemoteEvent', 'RemoteEvent'); inst.OnServerEvent = makeSignal(); @@ -555,6 +715,42 @@ export function registerRobloxShim(lua, opts) { send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` }); }); + // === Coroutines registry + task.wait через yield === + // Каждый скрипт стартует как coroutine. Когда юзер пишет task.wait(sec), + // мы делаем coroutine.yield(sec). Main-loop резюмирует когда время вышло. + const coroutines = new Map(); // id → coroutine + const waitingCoros = []; // [{coId, wakeAt}] + global.set('__rbxl_register_coroutine', (id, co) => { + coroutines.set(String(id), co); + }); + global.set('__rbxl_unregister_coroutine', (id) => { + coroutines.delete(String(id)); + }); + global.set('__rbxl_get_co', (id) => coroutines.get(String(id)) || undefined); + global.set('__rbxl_schedule_resume', (coId, delaySec) => { + waitingCoros.push({ + coId: String(coId), + wakeAt: SCHEDULER.now() + (Number(delaySec) || 0) * 1000, + }); + }); + + // Лагалейн Lua-код: определяем task.wait, wait, и обёртку для скрипта. + // Этот код выполняется ВНУТРИ Lua VM. + lua.doStringSync(` + -- task.wait(sec) → coroutine.yield(sec); main-loop вернёт через delay sec + local function rbx_wait(sec) + sec = sec or 0 + coroutine.yield(sec) + return sec + end + if type(task) == 'table' then + task.wait = rbx_wait + else + task = { wait = rbx_wait } + end + wait = rbx_wait + `); + // === Setter Part-свойств (Position/Size/Color/...) === // Юзер пишет: part.Position = Vector3.new(0, 10, 0) // В Lua-side это просто rawset. Но мы хотим, чтобы JS-сторона узнала и применила. @@ -578,7 +774,7 @@ export function registerRobloxShim(lua, opts) { partById.clear(); for (const p of prims) { if (!p || p.id == null) continue; - const part = newPart(p, send); + const part = newPart(p, send); // setters внутри шлют через send part.Parent = workspace; workspace.Children.push(part); partById.set(Number(p.id), part); @@ -593,16 +789,56 @@ export function registerRobloxShim(lua, opts) { onDataSnapshot() {}, tickScheduler(_dt) { + // 0. Tweens + _stepTweens(_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); + // 1. task.delay / task.defer + if (SCHEDULER.sleeping.length > 0) { + const ready = []; + const rest = []; + for (const t of SCHEDULER.sleeping) { + if (t.wakeAt <= now) ready.push(t); else rest.push(t); + } + SCHEDULER.sleeping = rest; + for (const t of ready) { + try { t.run(); } catch (_) {} + } } - SCHEDULER.sleeping = rest; - for (const t of ready) { - try { t.run(); } catch (_) {} + // 2. Резюм coroutine'ов которые task.wait() + // Скрипт-coroutine при первом запуске yield'ит с delay (от task.wait). + // Мы регистрируем delay через __rbxl_schedule_resume? Нет — проще: + // отслеживаем последний yield-результат через coroutine.resume → возвращает + // delay. После init скрипта проверим всех coroutines: если status==suspended, + // и им пора — резюмируем. + for (const [coId, co] of coroutines) { + let entry = waitingCoros.find(w => w.coId === coId); + if (!entry) { + // Первый раз видим этот co — нужно вытащить delay из yield-результата. + // Это сделано в _onYield ниже. + continue; + } + if (entry.wakeAt > now) continue; + // Время резюмить + waitingCoros.splice(waitingCoros.indexOf(entry), 1); + try { + const code = ` + local co = __rbxl_get_co(${JSON.stringify(coId)}) + if co and coroutine.status(co) == 'suspended' then + local ok, ret = coroutine.resume(co) + if not ok then + __rbxl_send_error(${JSON.stringify(coId)}, tostring(ret)) + __rbxl_unregister_coroutine(${JSON.stringify(coId)}) + elseif type(ret) == 'number' then + __rbxl_schedule_resume(${JSON.stringify(coId)}, ret) + elseif coroutine.status(co) == 'dead' then + __rbxl_unregister_coroutine(${JSON.stringify(coId)}) + end + end + `; + lua.doStringSync(code); + } catch (e) { + send('log', { level: 'error', text: `[coroutine resume ${coId}] ${e?.message || e}` }); + } } }, fireHeartbeat(dt) { diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index 7b6caa9..fe0c7f4 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -146,6 +146,38 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { } catch (e) {} return; } + if (cmd === 'sceneCreate') { + // Lua: Instance.new("Part") + part.Parent = workspace → создание примитива. + // payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored } + try { + const pm = runtime.scene3d?.primitiveManager; + if (!pm || typeof pm.addInstance !== 'function') return; + const opts = { + id: payload?.primId, + x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0, + sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1, + color: payload?.color, + anchored: payload?.anchored !== false, + canCollide: payload?.canCollide !== false, + }; + pm.addInstance(payload?.type || 'cube', opts); + } catch (e) { + console.error('[sceneCreate]', e); + } + return; + } + if (cmd === 'sceneDelete') { + // Lua: part:Destroy() → удаление примитива. + try { + const pm = runtime.scene3d?.primitiveManager; + if (!pm || typeof pm.removeInstance !== 'function') return; + const id = payload?.primId; + if (id != null) pm.removeInstance(Number(id)); + } catch (e) { + console.error('[sceneDelete]', e); + } + return; + } if (cmd === 'partVel') { try { const pm = runtime.scene3d?.primitiveManager; -- 2.47.2 From 0d7224a2b8826af30e0830c6d186f60ce182099b Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 11:50:22 +0300 Subject: [PATCH 09/39] =?UTF-8?q?fix(lua):=20WASM=20memory=20access=20?= =?UTF-8?q?=E2=80=94=20resume=20coroutine=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20lua.global.get=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20doSt?= =?UTF-8?q?ringSync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При каждом tick'е tickScheduler делал lua.doStringSync(resume code), что вызывало re-entrant WASM-крах memory access out of bounds после нескольких task.wait() итераций. Фикс: кешируем ссылку на __rbxl_resume_co при init и зовём её напрямую. Это безопасный путь (не парсит код заново, не открывает вложенный Lua-state поверх существующего). --- src/editor/engine/lua/RobloxShim.js | 76 ++++++++++++++++------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 7f68d7d..3fd24df 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -734,10 +734,11 @@ export function registerRobloxShim(lua, opts) { }); }); - // Лагалейн Lua-код: определяем task.wait, wait, и обёртку для скрипта. - // Этот код выполняется ВНУТРИ Lua VM. + // Lua-prelude: task.wait через coroutine.yield + готовая resume-функция. + // Главное: __rbxl_resume_co определена в Lua и вызывается из JS через + // lua.global.get('__rbxl_resume_co') — это безопаснее чем doStringSync + // потому что не парсит код заново и не создаёт re-entrant проблем. lua.doStringSync(` - -- task.wait(sec) → coroutine.yield(sec); main-loop вернёт через delay sec local function rbx_wait(sec) sec = sec or 0 coroutine.yield(sec) @@ -749,7 +750,23 @@ export function registerRobloxShim(lua, opts) { task = { wait = rbx_wait } end wait = rbx_wait + + -- Вызывается из JS-tickScheduler: + -- возвращает next-delay (number) если co yield'нулся ещё раз, + -- или nil если co завершился / умер. + function __rbxl_resume_co(co) + if not co or coroutine.status(co) ~= 'suspended' then return nil end + local ok, ret = coroutine.resume(co) + if not ok then + return false, tostring(ret) + end + if coroutine.status(co) == 'dead' then return nil end + if type(ret) == 'number' then return ret end + return 0 + end `); + // Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync) + const luaResumeCo = lua.global.get('__rbxl_resume_co'); // === Setter Part-свойств (Position/Size/Color/...) === // Юзер пишет: part.Position = Vector3.new(0, 10, 0) @@ -805,39 +822,32 @@ export function registerRobloxShim(lua, opts) { } } // 2. Резюм coroutine'ов которые task.wait() - // Скрипт-coroutine при первом запуске yield'ит с delay (от task.wait). - // Мы регистрируем delay через __rbxl_schedule_resume? Нет — проще: - // отслеживаем последний yield-результат через coroutine.resume → возвращает - // delay. После init скрипта проверим всех coroutines: если status==suspended, - // и им пора — резюмируем. - for (const [coId, co] of coroutines) { - let entry = waitingCoros.find(w => w.coId === coId); - if (!entry) { - // Первый раз видим этот co — нужно вытащить delay из yield-результата. - // Это сделано в _onYield ниже. - continue; + // Используем lua.global.get-кешированную __rbxl_resume_co функцию — + // безопаснее чем doStringSync (не re-entrant в WASM). + const dueCoros = []; + for (let i = waitingCoros.length - 1; i >= 0; i--) { + if (waitingCoros[i].wakeAt <= now) { + dueCoros.push(waitingCoros[i]); + waitingCoros.splice(i, 1); } - if (entry.wakeAt > now) continue; - // Время резюмить - waitingCoros.splice(waitingCoros.indexOf(entry), 1); + } + for (const entry of dueCoros) { + const co = coroutines.get(entry.coId); + if (!co) continue; try { - const code = ` - local co = __rbxl_get_co(${JSON.stringify(coId)}) - if co and coroutine.status(co) == 'suspended' then - local ok, ret = coroutine.resume(co) - if not ok then - __rbxl_send_error(${JSON.stringify(coId)}, tostring(ret)) - __rbxl_unregister_coroutine(${JSON.stringify(coId)}) - elseif type(ret) == 'number' then - __rbxl_schedule_resume(${JSON.stringify(coId)}, ret) - elseif coroutine.status(co) == 'dead' then - __rbxl_unregister_coroutine(${JSON.stringify(coId)}) - end - end - `; - lua.doStringSync(code); + const result = luaResumeCo(co); + // result: number (next delay), nil (done), false+errStr (failed) + if (result === null || result === undefined) { + coroutines.delete(entry.coId); + } else if (typeof result === 'number') { + waitingCoros.push({ + coId: entry.coId, + wakeAt: SCHEDULER.now() + result * 1000, + }); + } } catch (e) { - send('log', { level: 'error', text: `[coroutine resume ${coId}] ${e?.message || e}` }); + send('log', { level: 'error', text: `[coroutine ${entry.coId}] ${e?.message || e}` }); + coroutines.delete(entry.coId); } } }, -- 2.47.2 From ecc2055b3de21af15acfd7ae8a5b3de6a87ea057 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 12:00:29 +0300 Subject: [PATCH 10/39] =?UTF-8?q?fix(lua):=20partSet=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20PrimitiveManager.updateInstance=20(=D0=B1?= =?UTF-8?q?=D1=8B=D0=BB=20applyPatch/update=20=E2=80=94=20=D0=B8=D1=85=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=82);=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20debug-=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/lua/RobloxShim.js | 25 +++++++++++++---------- src/editor/engine/rbxl-lua-integration.js | 3 ++- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 3fd24df..097402a 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -738,11 +738,16 @@ export function registerRobloxShim(lua, opts) { // Главное: __rbxl_resume_co определена в Lua и вызывается из JS через // lua.global.get('__rbxl_resume_co') — это безопаснее чем doStringSync // потому что не парсит код заново и не создаёт re-entrant проблем. + // Lua-side helper для логов (используется в task.wait/resume для отладки) + global.set('__log', (level, text) => { + send('log', { level: String(level || 'info'), text: String(text || '') }); + }); + lua.doStringSync(` local function rbx_wait(sec) sec = sec or 0 - coroutine.yield(sec) - return sec + local ret = coroutine.yield(sec) + return ret or sec end if type(task) == 'table' then task.wait = rbx_wait @@ -751,20 +756,19 @@ export function registerRobloxShim(lua, opts) { end wait = rbx_wait - -- Вызывается из JS-tickScheduler: - -- возвращает next-delay (number) если co yield'нулся ещё раз, - -- или nil если co завершился / умер. function __rbxl_resume_co(co) if not co or coroutine.status(co) ~= 'suspended' then return nil end local ok, ret = coroutine.resume(co) - if not ok then - return false, tostring(ret) - end + if not ok then return false, tostring(ret) end if coroutine.status(co) == 'dead' then return nil end if type(ret) == 'number' then return ret end return 0 end `); + // Добавим Lua-side helper для лога + global.set('__log', (level, text) => { + send('log', { level: String(level || 'info'), text: String(text || '') }); + }); // Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync) const luaResumeCo = lua.global.get('__rbxl_resume_co'); @@ -822,8 +826,6 @@ export function registerRobloxShim(lua, opts) { } } // 2. Резюм coroutine'ов которые task.wait() - // Используем lua.global.get-кешированную __rbxl_resume_co функцию — - // безопаснее чем doStringSync (не re-entrant в WASM). const dueCoros = []; for (let i = waitingCoros.length - 1; i >= 0; i--) { if (waitingCoros[i].wakeAt <= now) { @@ -836,7 +838,6 @@ export function registerRobloxShim(lua, opts) { if (!co) continue; try { const result = luaResumeCo(co); - // result: number (next delay), nil (done), false+errStr (failed) if (result === null || result === undefined) { coroutines.delete(entry.coId); } else if (typeof result === 'number') { @@ -844,6 +845,8 @@ export function registerRobloxShim(lua, opts) { coId: entry.coId, wakeAt: SCHEDULER.now() + result * 1000, }); + } else if (result === false) { + coroutines.delete(entry.coId); } } catch (e) { send('log', { level: 'error', text: `[coroutine ${entry.coId}] ${e?.message || e}` }); diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index fe0c7f4..4d8c24d 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -141,7 +141,8 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { else if (prop === 'anchored') patch.anchored = value; else if (prop === 'canCollide') patch.canCollide = value; else if (prop === 'opacity') patch.opacity = value; - if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch); + 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) {} return; -- 2.47.2 From 936f93a42c6d69464ef6eb02c37a55268b7e1e32 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 12:19:14 +0300 Subject: [PATCH 11/39] =?UTF-8?q?fix(lua):=20part.Position=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D1=8C=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=D0=B5=D1=82=20=D0=BA?= =?UTF-8?q?=D1=83=D0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit КОРНЕВАЯ ПРИЧИНА: BabylonScene.freezeStaticPrimitives() замораживает world matrix anchored-примитивов при Play для оптимизации. mesh.position.set(x,y,z) после этого не обновляет рендер. Фикс в PrimitiveManager.updateInstance: если patch содержит position/ rotation/size — расфризить world matrix прежде чем менять transform. --- src/editor/engine/GameRuntime.js | 7 +++- src/editor/engine/PrimitiveManager.js | 11 +++++- src/editor/engine/lua/RobloxShim.js | 1 - src/editor/engine/rbxl-lua-integration.js | 43 +++++++++++++---------- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 0c9c3a7..7a53415 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -185,7 +185,12 @@ export class GameRuntime { sb.setOnCommand(({ cmd, payload }) => { if (cmd === 'partSet' || cmd === 'partVel' || cmd === 'sceneCreate' || cmd === 'sceneDelete') { - try { handleLuaCommand(null, cmd, payload, this); } catch (_) {} + try { + handleLuaCommand(null, cmd, payload, this); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e); + } } else { this._handleCommand(null, cmd, payload); } diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 8a50c0c..9e6a556 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -689,7 +689,16 @@ export class PrimitiveManager { const data = this.instances.get(id); if (!data) return; - // Позиция + // Позиция / поворот / размер — нужно расфризить world matrix, + // иначе freezeStaticPrimitives() сделает mesh.position.set бессмысленным. + const positionChanged = patch.x !== undefined || patch.y !== undefined || patch.z !== undefined; + const transformChanged = positionChanged + || patch.rotationX !== undefined || patch.rotationY !== undefined || patch.rotationZ !== undefined + || patch.sx !== undefined || patch.sy !== undefined || patch.sz !== undefined; + if (transformChanged && data._worldMatrixFrozen) { + try { data.mesh.unfreezeWorldMatrix?.(); } catch (_) {} + data._worldMatrixFrozen = false; + } if (patch.x !== undefined) data.x = patch.x; if (patch.y !== undefined) data.y = patch.y; if (patch.z !== undefined) data.z = patch.z; diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 097402a..6f4d276 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -279,7 +279,6 @@ function newPart(primData, sendFn) { }; // Setter'ы шлют partSet → BabylonScene.primitiveManager через handleLuaCommand. - // Формат payload должен соответствовать rbxl-lua-integration.js#handleLuaCommand. const send = (prop, value) => { try { sendFn('partSet', { primId: p.__primId, prop, value }); } catch (_) {} }; diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index 4d8c24d..057941d 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -122,29 +122,34 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { return; } if (cmd === 'partSet') { + const pm = runtime.scene3d?.primitiveManager; + if (!pm) { + console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d); + return; + } + const primId = payload?.primId; + const prop = payload?.prop; + const value = payload?.value; + const patch = {}; + if (prop === 'position' && value) { + patch.x = value.x; patch.y = value.y; patch.z = value.z; + } else if (prop === 'cframe' && value) { + patch.x = value.x; patch.y = value.y; patch.z = value.z; + patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz; + } else if (prop === 'size' && value) { + patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz; + } else if (prop === 'color') patch.color = value; + else if (prop === 'material') patch.material = value; + else if (prop === 'anchored') patch.anchored = value; + else if (prop === 'canCollide') patch.canCollide = value; + else if (prop === 'opacity') patch.opacity = value; try { - const pm = runtime.scene3d?.primitiveManager; - if (!pm) return; - const primId = payload?.primId; - const prop = payload?.prop; - const value = payload?.value; - const patch = {}; - if (prop === 'position' && value) { - patch.x = value.x; patch.y = value.y; patch.z = value.z; - } else if (prop === 'cframe' && value) { - patch.x = value.x; patch.y = value.y; patch.z = value.z; - patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz; - } else if (prop === 'size' && value) { - patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz; - } else if (prop === 'color') patch.color = value; - else if (prop === 'material') patch.material = value; - else if (prop === 'anchored') patch.anchored = value; - else if (prop === 'canCollide') patch.canCollide = value; - else if (prop === 'opacity') patch.opacity = value; if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch); else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch); else if (typeof pm.update === 'function') pm.update(primId, patch); - } catch (e) {} + } catch (e) { + console.error('[partSet] updateInstance failed:', e); + } return; } if (cmd === 'sceneCreate') { -- 2.47.2 From 371ddaaae8428a5826c317351fd88e594e3c7162 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 12:40:49 +0300 Subject: [PATCH 12/39] =?UTF-8?q?feat(lua):=20=D0=AD=D1=82=D0=B0=D0=BF=205?= =?UTF-8?q?=20=E2=80=94=20GUI=20(Frame,=20TextLabel,=20TextButton,=20Image?= =?UTF-8?q?Label,=20TextBox,=20ScrollingFrame)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В Lua теперь работает Roblox-style GUI: local sg = Instance.new('ScreenGui') local label = Instance.new('TextLabel', sg) label.Text = 'Hello' label.TextColor3 = Color3.fromRGB(255, 255, 0) label.Position = UDim2.new(0.5, 0, 0.5, 0) label.Size = UDim2.new(0.2, 0, 0.05, 0) local btn = Instance.new('TextButton', sg) btn.Text = 'Click me' btn.MouseButton1Click:Connect(function() print('clicked!') end) Реализация: GUI-обёртка newGuiInstance создаёт элемент через gui.create команду → GameRuntime.scene3d.guiManager. Setter'ы Text/Visible/ BackgroundColor3/TextColor3/TextSize/Position/Size шлют gui.update. Destroy шлёт gui.remove. Клики через guiClick → guiByLocalRef → inst.MouseButton1Click.Fire(). Добавлен localPlayer.PlayerGui для совместимости с Roblox-скриптами. Co-Authored-By: Claude Opus 4.7 --- src/editor/engine/lua/RobloxShim.js | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 6f4d276..00c491e 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -480,6 +480,12 @@ export function registerRobloxShim(lua, opts) { const localPlayer = newInstance('Player', 'Player'); localPlayer.Parent = players; localPlayer.UserId = 1; + // PlayerGui — контейнер для GUI принадлежащих игроку. В Rublox это no-op + // (overlay глобальный), но Roblox-скрипты часто делают gui.Parent = playerGui. + const playerGui = newInstance('PlayerGui', 'PlayerGui'); + playerGui.Parent = localPlayer; + localPlayer.Children.push(playerGui); + localPlayer.PlayerGui = playerGui; localPlayer.DisplayName = 'Player'; players.Children.push(localPlayer); players.LocalPlayer = localPlayer; @@ -643,6 +649,136 @@ export function registerRobloxShim(lua, opts) { global.set('Workspace', workspace); // === Instance.new === + // === Helper: создание GUI-элемента через game.gui.create === + // Roblox: Frame/TextLabel/TextButton/ImageLabel/TextBox/ScrollingFrame. + // Шлём gui.create команду в main thread → GuiManager создаёт элемент. + // Возвращаем Lua-объект с setter'ами для основных свойств. + let _nextGuiLocalRef = 0; + function newGuiInstance(robloxClass) { + const localRef = `_gui_lua_${_nextGuiLocalRef++}`; + const inst = newInstance(robloxClass, robloxClass); + inst.__guiLocalRef = localRef; + inst.__guiClass = robloxClass; + // Маппим Roblox-класс на тип в GuiManager + const guiType = ({ + Frame: 'frame', + TextLabel: 'text', + TextButton: 'button', + ImageLabel: 'image', + ImageButton: 'button', + TextBox: 'textbox', + ScrollingFrame: 'scroll', + })[robloxClass] || 'frame'; + // Внутренние стейты + inst._gui = { + type: guiType, + text: '', + bgColor: '#3a2820', + bgOpacity: 1, + textColor: '#f0e6d8', + textSize: 16, + x: 50, y: 50, w: 20, h: 10, + visible: true, + }; + // Шлём create при первом обращении (lazy) или сейчас — лучше сейчас, чтобы + // не было гонок при моментальной правке свойств после Instance.new. + send('gui.create', { + type: guiType, + opts: { ...inst._gui, _scriptCreated: true }, + localRef, + }); + // Сигналы (для кнопок) + if (robloxClass === 'TextButton' || robloxClass === 'ImageButton') { + inst.MouseButton1Click = makeSignal(); + inst.MouseEnter = makeSignal(); + inst.MouseLeave = makeSignal(); + inst.Activated = inst.MouseButton1Click; + } + // Setters + const updateField = (field, value) => { + inst._gui[field] = value; + send('gui.update', { id: localRef, patch: { [field]: value } }); + }; + Object.defineProperty(inst, 'Text', { + get() { return inst._gui.text; }, + set(v) { updateField('text', String(v ?? '')); }, + enumerable: true, + }); + Object.defineProperty(inst, 'Visible', { + get() { return inst._gui.visible; }, + set(v) { updateField('visible', !!v); }, + enumerable: true, + }); + Object.defineProperty(inst, 'BackgroundColor3', { + get() { return RbxColor3.fromHex(inst._gui.bgColor); }, + set(v) { + if (!v) return; + const hex = v.toHex ? v.toHex() : (v instanceof RbxColor3 ? v.toHex() : '#3a2820'); + updateField('bgColor', hex); + }, + enumerable: true, + }); + Object.defineProperty(inst, 'BackgroundTransparency', { + get() { return 1 - (inst._gui.bgOpacity ?? 1); }, + set(v) { updateField('bgOpacity', 1 - Math.max(0, Math.min(1, +v || 0))); }, + enumerable: true, + }); + Object.defineProperty(inst, 'TextColor3', { + get() { return RbxColor3.fromHex(inst._gui.textColor); }, + set(v) { + if (!v) return; + const hex = v.toHex ? v.toHex() : '#f0e6d8'; + updateField('textColor', hex); + }, + enumerable: true, + }); + Object.defineProperty(inst, 'TextSize', { + get() { return inst._gui.textSize; }, + set(v) { updateField('textSize', Math.max(8, Math.min(72, +v || 16))); }, + enumerable: true, + }); + // Position: UDim2 → x,y проценты (Roblox-style: scale=%, offset=px) + // Упрощённо берём scale*100 как x/y; offset игнорируем. + Object.defineProperty(inst, 'Position', { + get() { + return new RbxUDim2(inst._gui.x / 100, 0, inst._gui.y / 100, 0); + }, + set(v) { + if (!v) return; + const xPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10; + const yPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10; + inst._gui.x = xPct; + inst._gui.y = yPct; + send('gui.update', { id: localRef, patch: { x: xPct, y: yPct } }); + }, + enumerable: true, + }); + Object.defineProperty(inst, 'Size', { + get() { + return new RbxUDim2(inst._gui.w / 100, 0, inst._gui.h / 100, 0); + }, + set(v) { + if (!v) return; + const wPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10; + const hPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10; + inst._gui.w = wPct; + inst._gui.h = hPct; + send('gui.update', { id: localRef, patch: { w: wPct, h: hPct } }); + }, + enumerable: true, + }); + // Destroy — удаление GUI + const origDestroy = inst.Destroy; + inst.Destroy = function () { + try { send('gui.remove', { id: localRef }); } catch (_) {} + origDestroy.call(inst); + }; + return inst; + } + + // Регистрация в guiByLocalRef для дальнейшей маршрутизации событий клика + const guiByLocalRef = new Map(); + // Счётчик для новых Part'ов, создаваемых через Instance.new("Part"): // primitiveManager.addInstance даст уникальный id, мы используем временный // отрицательный id пока не пришёл sceneCreate ACK. Но проще: даём свой @@ -693,6 +829,18 @@ export function registerRobloxShim(lua, opts) { inst.Health = 100; inst.MaxHealth = 100; inst.Died = makeSignal(); inst.HealthChanged = makeSignal(); inst.TakeDamage = function (n) { this.Health = Math.max(0, this.Health - (n || 0)); }; + } else if (className === 'ScreenGui') { + // ScreenGui — логический корень GUI. В Rublox overlay глобальный, + // поэтому ScreenGui это просто контейнер-no-op (без gui.create). + inst = newInstance('ScreenGui', 'ScreenGui'); + inst.__isScreenGui = true; + inst.Enabled = true; + } else if (className === 'Frame' || className === 'TextLabel' + || className === 'TextButton' || className === 'ImageLabel' + || className === 'ImageButton' || className === 'TextBox' + || className === 'ScrollingFrame') { + inst = newGuiInstance(className); + guiByLocalRef.set(inst.__guiLocalRef, inst); } else { inst = newInstance(className, className); } @@ -885,6 +1033,14 @@ export function registerRobloxShim(lua, opts) { if (humanoid.Touched) humanoid.Touched.Fire(part); } } + // GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id} + if (p.type === 'guiClick') { + const ref = p.localId || p.id; + const guiEl = guiByLocalRef.get(ref); + if (guiEl?.MouseButton1Click) { + guiEl.MouseButton1Click.Fire(); + } + } }, // Доступ к ключевым объектам (для тестов и отладки) partById, localPlayer, humanoid, character, workspace, players, game, -- 2.47.2 From a1faf237a1fcb88bb8abfa6510cca0894ad6989a Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 12:45:15 +0300 Subject: [PATCH 13/39] =?UTF-8?q?feat(lua):=20=D0=AD=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=D1=8B=206+7=20=E2=80=94=20Sound=20+=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20RUBLOX=5FLUA=5FAPI.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Этап 6 — Sound: local s = Instance.new('Sound') s.SoundId = 'coin' -- или 'jump'/'win'/'lose'/'hit'/'click'/'pickup' s.Volume = 1 s.PlaybackSpeed = 1.2 s:Play() s.Ended:Connect(function() print('ended') end) SoundService:PlayLocalSound(sound) тоже работает. Маппинг roblox-AssetID на встроенные звуки по эвристике (substring match). Этап 7 — Документация: RUBLOX_LUA_API.md — полный справочник всего реализованного. Содержание: базовые типы, DataModel, Part-setters, Instance.new, события (Touched/Heartbeat/RemoteEvent), таймеры, GUI, Sound, TweenService, Humanoid, что не работает, готовый пример игры (KillBrick + Coin + GUI-счётчик). Этим завершается план RUBLOX_LUA_SUPPORT_PLAN (все 7 этапов). Co-Authored-By: Claude Opus 4.7 --- RUBLOX_LUA_API.md | 504 ++++++++++++++++++++++++++++ src/editor/engine/lua/RobloxShim.js | 52 ++- 2 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 RUBLOX_LUA_API.md diff --git a/RUBLOX_LUA_API.md b/RUBLOX_LUA_API.md new file mode 100644 index 0000000..84c95dc --- /dev/null +++ b/RUBLOX_LUA_API.md @@ -0,0 +1,504 @@ +# Lua API Рублокса (справочник для скриптеров) + +Этот документ — полный список того, что работает в Lua-скриптах Рублокса. +API максимально приближен к Roblox, чтобы можно было переносить чужие +скрипты с минимальными правками. + +> **Как переключить скрипт на Lua:** в шапке вкладки редактора кода кликни +> по переключателю **JS / Lua**. Подсветка синтаксиса и автодополнение +> автоматически переключатся. + +--- + +## Содержание + +1. [Базовые типы](#базовые-типы) +2. [DataModel: game, workspace, Players](#datamodel) +3. [Part — куб на сцене](#part) +4. [Создание и удаление](#создание-и-удаление) +5. [События: Touched, Heartbeat, RemoteEvent](#события) +6. [Таймеры: task.wait, task.delay](#таймеры) +7. [GUI: TextLabel, TextButton, Frame](#gui) +8. [Звук: Sound](#звук) +9. [Анимации: TweenService](#tweenservice) +10. [Игрок: Humanoid, LocalPlayer](#игрок) +11. [Чего пока нет](#чего-пока-нет) + +--- + +## Базовые типы + +### `Vector3` + +```lua +local v = Vector3.new(1, 2, 3) +print(v.X, v.Y, v.Z) -- 1 2 3 +print(v.Magnitude) -- 3.7416... (длина) +print(v.Unit) -- нормализованный +print(v:Dot(otherVec)) -- скалярное произведение +print(v:Cross(otherVec)) -- векторное произведение +local mid = v:Lerp(otherVec, 0.5) -- линейная интерполяция + +-- Константы: +Vector3.zero -- (0,0,0) +Vector3.one -- (1,1,1) +Vector3.xAxis -- (1,0,0) +Vector3.yAxis, Vector3.zAxis +``` + +Поддержаны операторы: `+`, `-`, `*` (на число), `/`, унарный `-`. + +### `Color3` + +```lua +local c = Color3.new(0.5, 0.2, 0.8) -- 0..1 каждый +local c2 = Color3.fromRGB(255, 128, 0) -- 0..255 +local c3 = Color3.fromHSV(0.1, 0.8, 1) +local c4 = Color3.fromHex("#FF8000") +local mid = c:Lerp(c2, 0.5) +print(c:ToHex()) -- "#7F33CC" +``` + +### `UDim2` / `UDim` / `Vector2` + +Для GUI-координат: + +```lua +local pos = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана (scale/offset) +local pos2 = UDim2.fromScale(0.2, 0.1) +local pos3 = UDim2.fromOffset(100, 50) -- в пикселях +``` + +### `CFrame` + +```lua +local cf = CFrame.new(0, 10, 0) -- позиция +local cf2 = CFrame.lookAt(eye, target) -- упрощённый +print(cf.Position) -- Vector3 +``` + +### `Enum` + +```lua +Enum.KeyCode.W +Enum.KeyCode.Space +Enum.Material.Plastic, Enum.Material.Neon, Enum.Material.Wood +Enum.UserInputType.MouseButton1 +Enum.HumanoidStateType.Running +``` + +--- + +## DataModel + +Виртуальное дерево, как в Roblox: + +```lua +game -- корневой DataModel +game.Workspace -- = workspace (короче) +game.Players -- сервис игроков +game.Players.LocalPlayer -- локальный игрок +game.ReplicatedStorage -- хранилище общих ресурсов +game.StarterGui -- стартовое GUI +game.Lighting -- свет +``` + +Методы: + +```lua +local svc = game:GetService("RunService") +local part = workspace:FindFirstChild("Coin") +local part2 = workspace:FindFirstChildOfClass("Part") +local all = workspace:GetChildren() -- массив всех детей +local descendants = workspace:GetDescendants() +local sib = workspace.Coin:FindFirstAncestorOfClass("Workspace") +print(workspace:IsA("Workspace")) -- true +``` + +--- + +## Part + +`Part` — куб/сфера/цилиндр на сцене. **Это обёртка над примитивом Рублокса.** +Скрипт привязанный к кубу получает его через `script.Parent`: + +```lua +-- script.Parent — Part к которому прицеплен скрипт +print(script.Parent.Name) -- "Part_1" + +-- Чтение свойств +print(script.Parent.Position) -- Vector3 +print(script.Parent.Size) -- Vector3 +print(script.Parent.Color) -- Color3 +print(script.Parent.Anchored) -- bool +print(script.Parent.CanCollide) -- bool +print(script.Parent.Transparency) -- 0..1 + +-- Запись (двигает куб в реальном времени!) +script.Parent.Position = Vector3.new(0, 10, 0) +script.Parent.Size = Vector3.new(5, 1, 5) +script.Parent.Color = Color3.fromRGB(255, 0, 0) +script.Parent.Anchored = false -- куб начнёт падать (физика) +script.Parent.Transparency = 0.5 -- полупрозрачный +script.Parent.CFrame = CFrame.new(0, 20, 0) +``` + +--- + +## Создание и удаление + +### `Instance.new` + +```lua +-- Создать Part на сцене +local p = Instance.new("Part") +p.Position = Vector3.new(0, 5, 0) +p.Size = Vector3.new(2, 2, 2) +p.Color = Color3.fromRGB(255, 100, 0) +p.Anchored = true +p.Parent = workspace + +-- Удалить через 3 секунды +task.delay(3, function() + p:Destroy() +end) +``` + +Поддержанные классы: +- **Сцена:** `Part`, `WedgePart`, `MeshPart` +- **События:** `RemoteEvent`, `BindableEvent` +- **GUI:** `ScreenGui`, `Frame`, `TextLabel`, `TextButton`, `ImageLabel`, + `ImageButton`, `TextBox`, `ScrollingFrame` +- **Звук:** `Sound` +- **Прочее:** `Folder`, `Humanoid`, `Configuration`, любой `ClassName` + +--- + +## События + +### `script.Parent.Touched` — касание игрока + +```lua +script.Parent.Touched:Connect(function(hit) + print("Игрок коснулся!", hit.Name) + local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid") + if h then + h:TakeDamage(100) -- KillBrick + end +end) +``` + +### `RunService.Heartbeat` — каждый кадр + +```lua +local RunService = game:GetService("RunService") +RunService.Heartbeat:Connect(function(dt) + -- dt — время с прошлого кадра (~0.016) + script.Parent.Position = script.Parent.Position + Vector3.new(0, 0.1, 0) +end) +``` + +### `BindableEvent` / `RemoteEvent` — общение между скриптами + +```lua +-- Скрипт A создаёт событие в общем месте +local event = Instance.new("BindableEvent") +event.Name = "MyEvent" +event.Parent = game.ReplicatedStorage + +-- Скрипт B подписывается +local event = game.ReplicatedStorage:WaitForChild("MyEvent") +event.Event:Connect(function(msg, num) + print("Получено:", msg, num) +end) + +-- Скрипт A триггерит +event:Fire("привет", 42) +``` + +### `Humanoid.Died` + +```lua +local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid") +h.Died:Connect(function() + print("игрок умер") +end) +h.HealthChanged:Connect(function(newHp) + print("здоровье:", newHp) +end) +``` + +--- + +## Таймеры + +### `task.wait(сек)` — приостановить скрипт + +```lua +print("сейчас") +task.wait(1) +print("через секунду") +``` + +`task.wait` **не блокирует** другие скрипты — это yield через coroutines. +Можно использовать в `while true do ... task.wait(0.1) end` без проблем. + +### `task.delay(сек, fn)` — выполнить через + +```lua +task.delay(2, function() + print("через 2 секунды") +end) +``` + +### `task.spawn(fn)` — асинхронно + +```lua +task.spawn(function() + print("параллельно с основным потоком") +end) +``` + +--- + +## GUI + +### Базовая иерархия + +```lua +-- ScreenGui — корень всех GUI +local sg = Instance.new("ScreenGui") +sg.Parent = game.Players.LocalPlayer.PlayerGui + +-- TextLabel — статичный текст +local label = Instance.new("TextLabel") +label.Parent = sg +label.Text = "Привет!" +label.TextColor3 = Color3.fromRGB(255, 255, 0) +label.BackgroundColor3 = Color3.fromRGB(50, 30, 20) +label.Position = UDim2.new(0.4, 0, 0.1, 0) -- 40% от ширины, 10% от высоты +label.Size = UDim2.new(0.2, 0, 0.05, 0) +label.TextSize = 24 + +-- TextButton — кликабельная кнопка +local btn = Instance.new("TextButton") +btn.Parent = sg +btn.Text = "Нажми" +btn.Position = UDim2.new(0.4, 0, 0.5, 0) +btn.Size = UDim2.new(0.2, 0, 0.08, 0) +btn.MouseButton1Click:Connect(function() + print("Клик!") + label.Text = "Нажата!" +end) +``` + +### Свойства + +| Свойство | Тип | Описание | +|----------------------|-----------|-----------------------------------| +| `Text` | string | Видимый текст | +| `TextColor3` | Color3 | Цвет текста | +| `TextSize` | number | Размер шрифта | +| `BackgroundColor3` | Color3 | Цвет фона | +| `BackgroundTransparency` | 0..1 | 0=сплошной, 1=прозрачный | +| `Position` | UDim2 | Позиция (scale=%, offset=px/10) | +| `Size` | UDim2 | Размер | +| `Visible` | bool | Виден или нет | + +### События кнопок + +```lua +btn.MouseButton1Click:Connect(fn) -- ЛКМ клик +btn.MouseEnter:Connect(fn) -- наведение +btn.MouseLeave:Connect(fn) -- увод +btn.Activated:Connect(fn) -- = MouseButton1Click +``` + +--- + +## Звук + +```lua +local sound = Instance.new("Sound") +sound.SoundId = "coin" -- или "jump", "win", "lose", "hit", "click", "pickup" +sound.Volume = 1 -- 0..2 +sound.PlaybackSpeed = 1 -- pitch +sound:Play() +``` + +Также Roblox-AssetID работает с эвристикой: + +```lua +sound.SoundId = "rbxassetid://1234567890" -- автоподбор по имени переменной +``` + +Поддержанные звуки (процедурные, не из файлов): +- `jump` — прыжок +- `pickup` — подбор +- `coin` — звон монеты +- `win` — победа +- `lose` — поражение +- `click` — клик +- `hit` — удар + +Зацикливание: + +```lua +sound.Looped = true +sound:Play() -- играет до sound:Stop() +``` + +--- + +## TweenService + +Плавная анимация свойств: + +```lua +local TweenService = game:GetService("TweenService") + +local part = script.Parent +local tween = TweenService:Create( + part, + { Time = 2 }, -- длительность 2 сек + { Position = Vector3.new(0, 20, 0), + Color = Color3.fromRGB(255, 0, 0) } -- цели +) +tween:Play() + +tween.Completed:Connect(function() + print("Анимация завершилась!") +end) +``` + +Работает с `Position`, `Size`, `Color` (Vector3/Color3) и числовыми +свойствами (`Transparency`, `TextSize`, и т.д.). + +--- + +## Игрок + +### `game.Players.LocalPlayer` + +```lua +local plr = game.Players.LocalPlayer +print(plr.Name, plr.UserId, plr.DisplayName) +print(plr.Character) -- Model +``` + +### `Humanoid` + +```lua +local char = game.Players.LocalPlayer.Character +local h = char:FindFirstChildOfClass("Humanoid") + +print(h.Health, h.MaxHealth) +print(h.WalkSpeed) -- скорость ходьбы +print(h.JumpPower) -- сила прыжка + +h.Health = 0 -- мгновенная смерть → респавн +h:TakeDamage(50) -- урон с учётом invulnerability + +h.Died:Connect(function() + print("Помер") +end) +h.HealthChanged:Connect(function(newHp) + if newHp < 30 then + print("Здоровье низкое!") + end +end) +``` + +### `HumanoidRootPart` + +```lua +local hrp = char:FindFirstChild("HumanoidRootPart") +print(hrp.Position) +``` + +--- + +## Чего пока нет + +Не работает (пока): + +- **Скрипты не делятся на Server/LocalScript** — все скрипты client-side. +- **DataStoreService** — методы есть, но возвращают nil/no-op. +- **`workspace:Raycast`** / **`game.Lighting.ClockTime`** — заглушки. +- **`Players.PlayerAdded`** — никогда не фейерится (только один игрок). +- **3D-анимации (`Animation` instance + `AnimationController`)** — + `LoadAnimation` возвращает заглушку. +- **`Sound` из файлов** — только встроенные процедурные. +- **`SurfaceGui` / `BillboardGui`** — нет, только `ScreenGui`. +- **`Model:MoveTo` / `:SetPrimaryPartCFrame`** — нет. +- **Networking (`RemoteFunction:InvokeServer`)** — RemoteEvent работает + только в пределах одного клиента. + +Если что-то из этого критично — открой issue в репо. + +--- + +## Пример: KillBrick + монета + GUI-счётчик + +Положи 1 куб и 1 шарик на сцене. К каждому привяжи скрипт: + +**На кубе (KillBrick):** +```lua +script.Parent.Color = Color3.fromRGB(200, 30, 30) +script.Parent.Touched:Connect(function() + local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid") + if h then h:TakeDamage(100) end +end) +``` + +**На шарике (Coin):** +```lua +script.Parent.Color = Color3.fromRGB(255, 215, 0) +script.Parent.Touched:Connect(function() + -- Запускаем событие на ReplicatedStorage + local re = game.ReplicatedStorage:FindFirstChild("CoinPicked") + if not re then + re = Instance.new("BindableEvent") + re.Name = "CoinPicked" + re.Parent = game.ReplicatedStorage + end + re:Fire() + script.Parent:Destroy() +end) +``` + +**Глобальный скрипт (GUI):** +```lua +local sg = Instance.new("ScreenGui") +sg.Parent = game.Players.LocalPlayer.PlayerGui + +local label = Instance.new("TextLabel") +label.Parent = sg +label.Text = "Монет: 0" +label.Position = UDim2.new(0.05, 0, 0.05, 0) +label.Size = UDim2.new(0.1, 0, 0.05, 0) +label.TextSize = 20 +label.TextColor3 = Color3.fromRGB(255, 215, 0) + +local count = 0 +task.spawn(function() + while not game.ReplicatedStorage:FindFirstChild("CoinPicked") do + task.wait(0.1) + end + game.ReplicatedStorage.CoinPicked.Event:Connect(function() + count = count + 1 + label.Text = "Монет: " .. count + local sound = Instance.new("Sound") + sound.SoundId = "coin" + sound:Play() + end) +end) +``` + +Получится: красный куб убивает, золотая монета даёт +1 к счётчику со +звуком. + +--- + +**Версия документации:** Этап 7 (готово после реализации Этапов 1-6). +Если что-то описанное здесь не работает — это баг, репортуй. diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 00c491e..e133e7b 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -610,7 +610,10 @@ export function registerRobloxShim(lua, opts) { makeService('Lighting').Ambient = new RbxColor3(0.5, 0.5, 0.5); makeService('Chat'); - makeService('SoundService'); + const soundService = makeService('SoundService'); + soundService.PlayLocalSound = function (sound) { + if (sound && typeof sound.Play === 'function') sound.Play(); + }; makeService('PathfindingService'); makeService('CollectionService'); makeService('MarketplaceService'); @@ -829,6 +832,53 @@ export function registerRobloxShim(lua, opts) { inst.Health = 100; inst.MaxHealth = 100; inst.Died = makeSignal(); inst.HealthChanged = makeSignal(); inst.TakeDamage = function (n) { this.Health = Math.max(0, this.Health - (n || 0)); }; + } else if (className === 'Sound') { + // Sound — процедурные звуки через _playSound. + // SoundId → имя процедурного звука (rbxassetid игнорится). + inst = newInstance('Sound', 'Sound'); + inst.SoundId = ''; + inst.Volume = 1; + inst.PlaybackSpeed = 1; + inst.Pitch = 1; + inst.Looped = false; + inst.IsPlaying = false; + inst.Played = makeSignal(); + inst.Ended = makeSignal(); + // Map SoundId/имя на встроенный звук (jump/pickup/win/lose/click/hit/coin). + const _mapSoundName = (idOrName) => { + if (!idOrName) return 'click'; + const s = String(idOrName).toLowerCase(); + // Прямые ключи имеют приоритет + if (['jump','pickup','win','lose','click','hit','coin'].indexOf(s) >= 0) return s; + // Эвристика по части строки (для Roblox AssetID) + if (s.includes('jump')) return 'jump'; + if (s.includes('pickup') || s.includes('collect')) return 'pickup'; + if (s.includes('win') || s.includes('victory')) return 'win'; + if (s.includes('lose') || s.includes('death')) return 'lose'; + if (s.includes('hit') || s.includes('damage')) return 'hit'; + if (s.includes('coin') || s.includes('gem')) return 'coin'; + return 'click'; + }; + inst.Play = function () { + const name = _mapSoundName(this.SoundId || this.Name); + const pitch = +this.PlaybackSpeed || +this.Pitch || 1; + const volume = +this.Volume || 1; + send('sound.play', { name, volume, pitch }); + this.IsPlaying = true; + this.Played.Fire(); + // Простая модель: считаем что звук длится 0.5с + SCHEDULER.sleeping.push({ + wakeAt: SCHEDULER.now() + 500, + run: () => { + this.IsPlaying = false; + this.Ended.Fire(); + if (this.Looped) this.Play(); + }, + }); + }; + inst.Stop = function () { this.IsPlaying = false; }; + inst.Pause = function () { this.IsPlaying = false; }; + inst.Resume = function () { if (!this.IsPlaying) this.Play(); }; } else if (className === 'ScreenGui') { // ScreenGui — логический корень GUI. В Rublox overlay глобальный, // поэтому ScreenGui это просто контейнер-no-op (без gui.create). -- 2.47.2 From 2fa575ae4c61f621c47afa317c8d591b54bd0bdd Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 12:56:05 +0300 Subject: [PATCH 14/39] =?UTF-8?q?refactor(rbxl-import):=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20Lua-=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B8=D0=B4=D1=83=D1=82=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20LuaSharedSandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше было два параллельных Lua-runtime: - RobloxLuaSharedSandbox (Worker + wasmoon) для импортированных .rbxl; - LuaSharedSandbox (main thread + wasmoon) для user-Lua. Импортированные скрипты не получали фичи Этапов 4-6 (Position setters, GUI, Sound, TweenService) — их shim был в отдельном Worker'е с более старым API. Сейчас в GameRuntime.start(): 1. Скрипты с маркером '// @roblox-lua' распаковываются через unpackRobloxLuaCode() и попадают в тот же luaUserBatch что и user-Lua; 2. Собыраются _rbxlImported=true для лога; 3. Числовой script.target (примитив id) уже совместим с LuaSharedSandbox.addScript → резолвится в script.Parent. Удалены мёртвые файлы (общий размер ~2500 строк): - RobloxLuaSharedSandbox.js + RobloxLuaSharedWorker.js - RobloxLuaSandbox.js + RobloxLuaWorker.js (старая пара) - roblox-shim.js + roblox-services.js + roblox-physics.js - roblox-scheduler.js + roblox-tween.js - из rbxl-lua-integration.js убрана функция startRobloxLuaShared() Побочный эффект: импортированные Roblox-игры теперь автоматически получают: - живые part.Position/Size/Color setters; - полный GUI (Frame/TextLabel/TextButton); - TweenService:Create с реальной интерполяцией; - Sound с процедурными звуками; - Humanoid.Health/Died и прочие фичи Этапов 4-6. Co-Authored-By: Claude Opus 4.7 --- src/editor/engine/GameRuntime.js | 52 +- src/editor/engine/RobloxLuaSandbox.js | 164 ----- src/editor/engine/RobloxLuaSharedSandbox.js | 161 ----- src/editor/engine/RobloxLuaSharedWorker.js | 381 ----------- src/editor/engine/RobloxLuaWorker.js | 180 ----- src/editor/engine/rbxl-lua-integration.js | 45 +- src/editor/engine/roblox-physics.js | 216 ------ src/editor/engine/roblox-scheduler.js | 209 ------ src/editor/engine/roblox-services.js | 384 ----------- src/editor/engine/roblox-shim.js | 715 -------------------- src/editor/engine/roblox-tween.js | 204 ------ 11 files changed, 31 insertions(+), 2680 deletions(-) delete mode 100644 src/editor/engine/RobloxLuaSandbox.js delete mode 100644 src/editor/engine/RobloxLuaSharedSandbox.js delete mode 100644 src/editor/engine/RobloxLuaSharedWorker.js delete mode 100644 src/editor/engine/RobloxLuaWorker.js delete mode 100644 src/editor/engine/roblox-physics.js delete mode 100644 src/editor/engine/roblox-scheduler.js delete mode 100644 src/editor/engine/roblox-services.js delete mode 100644 src/editor/engine/roblox-shim.js delete mode 100644 src/editor/engine/roblox-tween.js diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 7a53415..0bf3b6e 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -19,7 +19,7 @@ import { ScriptSandbox } from './ScriptSandbox'; import { STORYS_addres } from '../../api/API'; import { PhysicsWorld } from './PhysicsWorld'; import { LabelManager } from './LabelManager'; -import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js'; +import { handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js'; import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js'; export class GameRuntime { @@ -116,15 +116,24 @@ export class GameRuntime { try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } // Roblox-Lua скрипты собираем для single-VM режима: один shared Worker // на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит. - const rbxlBatch = []; const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || []; - // НОВОЕ (Этап 2): Lua-скрипты с language='lua' идут через LuaSharedSandbox - // (один shared VM на всю игру). Это user-written Lua + Roblox API совместимость. - // Отличается от rbxl-import batch: тут код юзер написал в редакторе сам. + // Единый Lua-batch: и user-Lua (language='lua'), и импортированные .rbxl + // скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox. + // .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua. const luaUserBatch = []; for (const s of scripts) { if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { - rbxlBatch.push(s); + const luaSource = unpackRobloxLuaCode(s.code); + if (luaSource && luaSource.trim()) { + luaUserBatch.push({ + id: s.id, + name: s.name, + target: s.target, + language: 'lua', + code: luaSource, + _rbxlImported: true, + }); + } continue; } if (s && s.language === 'lua') { @@ -160,23 +169,8 @@ export class GameRuntime { // eslint-disable-next-line no-console console.log('[GameRuntime] sandbox started for script id=', s.id); } - // Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом. - let rbxlCount = 0; - if (rbxlBatch.length > 0) { - // GUI-дерево из projectData для pre-population - const guiElements = this.projectData?.scene?.gui || []; - const result = startRobloxLuaShared(rbxlBatch, { - primitives, - guiElements, - onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this), - }); - if (result && result.sandbox) { - this.sandboxes.push(result.sandbox); - this._rbxlSharedSandbox = result.sandbox; - rbxlCount = result.count; - } - } - // НОВОЕ (Этап 2): user-written Lua-скрипты с language='lua' через LuaSharedSandbox + // Импортированные .rbxl-скрипты теперь идут через тот же LuaSharedSandbox + // вместе с user-Lua (см. luaUserBatch выше). Отдельный Worker больше не нужен. let luaUserCount = 0; if (luaUserBatch.length > 0) { try { @@ -211,13 +205,15 @@ export class GameRuntime { this._log('error', `Lua-runtime ошибка: ${e?.message || e}`); } } - const jsOnly = this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0) - (this._luaUserSandbox ? 1 : 0); + const rbxlImported = luaUserBatch.filter(s => s._rbxlImported).length; + const luaWritten = luaUserCount - rbxlImported; + const jsOnly = this.sandboxes.length - (this._luaUserSandbox ? 1 : 0); this._log('info', `Запущено JS-скриптов: ${jsOnly}`); - if (rbxlCount > 0) { - this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlCount}`); + if (rbxlImported > 0) { + this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlImported}`); } - if (luaUserCount > 0) { - this._log('info', `Запущено Lua-скриптов юзера: ${luaUserCount}`); + if (luaWritten > 0) { + this._log('info', `Запущено Lua-скриптов юзера: ${luaWritten}`); } // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' // во все sandbox'ы. Не перезаписываем существующий обработчик — diff --git a/src/editor/engine/RobloxLuaSandbox.js b/src/editor/engine/RobloxLuaSandbox.js deleted file mode 100644 index 6a87e77..0000000 --- a/src/editor/engine/RobloxLuaSandbox.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * RobloxLuaSandbox — main-side обёртка над одним RobloxLuaWorker. - * - * Использование (по аналогии с ScriptSandbox): - * const sb = new RobloxLuaSandbox(luaSource, targetPrimitiveId); - * sb.setOnCommand((cmd, payload) => ...); - * sb.setInitialScene({primitives: {...}}); - * sb.start(); - * sb.tick(dt, sceneSnap); - * sb.fireEvent('touched', {primId, otherPrimId}); - * sb.stop(); - * - * Команды от Worker: - * { cmd: 'boot' } — Lua-VM запущена - * { cmd: 'ready' } — top-level код выполнен - * { cmd: 'log', payload: { level, text } } - * { cmd: 'partSet', payload: { primId, prop, value } } - * { cmd: 'partVel', payload: { primId, vx, vy, vz } } - * { cmd: 'playerCmd', payload: { method, args } } - * { cmd: 'tweenStart', payload: { ... } } - * { cmd: 'broadcast', payload: { msg, data } } - * { cmd: 'spawn', payload: { template, props, parentId } } - */ - -let _workerUrl = null; - -function getWorkerUrl() { - if (_workerUrl) return _workerUrl; - // Vite worker syntax — лучше через ?worker импорт; но мы можем - // динамически генерировать URL для ScriptSandboxWorker-style. - // Здесь упрощённо: загружаем worker как module через Vite ?worker&inline. - // Это будет настроено при интеграции в GameRuntime. - return null; -} - -export class RobloxLuaSandbox { - constructor(luaSource, targetPrimitiveId = null) { - this.luaSource = luaSource || ''; - this.targetPrimitiveId = targetPrimitiveId; - this.worker = null; - this._onCommand = null; - this._booted = false; - this._ready = false; - this._stopped = false; - this._pendingTicks = []; - this._pendingEvents = []; - this._initialScene = null; - } - - setOnCommand(cb) { this._onCommand = cb; } - setInitialScene(snap) { this._initialScene = snap; } - - /** - * @param {Worker} worker — экземпляр Worker'а (предоставляется снаружи, - * так как Vite требует new Worker(new URL(...)) syntax который надо - * прописать в месте импорта) - */ - start(worker) { - if (this.worker) return; - if (!worker) throw new Error('RobloxLuaSandbox.start(worker): worker is required'); - - this.worker = worker; - this.worker.onmessage = (e) => this._handle(e); - this.worker.onerror = (err) => { - this._emit('log', { level: 'error', text: `Worker error: ${err.message || err}` }); - }; - this.worker.postMessage({ - cmd: 'init', - payload: { - code: this.luaSource, - target: this.targetPrimitiveId, - sceneSnap: this._initialScene || { primitives: {} }, - }, - }); - } - - /** Передать кадр (snap сцены + dt). */ - tick(dt, sceneSnap) { - if (!this.worker) return; - if (!this._ready) { - this._pendingTicks.push({ dt, sceneSnap }); - return; - } - try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {} - } - - /** Передать событие. */ - fireEvent(kind, args, signalId) { - if (!this.worker) return; - if (!this._ready) { - this._pendingEvents.push({ kind, args, signalId }); - return; - } - try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, signalId } }); } catch (e) {} - } - - stop() { - this._stopped = true; - try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {} - try { this.worker?.terminate(); } catch (e) {} - this.worker = null; - } - - // ── Совместимость с интерфейсом ScriptSandbox (GameRuntime ожидает эти методы) ── - // Все no-op либо мапятся на fireEvent — наш Worker сам держит state сцены. - sendSceneSnapshot(_snap) { /* no-op: ничего не делает, наш Worker не использует snapshot напрямую */ } - sendGuiSnapshot(_snap) { /* no-op */ } - sendSkinsSnapshot(_snap) { /* no-op */ } - sendInventorySnapshot(_snap) { /* no-op */ } - sendTerrainHeightmap(_payload) { /* no-op */ } - sendGlobalEvent(kind, payload) { - // Глобальные события (input, hpChange, broadcast) маршрутизируем в наш fireEvent. - try { this.fireEvent(kind, [payload]); } catch (e) {} - } - sendBroadcast(msg, data) { - try { this.fireEvent('broadcast', [msg, data]); } catch (e) {} - } - sendOnTouchEvent(payload) { - try { this.fireEvent('touched', [payload]); } catch (e) {} - } - sendOnTickEvent(dt) { - try { this.tick(dt, null); } catch (e) {} - } - sendTweenDone(payload) { - try { this.fireEvent('tweenDone', [payload]); } catch (e) {} - } - sendSpawnResolved(payload) { - try { this.fireEvent('spawnResolved', [payload]); } catch (e) {} - } - setInitialSelfPosition(_p) { /* no-op */ } - setModules(_modules) { /* no-op: rbxl Lua не использует наш game.require */ } - get scriptId() { return this._scriptId; } - set scriptId(v) { this._scriptId = v; } - - _handle(ev) { - if (this._stopped) return; - const { cmd, payload } = ev.data || {}; - if (cmd === 'boot') { - this._booted = true; - return; - } - if (cmd === 'ready') { - this._ready = true; - // флушим накопленное - for (const t of this._pendingTicks) { - try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {} - } - this._pendingTicks = []; - for (const e of this._pendingEvents) { - try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {} - } - this._pendingEvents = []; - this._emit('ready', null); - return; - } - this._emit(cmd, payload); - } - - _emit(cmd, payload) { - if (this._onCommand) { - try { this._onCommand(cmd, payload); } catch (e) {} - } - } -} diff --git a/src/editor/engine/RobloxLuaSharedSandbox.js b/src/editor/engine/RobloxLuaSharedSandbox.js deleted file mode 100644 index 65cd699..0000000 --- a/src/editor/engine/RobloxLuaSharedSandbox.js +++ /dev/null @@ -1,161 +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 != null) { - const t = payload.target; - // target может быть: число (импортированный rbxl), {id|ref}, 'primitive:' - 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)) { - this.fireEvent('guiClick', { guiId: payload.id || payload.localId }); - return; - } - // keyboard - if (type === 'keydown' || type === 'keyup') { - this.fireEvent(type, { key: payload.key }); - return; - } - // hp/death - if (type === 'hpChange' || type === 'humanoidHealth') { - this.fireEvent('humanoidHealth', { health: payload.hp || payload.health || 0 }); - return; - } - if (type === 'died' || type === 'humanoidDied') { - this.fireEvent('humanoidDied', {}); - return; - } - // default: пробрасываем как kind=type - this.fireEvent(type || 'unknown', payload); - } - sendBroadcast(msg, data) { this.fireEvent('broadcast', { msg, data }); } - sendOnTouchEvent(payload) { this.fireEvent('touched', { primId: payload?.primId, isPlayer: true }); } - sendOnTickEvent(dt) { this.tick(dt); } - sendTweenDone(payload) { this.fireEvent('tweenDone', payload); } - sendSpawnResolved(payload) { this.fireEvent('spawnResolved', payload); } - setInitialSelfPosition(_p) {} - setModules(_modules) {} -} diff --git a/src/editor/engine/RobloxLuaSharedWorker.js b/src/editor/engine/RobloxLuaSharedWorker.js deleted file mode 100644 index 55837fb..0000000 --- a/src/editor/engine/RobloxLuaSharedWorker.js +++ /dev/null @@ -1,381 +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); - 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) { - part.Touched.Fire(state.api.character?.HumanoidRootPart || part); - } - if (payload.isPlayer) { - state.api.humanoid?.Touched?.Fire?.(part); - } - } else if (kind === 'keydown' || kind === 'keyup') { - // UserInputService.InputBegan/Ended - const uis = state.api.services?.get('UserInputService') || - (() => { - const s = new (state.lua.global.get('Instance')?.new ? Object : Object)(); - return null; - })(); - if (uis) { - if (kind === 'keydown') uis.InputBegan?.Fire?.({ KeyCode: { Name: payload.key } }); - else uis.InputEnded?.Fire?.({ KeyCode: { Name: payload.key } }); - } - } else if (kind === 'humanoidDied') { - state.api.humanoid?.Died?.Fire?.(); - } else if (kind === 'humanoidHealth') { - const h = state.api.humanoid; - if (h) { - h.Health = payload.health; - h.HealthChanged?.Fire?.(payload.health); - } - } - } catch (e) { - log('warn', `event ${kind} err: ${e?.message || e}`); - } -} - -self.__rbxlSharedState = state; diff --git a/src/editor/engine/RobloxLuaWorker.js b/src/editor/engine/RobloxLuaWorker.js deleted file mode 100644 index c58b6f7..0000000 --- a/src/editor/engine/RobloxLuaWorker.js +++ /dev/null @@ -1,180 +0,0 @@ -/* eslint-disable no-restricted-globals */ -/** - * RobloxLuaWorker.js — Web Worker, хостящий Lua 5.4 VM (wasmoon) для исполнения - * Roblox-Lua скриптов импортированных через rbxl-importer. - * - * Запускается из RobloxLuaSandbox.js (main thread). - * - * IPC (с main): - * <- init { code: string, target?: id, shim: 'full'|'mini', sceneSnap: object } - * <- tick { dt, sceneSnap } — каждый кадр - * <- event { kind: 'touched'|'changed'|..., args } — события сцены - * -> boot нет payload — Worker запустился, Lua-VM ready - * -> ready нет payload — top-level lua код исполнен - * -> log { level, text } - * -> partSet { primId, prop, value } — изменение свойства Part'а - * -> partVel { primId, vx, vy, vz } - * -> playerCmd { method, args } — методы game.player (teleport, damage, walkSpeed) - * -> tweenStart{ targetId, prop, from, to, durationSec, easing } - * -> broadcast { msg, data } — RemoteEvent аналог - * -> spawn { template, props, parentId } — Instance.new() - * - * Lua-runtime архитектура: - * - wasmoon = Lua 5.4 в WebAssembly, ~500KB, ~5x быстрее fengari. - * - Lua-VM глобалы: game, workspace, script, task, wait, print, warn, error. - * - Все Roblox-классы — JS-объекты-прокси (см. roblox-shim.js, регистрируемые - * через factory.setProxy). - * - * Безопасность: - * - Worker изолирован от DOM. - * - Memory limit ~50 MB на VM (через wasmoon options). - * - На каждые N=10000 инструкций Lua hook → возможность отменить (TODO). - * - * Воркер ДЕРЖИТ И ОБНОВЛЯЕТ snapshot сцены (зеркало того что в Babylon-сцене), - * чтобы Lua-код мог читать Position/Color без round-trip к main thread. - * Обновление от main: cmd='tick' с дельтой сцены. - * - * Это первый MVP-вариант. Полный shim API регистрируется в фазе 4.3-4.13. - */ - -import { LuaFactory } from 'wasmoon'; -import { registerRobloxApi } from './roblox-shim.js'; - -/** - * Worker-side state. Один Worker = один скрипт. - */ -const state = { - factory: null, - lua: null, - target: null, // id примитива к которому привязан script.Parent - sceneSnap: { primitives: {} },// зеркало - isStopped: false, - pendingEvents: [], // события до init - signals: new Map(), // signalId → [callbacks] - nextSignalId: 1, -}; - -/* ──────── IPC helpers ──────── */ - -function send(cmd, payload) { - self.postMessage({ cmd, payload }); -} - -function log(level, text) { - send('log', { level, text }); -} - -/* ──────── Worker entrypoint ──────── */ - -self.addEventListener('message', async (ev) => { - const { cmd, payload } = ev.data || {}; - try { - if (cmd === 'init') { - await handleInit(payload); - } else if (cmd === 'tick') { - handleTick(payload); - } else if (cmd === 'event') { - handleEvent(payload); - } else if (cmd === 'stop') { - state.isStopped = true; - try { state.lua?.global?.close?.(); } catch (e) {} - } - } catch (err) { - log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`); - } -}); - -async function handleInit({ code, target, sceneSnap }) { - state.target = target; - state.sceneSnap = sceneSnap || { primitives: {} }; - - state.factory = new LuaFactory(); - state.lua = await state.factory.createEngine({ - injectObjects: true, - enableProxy: true, - traceAllocations: false, - }); - - // Регистрируем Roblox API. - registerRobloxApi(state.lua, { - getSceneSnap: () => state.sceneSnap, - targetPrimitiveId: state.target, - send, - registerSignal: (callback) => { - const id = state.nextSignalId++; - const list = state.signals.get(id) || []; - list.push(callback); - state.signals.set(id, list); - return id; - }, - }); - - send('boot', null); - - try { - // Оборачиваем в pcall + ловим errors. Roblox-карты часто делают - // game.Players.LocalPlayer:WaitForChild("PlayerGui") который у нас - // даёт null — top-level код падает на первой такой строке. - // pcall ловит и даёт сигналам/Touched зарегистрироваться там где смогли. - const wrapped = ` - local _ok, _err = pcall(function() - ${code} - end) - if not _ok then - warn("[rbxl-lua partial fail] " .. tostring(_err)) - end - `; - await state.lua.doString(wrapped); - send('ready', null); - } catch (e) { - log('error', `Lua error: ${e && e.message ? e.message : e}`); - send('ready', null); - } - - // После ready доставляем events которые накопились - for (const ev of state.pendingEvents) handleEvent(ev); - state.pendingEvents = []; -} - -function handleTick({ dt, sceneSnap }) { - if (state.isStopped || !state.lua) return; - if (sceneSnap) state.sceneSnap = sceneSnap; - // Heartbeat — для всех подписанных - fireSignalByName('Heartbeat', [dt]); - // Stepped (старая API) — тоже даём - fireSignalByName('Stepped', [dt]); - // RenderStepped — отдельно (на клиенте между physics и render) - fireSignalByName('RenderStepped', [dt]); -} - -function handleEvent({ kind, args, signalId }) { - if (!state.lua) { - state.pendingEvents.push({ kind, args, signalId }); - return; - } - if (signalId != null) { - const list = state.signals.get(signalId) || []; - for (const cb of list) { - try { cb(...(args || [])); } catch (e) { log('error', `signal callback error: ${e}`); } - } - } else { - fireSignalByName(kind, args || []); - } -} - -function fireSignalByName(name, args) { - // namedSignals регистрируются в roblox-shim как сильные строки - // (например 'Heartbeat'). Все callback'и под этим именем в signals. - // Без отдельной мапы — ищем линейно. - for (const [id, list] of state.signals.entries()) { - if (list.__name === name) { - for (const cb of list) { - try { cb(...args); } catch (e) { log('error', `${name} cb error: ${e}`); } - } - } - } -} - -/* ──────── Helper export для тестов ──────── */ - -self.__rbxlState = state; diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index 057941d..ae1ce2e 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -1,13 +1,13 @@ /** - * rbxl-lua-integration.js — single-VM интеграция (v2). + * rbxl-lua-integration.js — вспомогательные функции для импорта .rbxl-карт. * - * Двухфазная инициализация: - * 1) init worker → pre-populate workspace + GUI tree (включая сигналы) - * 2) addScriptsBatch ВСЕ скрипты одним IPC сообщением - * 3) ready → kickoff → emit PlayerAdded, начать tick + * Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные + * Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua + * (см. GameRuntime.start()). Этот файл оставлен только для: + * - unpackRobloxLuaCode() — распаковка Lua из JS-комментария-обёртки; + * - handleLuaCommand() — обработка partSet/sceneCreate/sceneDelete/playerCmd + * команд от Lua-VM в BabylonScene. */ -import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker'; -import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js'; /** Распаковка lua_source из packed-кода. */ export function unpackRobloxLuaCode(code) { @@ -80,37 +80,6 @@ export function buildLuaGuiTree(guiElements) { return out; } -/** - * Старт shared-sandbox: init → addScripts → kickoff. - */ -export function startRobloxLuaShared(scripts, ctx) { - try { - const luaScripts = []; - for (const s of scripts) { - if (!s || typeof s.code !== 'string') continue; - if (!s.code.startsWith('// @roblox-lua')) continue; - const luaSource = unpackRobloxLuaCode(s.code); - if (!luaSource) continue; - luaScripts.push({ id: s.id, target: s.target, luaSource }); - } - if (luaScripts.length === 0) return { sandbox: null, count: 0 }; - - const worker = new RobloxLuaSharedWorker(); - const sceneSnap = buildLuaSceneSnap(ctx.primitives); - const guiTree = buildLuaGuiTree(ctx.guiElements || []); - const mgr = new RobloxLuaSharedSandbox(); - mgr.setOnCommand(ctx.onCommand); - mgr.start(sceneSnap, guiTree, worker); - mgr.addScriptsBatch(luaScripts); - mgr.kickoff(); - return { sandbox: mgr, count: luaScripts.length }; - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[rbxl-lua-shared v2] start failed:', e?.message || e); - return null; - } -} - /** * Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене. */ diff --git a/src/editor/engine/roblox-physics.js b/src/editor/engine/roblox-physics.js deleted file mode 100644 index 0962898..0000000 --- a/src/editor/engine/roblox-physics.js +++ /dev/null @@ -1,216 +0,0 @@ -/** - * roblox-physics.js — эмуляция BodyMover / Constraint объектов Roblox. - * - * Roblox BodyMover'ы (старые, deprecated но массово используются): - * BodyVelocity — поддерживает заданную линейную velocity - * BodyAngularVelocity — поддерживает заданную угловую velocity - * BodyGyro — пытается удержать ориентацию (Lookat) - * BodyForce — постоянная сила - * BodyPosition — пытается удержать позицию - * BodyThrust — направленный импульс - * - * Constraint (новые): - * AlignPosition, AlignOrientation, LinearVelocity, AngularVelocity, Torque, - * VectorForce, Spring, RodConstraint, RopeConstraint, ... - * - * MVP: реализуем самые частые (BodyVelocity, BodyGyro, AlignPosition, VectorForce). - * Остальные — заглушки + warning. - * - * Архитектура: - * - Когда Lua делает `Instance.new("BodyVelocity", part)`, мы создаём RbxBodyVelocity, - * прикрепляем к Part через .Parent. - * - На каждом tick шедулера обходим активные movers и отсылаем physForce в main. - * - Main применяет к Babylon physics impostor. - */ - -import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js'; - -class RbxBodyMoverBase extends RbxInstance { - constructor(className) { - super(className, { Name: className }); - this._ctx = null; // { send, registerMover } - this.__parentPart = null; - } - /** Установить родителя и зарегистрироваться в physics-manager. */ - setMoverParent(part) { - this.Parent = part; - if (part && part.__primId != null) { - this.__parentPart = part; - this._ctx?.registerMover?.(this); - } - } -} - -export class RbxBodyVelocity extends RbxBodyMoverBase { - constructor() { - super('BodyVelocity'); - this.Velocity = new RbxVector3(0, 0, 0); - this.MaxForce = new RbxVector3(4000, 4000, 4000); - this.P = 1250; - } - _step(_dt) { - if (!this.__parentPart || !this._ctx) return; - // posVel — желаемая velocity. Применяем как setVelocity. - this._ctx.send('partVel', { - primId: this.__parentPart.__primId, - vx: this.Velocity.X, - vy: this.Velocity.Y, - vz: this.Velocity.Z, - }); - } -} - -export class RbxBodyGyro extends RbxBodyMoverBase { - constructor() { - super('BodyGyro'); - this.CFrame = null; // целевое вращение - this.MaxTorque = new RbxVector3(4000, 4000, 4000); - this.D = 500; - this.P = 3000; - } - _step(_dt) { - if (!this.__parentPart || !this._ctx || !this.CFrame) return; - const [rx, ry, rz] = this.CFrame.toEulerXYZ(); - this._ctx.send('partSet', { - primId: this.__parentPart.__primId, - prop: 'rotation', - value: { rx, ry, rz }, - }); - } -} - -export class RbxBodyPosition extends RbxBodyMoverBase { - constructor() { - super('BodyPosition'); - this.Position = new RbxVector3(0, 0, 0); - this.MaxForce = new RbxVector3(4000, 4000, 4000); - this.D = 1250; - this.P = 10000; - } - _step(_dt) { - if (!this.__parentPart || !this._ctx) return; - this._ctx.send('partSet', { - primId: this.__parentPart.__primId, - prop: 'position', - value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z }, - }); - } -} - -export class RbxBodyForce extends RbxBodyMoverBase { - constructor() { - super('BodyForce'); - this.Force = new RbxVector3(0, 0, 0); - } - _step(dt) { - if (!this.__parentPart || !this._ctx) return; - this._ctx.send('partForce', { - primId: this.__parentPart.__primId, - fx: this.Force.X * dt, fy: this.Force.Y * dt, fz: this.Force.Z * dt, - }); - } -} - -export class RbxBodyAngularVelocity extends RbxBodyMoverBase { - constructor() { - super('BodyAngularVelocity'); - this.AngularVelocity = new RbxVector3(0, 0, 0); - this.MaxTorque = new RbxVector3(4000, 4000, 4000); - } - _step(_dt) { - if (!this.__parentPart || !this._ctx) return; - this._ctx.send('partAngVel', { - primId: this.__parentPart.__primId, - wx: this.AngularVelocity.X, wy: this.AngularVelocity.Y, wz: this.AngularVelocity.Z, - }); - } -} - -/* ──────── New Constraints ──────── */ - -export class RbxAlignPosition extends RbxBodyMoverBase { - constructor() { - super('AlignPosition'); - this.Position = new RbxVector3(0, 0, 0); - this.Attachment0 = null; - this.Attachment1 = null; - this.MaxForce = 1e6; - this.Enabled = true; - } - _step(_dt) { - if (!this.Enabled || !this.__parentPart || !this._ctx) return; - this._ctx.send('partSet', { - primId: this.__parentPart.__primId, - prop: 'position', - value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z }, - }); - } -} - -export class RbxLinearVelocity extends RbxBodyMoverBase { - constructor() { - super('LinearVelocity'); - this.VectorVelocity = new RbxVector3(0, 0, 0); - this.MaxForce = 1e6; - this.Enabled = true; - } - _step(_dt) { - if (!this.Enabled || !this.__parentPart || !this._ctx) return; - this._ctx.send('partVel', { - primId: this.__parentPart.__primId, - vx: this.VectorVelocity.X, - vy: this.VectorVelocity.Y, - vz: this.VectorVelocity.Z, - }); - } -} - -/* ──────── Manager ──────── */ - -export class RobloxPhysicsManager { - constructor(send) { - this._send = send; - this._movers = new Set(); - } - - install(lua) { - const self = this; - const ctx = { - send: this._send, - registerMover: (m) => self._movers.add(m), - }; - - // Подменяем Instance.new для физических классов - const origInstance = lua.global.get('Instance'); - lua.global.set('Instance', { - new: (className, parent) => { - let inst = null; - switch (className) { - case 'BodyVelocity': inst = new RbxBodyVelocity(); break; - case 'BodyGyro': inst = new RbxBodyGyro(); break; - case 'BodyPosition': inst = new RbxBodyPosition(); break; - case 'BodyForce': inst = new RbxBodyForce(); break; - case 'BodyAngularVelocity':inst = new RbxBodyAngularVelocity(); break; - case 'AlignPosition': inst = new RbxAlignPosition(); break; - case 'LinearVelocity': inst = new RbxLinearVelocity(); break; - } - if (inst) { - inst._ctx = ctx; - if (parent) { - inst.setMoverParent(parent); - if (parent.Children) parent.Children.push(inst); - } - return inst; - } - return origInstance.new(className, parent); - }, - }); - } - - tick(dt) { - for (const m of [...this._movers]) { - if (m.__destroyed || !m.__parentPart) { this._movers.delete(m); continue; } - try { m._step(dt); } catch (e) {} - } - } -} diff --git a/src/editor/engine/roblox-scheduler.js b/src/editor/engine/roblox-scheduler.js deleted file mode 100644 index 936c181..0000000 --- a/src/editor/engine/roblox-scheduler.js +++ /dev/null @@ -1,209 +0,0 @@ -/** - * roblox-scheduler.js — шедулер корутин для Roblox-Lua wait/task. - * - * Архитектура: - * - Каждый верхне-уровневый Lua-код оборачивается в coroutine. - * - wait(sec) / task.wait(sec) делают coroutine.yield(sec) - * - Шедулер запоминает: { coro, resumeAt: tick + sec } - * - На каждом handleTick из main thread шедулер ресюмит готовые корутины - * - * RBXScriptSignal.Wait() = аналогично, но wait не на время, а на event'е: - * - { coro, waitingForSignal: signalName } - * - При Fire() сигнала шедулер ресюмит все ждущие - * - * Использование: - * const sched = new RobloxScheduler(luaEngine); - * sched.spawnMain(luaSource); - * // Каждый кадр: - * sched.tick(dtSec); - * // При событии: - * sched.fireSignal('Heartbeat', dt); - */ - -export class RobloxScheduler { - constructor(lua) { - this.lua = lua; - this.time = 0; - this.tasks = []; // [{ coro, resumeAt, waitForSignal?, signalArgsBuf? }] - this.signalWaiters = new Map(); // name → [task] - this._coroBox = null; - } - - /** - * Регистрирует глобалы wait/task.wait/task.spawn/task.delay в Lua-VM. - * Должно вызываться ПОСЛЕ registerRobloxApi (т.к. перебивает заглушки). - */ - install() { - const self = this; - // wait(sec) — yield в корутине на sec секунд - this.lua.global.set('wait', (sec) => { - // Этот wait вызовется из Lua. Если мы внутри корутины (а мы внутри - // т.к. spawnMain обернул всё) — yield. Иначе вернём дельту времени - // как обычное wait в Roblox. - const s = +sec || 0; - self._currentYield = { kind: 'sleep', sec: s }; - // Возврат тут — это значение которое получит await в Lua; - // wasmoon обработает yield извне. - return s; - }); - this.lua.global.set('task', { - wait: (sec) => { - self._currentYield = { kind: 'sleep', sec: +sec || 0 }; - return +sec || 0; - }, - spawn: (fn, ...args) => { - self.spawnCoroutine(fn, args); - }, - delay: (sec, fn, ...args) => { - self.tasks.push({ - resumeAt: self.time + (+sec || 0), - runFn: () => { try { fn(...args); } catch (e) {} }, - }); - }, - defer: (fn, ...args) => { - self.tasks.push({ - resumeAt: self.time, - runFn: () => { try { fn(...args); } catch (e) {} }, - }); - }, - }); - this.lua.global.set('spawn', (fn) => { self.spawnCoroutine(fn, []); }); - this.lua.global.set('delay', (sec, fn) => { - self.tasks.push({ - resumeAt: self.time + (+sec || 0), - runFn: () => { try { fn(); } catch (e) {} }, - }); - }); - } - - /** - * Запустить верхне-уровневый Lua-код как корутину. - * Возвращает Promise который резолвится когда код достиг ready (либо ушёл в первый yield). - */ - async spawnMain(luaSource) { - // Оборачиваем источник в coroutine.wrap(function() ... end) - // и сразу зовём — это даёт нам ручку на корутине через специальный - // приём: храним её в global _userCoro. - const wrapped = ` - _userCoro = coroutine.create(function() - ${luaSource} - end) - local ok, yieldVal = coroutine.resume(_userCoro) - if not ok then - error("user script error: " .. tostring(yieldVal)) - end - return yieldVal - `; - try { - await this.lua.doString(wrapped); - const coroStatus = await this.lua.doString('return coroutine.status(_userCoro)'); - if (coroStatus === 'suspended') { - // Ушла в yield — добавляем в шедулер - const yieldInfo = this._currentYield || { kind: 'sleep', sec: 0 }; - this._currentYield = null; - this.tasks.push({ - resumeAt: this.time + (yieldInfo.kind === 'sleep' ? yieldInfo.sec : 0), - waitForSignal: yieldInfo.kind === 'signal' ? yieldInfo.name : null, - coro: '_userCoro', - }); - } - } catch (e) { - console.warn('spawnMain error:', e); - } - } - - /** - * Запустить произвольную функцию как корутину (для task.spawn). - */ - spawnCoroutine(fn, args) { - // Создаём корутину на JS-стороне: просто вызываем fn() сразу, - // а если внутри неё дёрнут wait — yield не сработает (JS не делает - // sync yield в обычной функции). Поэтому task.spawn для JS-функций - // равен прямому вызову. - // В будущем (4.7.1) можно через Lua coroutine реализовать. - try { fn(...(args || [])); } catch (e) { /* swallow */ } - } - - /** - * Продвинуть время на dt и резюмить готовые корутины. - * Также автоматически fire'ит RunService.Heartbeat / Stepped / RenderStepped. - */ - async tick(dtSec) { - const dt = +dtSec || 0; - this.time += dt; - // Heartbeat / Stepped / RenderStepped для RunService - const game = this.lua.global.get('game'); - if (game && typeof game.GetService === 'function') { - const rs = game.GetService('RunService'); - if (rs) { - if (rs.Heartbeat && rs.Heartbeat.Fire) rs.Heartbeat.Fire(dt); - if (rs.Stepped && rs.Stepped.Fire) rs.Stepped.Fire(this.time, dt); - if (rs.RenderStepped && rs.RenderStepped.Fire) rs.RenderStepped.Fire(dt); - } - } - // Резюмим всё что готово - const ready = this.tasks.filter(t => !t.waitForSignal && t.resumeAt <= this.time); - this.tasks = this.tasks.filter(t => !(ready.includes(t))); - for (const t of ready) { - await this._resumeTask(t); - } - } - - /** - * Fire signal — разбудить все task'и ждущие этого сигнала. - */ - async fireSignal(name, ...args) { - const waiters = this.signalWaiters.get(name) || []; - this.signalWaiters.set(name, []); - for (const t of waiters) { - // Resume корутины передавая args как возврат :Wait() - await this._resumeTask(t, args); - } - } - - async _resumeTask(task, resumeArgs = []) { - if (task.runFn) { - try { - const ret = task.runFn(); - if (ret && typeof ret.then === 'function') await ret; - } catch (e) {} - return; - } - if (task.coro) { - try { - // resumeArgs идут как аргументы в coroutine.resume - const argsCode = resumeArgs.map((a, i) => { - if (typeof a === 'number') return String(a); - if (typeof a === 'string') return JSON.stringify(a); - return 'nil'; - }).join(', '); - const code = ` - local ok, val = coroutine.resume(${task.coro}${argsCode ? ', ' + argsCode : ''}) - if not ok then - error("coro error: " .. tostring(val)) - end - return val - `; - await this.lua.doString(code); - const status = await this.lua.doString(`return coroutine.status(${task.coro})`); - if (status === 'suspended') { - // Опять ушла в yield - const yi = this._currentYield || { kind: 'sleep', sec: 0 }; - this._currentYield = null; - if (yi.kind === 'sleep') { - this.tasks.push({ - resumeAt: this.time + yi.sec, - coro: task.coro, - }); - } else if (yi.kind === 'signal') { - const list = this.signalWaiters.get(yi.name) || []; - list.push({ coro: task.coro }); - this.signalWaiters.set(yi.name, list); - } - } - } catch (e) { - // Корутина завершилась с ошибкой — просто дропаем - } - } - } -} diff --git a/src/editor/engine/roblox-services.js b/src/editor/engine/roblox-services.js deleted file mode 100644 index 8ffbfba..0000000 --- a/src/editor/engine/roblox-services.js +++ /dev/null @@ -1,384 +0,0 @@ -/** - * roblox-services.js — расширения Roblox-API для сервисов: - * Players / Humanoid / UserInputService / RemoteEvent / RemoteFunction - * / DataStoreService / HttpService. - * - * Регистрируется ПОСЛЕ registerRobloxApi (см. roblox-shim.js). - * - * Поведение: - * - Players.LocalPlayer.Character.Humanoid.Health, WalkSpeed, JumpPower - * мапятся на game.player.* в Rublox через `playerCmd` IPC. - * - UserInputService.InputBegan/InputEnded — пробрасываются из main - * по событию через fireEvent. - * - RemoteEvent:FireServer/FireClient → broadcast. - * - DataStoreService:GetDataStore → game.save. - */ - -import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js'; - -/* ──────── Humanoid ──────── */ - -class RbxHumanoid extends RbxInstance { - constructor(ctx) { - super('Humanoid', { Name: 'Humanoid' }); - this._ctx = ctx; // { send, getPlayerState } - this._snap = { - Health: 100, - MaxHealth: 100, - WalkSpeed: 16, - JumpPower: 50, - JumpHeight: 7.2, - HipHeight: 0, - HumanoidStateType: 'GettingUp', - PlatformStand: false, - }; - this.Died = new RbxSignal('Died'); - this.HealthChanged = new RbxSignal('HealthChanged'); - this.Touched = new RbxSignal('Touched'); - this.Running = new RbxSignal('Running'); - this.Jumping = new RbxSignal('Jumping'); - this.StateChanged = new RbxSignal('StateChanged'); - } - - get Health() { return this._snap.Health; } - set Health(v) { - const old = this._snap.Health; - const nv = Math.max(0, +v || 0); - this._snap.Health = nv; - if (nv !== old) this.HealthChanged.Fire(nv); - if (nv <= 0 && old > 0) { - this.Died.Fire(); - this._ctx.send?.('playerCmd', { method: 'die', args: [] }); - } else { - this._ctx.send?.('playerCmd', { method: 'setHealth', args: [nv] }); - } - } - get MaxHealth() { return this._snap.MaxHealth; } - set MaxHealth(v) { - this._snap.MaxHealth = +v || 100; - this._ctx.send?.('playerCmd', { method: 'setMaxHealth', args: [this._snap.MaxHealth] }); - } - get WalkSpeed() { return this._snap.WalkSpeed; } - set WalkSpeed(v) { - this._snap.WalkSpeed = +v || 0; - this._ctx.send?.('playerCmd', { method: 'setWalkSpeed', args: [this._snap.WalkSpeed] }); - } - get JumpPower() { return this._snap.JumpPower; } - set JumpPower(v) { - this._snap.JumpPower = +v || 0; - this._ctx.send?.('playerCmd', { method: 'setJumpPower', args: [this._snap.JumpPower] }); - } - get JumpHeight() { return this._snap.JumpHeight; } - set JumpHeight(v) { - this._snap.JumpHeight = +v || 0; - this._ctx.send?.('playerCmd', { method: 'setJumpHeight', args: [this._snap.JumpHeight] }); - } - get PlatformStand() { return !!this._snap.PlatformStand; } - set PlatformStand(v) { - this._snap.PlatformStand = !!v; - this._ctx.send?.('playerCmd', { method: 'setPlatformStand', args: [!!v] }); - } - TakeDamage(amount) { - this.Health = Math.max(0, this.Health - (+amount || 0)); - } - Move(direction, relative) { - if (direction instanceof RbxVector3) { - this._ctx.send?.('playerCmd', { - method: 'move', - args: [{ x: direction.X, y: direction.Y, z: direction.Z }, !!relative], - }); - } - } - Jump() { - this._ctx.send?.('playerCmd', { method: 'jump', args: [] }); - } - LoadAnimation(animation) { - // Animation объект — content rbxassetid. Возвращаем animation-track stub. - const aid = animation?.AnimationId || ''; - return { - AnimationId: aid, - Length: 0, - IsPlaying: false, - Looped: false, - Play: () => this._ctx.send?.('playerCmd', { method: 'playAnim', args: [aid] }), - Stop: () => this._ctx.send?.('playerCmd', { method: 'stopAnim', args: [aid] }), - AdjustSpeed: (s) => this._ctx.send?.('playerCmd', { method: 'animSpeed', args: [aid, s] }), - GetTimeOfKeyframe: () => 0, - KeyframeReached: new RbxSignal('KeyframeReached'), - }; - } - ChangeState(state) { - this._snap.HumanoidStateType = state; - this.StateChanged.Fire(state); - } - SetStateEnabled(_state, _enabled) { /* noop */ } - GetState() { return this._snap.HumanoidStateType; } -} - -/* ──────── Character / Player ──────── */ - -class RbxCharacter extends RbxInstance { - constructor(ctx) { - super('Model', { Name: 'Character' }); - // HumanoidRootPart — это «Position персонажа» - this.HumanoidRootPart = new RbxInstance('Part', { Name: 'HumanoidRootPart', Parent: this }); - // mock Position через getter — берём текущую позицию из ctx - Object.defineProperty(this.HumanoidRootPart, 'Position', { - get: () => { - const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 }; - return new RbxVector3(p.x, p.y, p.z); - }, - set: (v) => { - if (v instanceof RbxVector3) { - ctx.send?.('playerCmd', { method: 'teleport', args: [v.X, v.Y, v.Z] }); - } - }, - }); - Object.defineProperty(this.HumanoidRootPart, 'CFrame', { - get: () => { - const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 }; - return { X: p.x, Y: p.y, Z: p.z, p: { X: p.x, Y: p.y, Z: p.z } }; - }, - set: (v) => { - if (v && typeof v === 'object') { - ctx.send?.('playerCmd', { method: 'teleport', args: [v.X || 0, v.Y || 0, v.Z || 0] }); - } - }, - }); - this.Children.push(this.HumanoidRootPart); - this.Humanoid = new RbxHumanoid(ctx); - this.Humanoid.Parent = this; - this.Children.push(this.Humanoid); - } -} - -class RbxPlayer extends RbxInstance { - constructor(ctx) { - super('Player', { Name: 'Player' }); - this.UserId = 1; - this.DisplayName = 'Player'; - this.Character = new RbxCharacter(ctx); - this.CharacterAdded = new RbxSignal('CharacterAdded'); - this.CharacterRemoving = new RbxSignal('CharacterRemoving'); - // На MVP — характер уже создан. - setTimeout(() => this.CharacterAdded.Fire(this.Character), 0); - this.leaderstats = new RbxInstance('Folder', { Name: 'leaderstats', Parent: this }); - this.Children.push(this.leaderstats); - } - GetMouse() { - return { Hit: { Position: new RbxVector3(0, 0, 0) }, Target: null, - Button1Down: new RbxSignal('Button1Down'), Move: new RbxSignal('Move') }; - } - Kick(reason) { - // в нашем плеере — просто log - return reason; - } -} - -/* ──────── UserInputService ──────── */ - -class RbxUserInputService extends RbxInstance { - constructor() { - super('UserInputService', { Name: 'UserInputService' }); - this.InputBegan = new RbxSignal('InputBegan'); - this.InputEnded = new RbxSignal('InputEnded'); - this.InputChanged = new RbxSignal('InputChanged'); - this.JumpRequest = new RbxSignal('JumpRequest'); - this.KeyboardEnabled = true; - this.MouseEnabled = true; - this.TouchEnabled = false; - } - GetMouseLocation() { return { X: 0, Y: 0 }; } - IsKeyDown(_keyCode) { return false; } // в MVP всегда false -} - -/* ──────── RemoteEvent / RemoteFunction ──────── */ - -class RbxRemoteEvent extends RbxInstance { - constructor(ctx) { - super('RemoteEvent', { Name: 'RemoteEvent' }); - this._ctx = ctx; - this.OnServerEvent = new RbxSignal('OnServerEvent'); - this.OnClientEvent = new RbxSignal('OnClientEvent'); - } - FireServer(...args) { - // singleplayer: server == client, просто отдаём в OnServerEvent - this.OnServerEvent.Fire(this._ctx.localPlayer, ...args); - this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args }); - } - FireClient(_player, ...args) { - this.OnClientEvent.Fire(...args); - this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args }); - } - FireAllClients(...args) { - this.OnClientEvent.Fire(...args); - this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args }); - } -} - -class RbxRemoteFunction extends RbxInstance { - constructor(ctx) { - super('RemoteFunction', { Name: 'RemoteFunction' }); - this._ctx = ctx; - this.OnServerInvoke = null; // function(player, ...args) → result - } - InvokeServer(...args) { - if (typeof this.OnServerInvoke === 'function') { - try { return this.OnServerInvoke(this._ctx.localPlayer, ...args); } catch (e) {} - } - return null; - } - InvokeClient(_player, ...args) { - if (typeof this.OnClientInvoke === 'function') { - try { return this.OnClientInvoke(...args); } catch (e) {} - } - return null; - } -} - -/* ──────── DataStoreService ──────── */ - -class RbxDataStore { - constructor(name, ctx) { - this.name = name; - this._ctx = ctx; - } - GetAsync(key) { - try { - const data = this._ctx.loadSave?.(this.name + ':' + key); - return data ?? null; - } catch (e) { return null; } - } - SetAsync(key, value) { - this._ctx.saveSave?.(this.name + ':' + key, value); - return value; - } - UpdateAsync(key, updaterFn) { - const cur = this.GetAsync(key); - const next = updaterFn(cur); - if (next !== undefined) this.SetAsync(key, next); - return next; - } - IncrementAsync(key, delta) { - const cur = +this.GetAsync(key) || 0; - const next = cur + (+delta || 1); - this.SetAsync(key, next); - return next; - } - RemoveAsync(key) { - this._ctx.removeSave?.(this.name + ':' + key); - } -} - -class RbxDataStoreService extends RbxInstance { - constructor(ctx) { - super('DataStoreService', { Name: 'DataStoreService' }); - this._ctx = ctx; - this._stores = new Map(); - } - GetDataStore(name) { - if (!this._stores.has(name)) this._stores.set(name, new RbxDataStore(name, this._ctx)); - return this._stores.get(name); - } - GetGlobalDataStore() { return this.GetDataStore('__global__'); } - GetOrderedDataStore(name) { return this.GetDataStore('ordered:' + name); } -} - -/* ──────── HttpService ──────── */ - -class RbxHttpService extends RbxInstance { - constructor(ctx) { - super('HttpService', { Name: 'HttpService' }); - this._ctx = ctx; - this.HttpEnabled = false; // в нашем плеере по дефолту выкл, безопаснее - } - GenerateGUID(wrap) { - const c = () => Math.random().toString(16).slice(2, 6); - const guid = `${c()}${c()}-${c()}-${c()}-${c()}-${c()}${c()}${c()}`.toUpperCase(); - return wrap === false ? guid : `{${guid}}`; - } - JSONEncode(value) { try { return JSON.stringify(value); } catch (e) { return ''; } } - JSONDecode(s) { try { return JSON.parse(s); } catch (e) { return null; } } - GetAsync(url) { - // CORS / sandbox: блокируем в MVP, возвращаем заглушку - this._ctx.send?.('log', { level: 'warn', text: `HttpService:GetAsync(${url}) blocked in MVP` }); - return ''; - } - PostAsync(url) { - this._ctx.send?.('log', { level: 'warn', text: `HttpService:PostAsync(${url}) blocked in MVP` }); - return ''; - } -} - -/* ──────── install ──────── */ - -export function installRobloxServices(lua, ctx) { - // ctx: { send, getPlayerState, getSnapPlayer, loadSave, saveSave, removeSave } - const game = lua.global.get('game'); - if (!game) return; - - // Создаём LocalPlayer - const player = new RbxPlayer({ - send: ctx.send, - getPlayerState: ctx.getPlayerState, - }); - - // Players service апгрейдим - const players = game.GetService('Players'); - if (players) { - players.LocalPlayer = player; - // GetPlayers / GetPlayerFromCharacter - players.GetPlayers = () => [player]; - players.GetPlayerFromCharacter = (c) => (c === player.Character ? player : null); - } - - // UserInputService - const uis = new RbxUserInputService(); - // RemoteEvent / DataStoreService / HttpService — выдаются через GetService - const dss = new RbxDataStoreService({ - loadSave: ctx.loadSave, - saveSave: ctx.saveSave, - removeSave: ctx.removeSave, - }); - const httpSvc = new RbxHttpService({ send: ctx.send }); - - // Подмена GetService — добавляем наши новые сервисы - const origGetService = game.GetService; - game.GetService = function(svc) { - if (svc === 'UserInputService') return uis; - if (svc === 'DataStoreService') return dss; - if (svc === 'HttpService') return httpSvc; - // ContextActionService — стаб - if (svc === 'ContextActionService') { - return { - ClassName: 'ContextActionService', Name: 'ContextActionService', - BindAction: (_name, fn, _gui, ...keys) => { /* в MVP — игнор */ }, - UnbindAction: () => {}, - }; - } - return origGetService.call(this, svc); - }; - - // Instance.new('RemoteEvent') / 'RemoteFunction' — переопределяем фабрику - const origInstance = lua.global.get('Instance'); - lua.global.set('Instance', { - new: (className, parent) => { - if (className === 'RemoteEvent') { - const r = new RbxRemoteEvent({ send: ctx.send, localPlayer: player }); - if (parent) { r.Parent = parent; parent.Children.push(r); } - return r; - } - if (className === 'RemoteFunction') { - const r = new RbxRemoteFunction({ send: ctx.send, localPlayer: player }); - if (parent) { r.Parent = parent; parent.Children.push(r); } - return r; - } - return origInstance.new(className, parent); - }, - }); - - return { player, uis, dss, httpSvc }; -} - -export { RbxHumanoid, RbxCharacter, RbxPlayer, RbxUserInputService, - RbxRemoteEvent, RbxRemoteFunction, RbxDataStoreService, RbxHttpService }; diff --git a/src/editor/engine/roblox-shim.js b/src/editor/engine/roblox-shim.js deleted file mode 100644 index 362b0bb..0000000 --- a/src/editor/engine/roblox-shim.js +++ /dev/null @@ -1,715 +0,0 @@ -/** - * roblox-shim.js — регистрация Roblox API внутри Lua-VM (wasmoon). - * - * Используется из RobloxLuaWorker.js. Регистрирует глобалы: - * - game, workspace, script ← Instance-прокси - * - Vector3, Color3, CFrame, UDim, UDim2 ← конструкторы математических классов - * - Instance.new(class) ← фабрика - * - wait, task, tick, os, print, warn ← стандартные глобалы - * - Enum ← enum-таблица - * - * Архитектура: - * - JS-классы (RbxVector3, RbxCFrame, ...) — обычные дата-объекты с - * перегруженными методами. - * - Instance — прокси-объект который хранит { className, properties, children, parent }. - * Геттеры/сеттеры эмулируются через __index/__newindex (mt в wasmoon). - * - RBXScriptSignal — JS-объект с Connect/Wait/Disconnect. - * - * Sandbox-side: при изменении Part.Position и т.п. отсылаем в main thread - * `partSet` → main применит к Babylon-сцене. - */ - -/* ──────── Math classes ──────── */ - -class RbxVector3 { - constructor(x, y, z) { - this.X = +x || 0; - this.Y = +y || 0; - this.Z = +z || 0; - } - get Magnitude() { - return Math.sqrt(this.X*this.X + this.Y*this.Y + this.Z*this.Z); - } - get Unit() { - const m = this.Magnitude || 1; - return new RbxVector3(this.X / m, this.Y / m, this.Z / m); - } - Dot(o) { return this.X*o.X + this.Y*o.Y + this.Z*o.Z; } - Cross(o) { - return new RbxVector3( - this.Y*o.Z - this.Z*o.Y, - this.Z*o.X - this.X*o.Z, - this.X*o.Y - this.Y*o.X, - ); - } - Lerp(o, alpha) { - return new RbxVector3( - this.X + (o.X - this.X) * alpha, - this.Y + (o.Y - this.Y) * alpha, - this.Z + (o.Z - this.Z) * alpha, - ); - } - add(o) { return new RbxVector3(this.X + o.X, this.Y + o.Y, this.Z + o.Z); } - sub(o) { return new RbxVector3(this.X - o.X, this.Y - o.Y, this.Z - o.Z); } - mul(scalar) { - if (typeof scalar === 'number') { - return new RbxVector3(this.X * scalar, this.Y * scalar, this.Z * scalar); - } - return new RbxVector3(this.X * scalar.X, this.Y * scalar.Y, this.Z * scalar.Z); - } - toString() { return `${this.X}, ${this.Y}, ${this.Z}`; } -} - -class RbxColor3 { - constructor(r, g, b) { - this.R = +r || 0; - this.G = +g || 0; - this.B = +b || 0; - } - static fromRGB(r, g, b) { - return new RbxColor3((r||0)/255, (g||0)/255, (b||0)/255); - } - static fromHex(hex) { - const h = String(hex || '#000000').replace('#',''); - return new RbxColor3( - parseInt(h.slice(0,2), 16)/255, - parseInt(h.slice(2,4), 16)/255, - parseInt(h.slice(4,6), 16)/255, - ); - } - Lerp(o, alpha) { - return new RbxColor3( - this.R + (o.R - this.R) * alpha, - this.G + (o.G - this.G) * alpha, - this.B + (o.B - this.B) * alpha, - ); - } - toHex() { - const h = (n) => Math.max(0, Math.min(255, Math.round(n * 255))).toString(16).padStart(2, '0'); - return `#${h(this.R)}${h(this.G)}${h(this.B)}`; - } - toString() { return `${this.R}, ${this.G}, ${this.B}`; } -} - -class RbxCFrame { - constructor(x, y, z, r00=1, r01=0, r02=0, r10=0, r11=1, r12=0, r20=0, r21=0, r22=1) { - this.X = +x || 0; this.Y = +y || 0; this.Z = +z || 0; - // Row-major 3x3 - this.r00 = r00; this.r01 = r01; this.r02 = r02; - this.r10 = r10; this.r11 = r11; this.r12 = r12; - this.r20 = r20; this.r21 = r21; this.r22 = r22; - } - static new(x, y, z) { - if (x instanceof RbxVector3) return new RbxCFrame(x.X, x.Y, x.Z); - return new RbxCFrame(x || 0, y || 0, z || 0); - } - static Angles(rx, ry, rz) { - // Euler XYZ → 3x3 (intrinsic) - const cx = Math.cos(rx), sx = Math.sin(rx); - const cy = Math.cos(ry), sy = Math.sin(ry); - const cz = Math.cos(rz), sz = Math.sin(rz); - // R = Rx * Ry * Rz - const r00 = cy*cz, r01 = -cy*sz, r02 = sy; - const r10 = sx*sy*cz + cx*sz, r11 = -sx*sy*sz + cx*cz, r12 = -sx*cy; - const r20 = -cx*sy*cz + sx*sz, r21 = cx*sy*sz + sx*cz, r22 = cx*cy; - return new RbxCFrame(0, 0, 0, r00, r01, r02, r10, r11, r12, r20, r21, r22); - } - static fromEulerAnglesXYZ(rx, ry, rz) { return RbxCFrame.Angles(rx, ry, rz); } - get Position() { return new RbxVector3(this.X, this.Y, this.Z); } - get LookVector() { return new RbxVector3(-this.r02, -this.r12, -this.r22); } - get RightVector() { return new RbxVector3(this.r00, this.r10, this.r20); } - get UpVector() { return new RbxVector3(this.r01, this.r11, this.r21); } - Lerp(o, a) { - // Линейная интерполяция (без правильного slerp на матрицах — для MVP сойдёт) - return new RbxCFrame( - this.X + (o.X - this.X) * a, - this.Y + (o.Y - this.Y) * a, - this.Z + (o.Z - this.Z) * a, - this.r00, this.r01, this.r02, - this.r10, this.r11, this.r12, - this.r20, this.r21, this.r22, - ); - } - Inverse() { - // Транспонируем 3x3 (для rotation matrix Inverse == Transpose) - return new RbxCFrame( - -this.X, -this.Y, -this.Z, - this.r00, this.r10, this.r20, - this.r01, this.r11, this.r21, - this.r02, this.r12, this.r22, - ); - } - toEulerXYZ() { - const rx = Math.atan2(this.r21, this.r22); - const ry = Math.atan2(-this.r20, Math.sqrt(this.r21*this.r21 + this.r22*this.r22)); - const rz = Math.atan2(this.r10, this.r00); - return [rx, ry, rz]; - } - toString() { return `${this.X}, ${this.Y}, ${this.Z}`; } -} - -class RbxUDim { - constructor(scale, offset) { this.Scale = +scale || 0; this.Offset = +offset | 0; } - toString() { return `${this.Scale}, ${this.Offset}`; } -} - -class RbxUDim2 { - constructor(xs, xo, ys, yo) { - this.X = new RbxUDim(xs, xo); - this.Y = new RbxUDim(ys, yo); - } - static new(xs, xo, ys, yo) { return new RbxUDim2(xs, xo, ys, yo); } - static fromScale(xs, ys) { return new RbxUDim2(xs, 0, ys, 0); } - static fromOffset(xo, yo) { return new RbxUDim2(0, xo, 0, yo); } - toString() { return `${this.X.Scale}, ${this.X.Offset}, ${this.Y.Scale}, ${this.Y.Offset}`; } -} - -/* ──────── RBXScriptSignal ──────── */ - -let _signalIdCounter = 1000; - -class RbxSignal { - constructor(name) { - this.name = name; - this.id = _signalIdCounter++; - this.connections = []; - } - Connect(callback) { - const conn = { callback, connected: true }; - this.connections.push(conn); - return { - Disconnect: () => { conn.connected = false; }, - disconnect: () => { conn.connected = false; }, - Connected: () => conn.connected, - }; - } - // Legacy Roblox API — lowercase alias - connect(callback) { return this.Connect(callback); } - Wait() { return null; } - wait() { return null; } - Fire(...args) { - for (const c of this.connections) { - if (!c.connected) continue; - try { c.callback(...args); } catch (e) { /* swallow */ } - } - } - fire(...args) { return this.Fire(...args); } -} - -/* ──────── Instance прокси ──────── */ - -let _instanceCounter = 1; - -// Null-stub: возвращается из FindFirstChild/WaitForChild когда объект не найден. -// Имеет все методы Instance как no-op, чтобы Lua-цепочки вроде -// game.Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("X").Touched:Connect(fn) -// не падали с "attempt to call js_null", когда промежуточный объект не существует. -// Все методы возвращают сам _nullStub чтобы цепочка не разорвалась. -// nullSignal: callable proxy. Lua делает x:Connect(fn) = x.Connect(x, fn), -// но также pattern signal:Connect(fn) сначала достаёт signal.Connect (это функция), -// потом вызывает её. Мы возвращаем функцию которая безусловно возвращает {Disconnect}. -const _nullConn = { Disconnect: () => {}, disconnect: () => {}, Connected: false }; -const _nullSignalFn = () => _nullConn; -const _nullSignal = new Proxy(_nullSignalFn, { - get(_, k) { - if (k === 'Connect' || k === 'connect') return _nullSignalFn; - if (k === 'Wait' || k === 'wait') return () => null; - if (k === 'Fire' || k === 'fire') return () => {}; - return undefined; - }, -}); -// Известные имена сигналов (Touched, Changed, MouseButton1Click, ...) -const _SIGNAL_NAMES = new Set([ - 'Touched','TouchEnded','Changed','Activated', - 'MouseButton1Click','MouseButton1Down','MouseButton1Up', - 'MouseButton2Click','MouseButton2Down','MouseButton2Up', - 'MouseEnter','MouseLeave','InputBegan','InputEnded','InputChanged', - 'PlayerAdded','PlayerRemoving','CharacterAdded','CharacterRemoving', - 'Heartbeat','Stepped','RenderStepped','Died','HealthChanged', - 'FocusLost','Focused','ChildAdded','ChildRemoved', - 'AncestryChanged','DescendantAdded','DescendantRemoving', - // Tool сигналы - 'Equipped','Unequipped','Selected','Deselected', - // прочие популярные - 'OnInvoke','OnServerInvoke','OnClientInvoke', - 'OnServerEvent','OnClientEvent','Fired','Triggered', - 'ChatMakeSystemMessage','ChatMade', -]); -// _makeDeepStub — рекурсивный proxy которому всё равно сколько раз его -// индексируют. На любом уровне: -// - caps-имя из _SIGNAL_NAMES → возвращает _nullSignal -// - 'Parent' → возвращает _nullStub -// - любое другое имя → callable proxy + рекурсивная глубина -// Это позволяет цепочкам типа `tool.Selected:Connect(fn)` или -// `script.Parent.Parent.Frame.Visible` молча no-op'аться. -// Вместо JS Proxy (который wasmoon оборачивает в js_promise) — используем -// специальный маркер. Реальный stub живёт на Lua-стороне. -const NULL_STUB_MARKER = { __isNullStubMarker: true }; -function _makeDeepStub() { return NULL_STUB_MARKER; } -const _nullStubBase = { __isNullStub: true, ClassName: 'Nil', Name: 'Nil', Children: [], Value: 0, Text: '', Visible: false }; -// _nullStub оставлен как маркер, но не используется как реальный stub — -// debug.setmetatable(nil) в Lua перехватывает всё это. -const _nullStub = _nullStubBase; - -class RbxInstance { - constructor(className, init = {}) { - this.__id = _instanceCounter++; - this.ClassName = className; - this.Name = init.Name || className; - this.Parent = init.Parent || null; - this.Children = []; - this.__props = {}; // raw properties (для Position и т.п.) - // Signals доступны как прямые свойства, плюс дублируются в __signals для serv-кода - this.Touched = new RbxSignal('Touched'); - this.TouchEnded = new RbxSignal('TouchEnded'); - this.Changed = new RbxSignal('Changed'); - this.AncestryChanged = new RbxSignal('AncestryChanged'); - this.ChildAdded = new RbxSignal('ChildAdded'); - this.ChildRemoved = new RbxSignal('ChildRemoved'); - this.__signals = { - Touched: this.Touched, - TouchEnded: this.TouchEnded, - Changed: this.Changed, - AncestryChanged: this.AncestryChanged, - ChildAdded: this.ChildAdded, - ChildRemoved: this.ChildRemoved, - }; - this.__sceneState = null; - } - - GetChildren() { return [...this.Children]; } - GetDescendants() { - const out = []; - const walk = (n) => { - for (const c of n.Children) { out.push(c); walk(c); } - }; - walk(this); - return out; - } - FindFirstChild(name, recursive) { - for (const c of this.Children) { - if (c.Name === name) return c; - if (recursive) { - const found = c.FindFirstChild(name, true); - if (found) return found; - } - } - // Возвращаем undefined — wasmoon отдаст это как nil. - // Lua-side debug.setmetatable(nil) перехватит дальнейшую индексацию. - return undefined; - } - FindFirstChildOfClass(className) { - for (const c of this.Children) { - if (c.ClassName === className) return c; - } - return undefined; - } - FindFirstAncestor(name) { - let p = this.Parent; - while (p) { - if (p.Name === name) return p; - p = p.Parent; - } - return undefined; - } - WaitForChild(name, _timeout) { - // В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать. - return this.FindFirstChild(name); - } - IsA(className) { - if (this.ClassName === className) return true; - // Roblox class hierarchy: Part isA BasePart isA PVInstance isA Instance. - const hierarchy = { - 'Part': ['BasePart', 'PVInstance', 'Instance'], - 'WedgePart': ['BasePart', 'PVInstance', 'Instance'], - 'CornerWedgePart': ['BasePart', 'PVInstance', 'Instance'], - 'MeshPart': ['BasePart', 'PVInstance', 'Instance'], - 'UnionOperation': ['PartOperation', 'BasePart', 'PVInstance', 'Instance'], - 'TrussPart': ['BasePart', 'PVInstance', 'Instance'], - 'SpawnLocation': ['Part', 'BasePart', 'PVInstance', 'Instance'], - 'Script': ['BaseScript', 'LuaSourceContainer', 'Instance'], - 'LocalScript': ['BaseScript', 'LuaSourceContainer', 'Instance'], - 'ModuleScript': ['LuaSourceContainer', 'Instance'], - 'Folder': ['Instance'], - 'Model': ['PVInstance', 'Instance'], - 'Sound': ['Instance'], - 'PointLight': ['Light', 'Instance'], - 'SpotLight': ['Light', 'Instance'], - 'Humanoid': ['Instance'], - }; - const ancestors = hierarchy[this.ClassName] || []; - return ancestors.includes(className); - } - Destroy() { - if (this.Parent && this.Parent.Children) { - const idx = this.Parent.Children.indexOf(this); - if (idx >= 0) this.Parent.Children.splice(idx, 1); - } - this.Parent = null; - this.__destroyed = true; - } - Clone() { - const cl = new RbxInstance(this.ClassName); - cl.Name = this.Name; - cl.__props = JSON.parse(JSON.stringify(this.__props)); - for (const c of this.Children) { - const cc = c.Clone(); - cc.Parent = cl; - cl.Children.push(cc); - } - return cl; - } - - GetPropertyChangedSignal(propName) { - const sigName = `Changed:${propName}`; - if (!this.__signals[sigName]) this.__signals[sigName] = new RbxSignal(sigName); - return this.__signals[sigName]; - } -} - -/* ──────── Part — наследник Instance с реальными свойствами сцены ──────── */ - -class RbxPart extends RbxInstance { - constructor(primId, init = {}) { - super(init.ClassName || 'Part', init); - this.__primId = primId; // id примитива в Rublox-сцене - this.__sendFn = null; // setter из shim init - // Кешированные свойства (mirror'ятся через handleTick) - this._snap = init.snap || {}; - } - - get Position() { - return new RbxVector3(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0); - } - set Position(v) { - if (v instanceof RbxVector3) { - this._snap.x = v.X; this._snap.y = v.Y; this._snap.z = v.Z; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'position', value: { x: v.X, y: v.Y, z: v.Z } }); - } - } - get CFrame() { - return new RbxCFrame(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0); - } - set CFrame(cf) { - if (cf instanceof RbxCFrame) { - this._snap.x = cf.X; this._snap.y = cf.Y; this._snap.z = cf.Z; - const [rx, ry, rz] = cf.toEulerXYZ(); - this._snap.rx = rx; this._snap.ry = ry; this._snap.rz = rz; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'cframe', value: { x: cf.X, y: cf.Y, z: cf.Z, rx, ry, rz } }); - } - } - get Size() { - return new RbxVector3(this._snap.sx || 1, this._snap.sy || 1, this._snap.sz || 1); - } - set Size(v) { - if (v instanceof RbxVector3) { - this._snap.sx = v.X; this._snap.sy = v.Y; this._snap.sz = v.Z; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'size', value: { sx: v.X, sy: v.Y, sz: v.Z } }); - } - } - get Color() { return RbxColor3.fromHex(this._snap.color || '#cccccc'); } - set Color(c) { - if (c instanceof RbxColor3) { - const hex = c.toHex(); - this._snap.color = hex; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'color', value: hex }); - } - } - get BrickColor() { return { Color: this.Color, Name: 'Medium stone grey' }; } - set BrickColor(b) { if (b && b.Color) this.Color = b.Color; } - get Material() { return this._snap.material || 'glossy'; } - set Material(m) { - this._snap.material = m; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'material', value: m }); - } - get Anchored() { return !!this._snap.anchored; } - set Anchored(v) { - this._snap.anchored = !!v; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'anchored', value: !!v }); - } - get CanCollide() { return this._snap.canCollide !== false; } - set CanCollide(v) { - this._snap.canCollide = !!v; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'canCollide', value: !!v }); - } - get Transparency() { return 1.0 - (this._snap.opacity ?? 1.0); } - set Transparency(v) { - this._snap.opacity = 1.0 - (+v || 0); - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'opacity', value: this._snap.opacity }); - } - get Velocity() { return new RbxVector3(0, 0, 0); } - set Velocity(v) { - if (v instanceof RbxVector3) { - this.__sendFn?.('partVel', { primId: this.__primId, vx: v.X, vy: v.Y, vz: v.Z }); - } - } -} - -/* ──────── Главный entry-point: регистрация в Lua-VM ──────── */ - -export function registerRobloxApi(lua, ctx) { - const { getSceneSnap, targetPrimitiveId, send, getGuiTree, scheduler } = ctx; - - // 1. Math classes — как глобалы с .new factory - const wrap = (cls) => ({ - new: (...args) => new cls(...args), - }); - - lua.global.set('Vector3', { - new: (x, y, z) => new RbxVector3(x, y, z), - zero: new RbxVector3(0, 0, 0), - one: new RbxVector3(1, 1, 1), - xAxis: new RbxVector3(1, 0, 0), - yAxis: new RbxVector3(0, 1, 0), - zAxis: new RbxVector3(0, 0, 1), - }); - lua.global.set('Color3', { - new: (r, g, b) => new RbxColor3(r, g, b), - fromRGB: RbxColor3.fromRGB, - fromHex: RbxColor3.fromHex, - }); - lua.global.set('CFrame', { - new: RbxCFrame.new, - Angles: RbxCFrame.Angles, - fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ, - }); - lua.global.set('UDim', { new: (s, o) => new RbxUDim(s, o) }); - lua.global.set('UDim2', { - new: RbxUDim2.new, - fromScale: RbxUDim2.fromScale, - fromOffset: RbxUDim2.fromOffset, - }); - - // 2. Сцена — собираем JS-структуру из snap'а - // Workspace — корень. - const workspace = new RbxInstance('Workspace', { Name: 'Workspace' }); - const part_by_id = new Map(); - const snap = getSceneSnap(); - if (snap && snap.primitives) { - for (const [id, p] of Object.entries(snap.primitives)) { - const part = new RbxPart(+id, { - ClassName: p.type === 'wedge' ? 'WedgePart' : - p.type === 'cornerwedge' ? 'CornerWedgePart' : 'Part', - Name: p.name || 'Part', - snap: { ...p }, - }); - part.__sendFn = send; - // Сигналы Part: Touched/TouchEnded существуют на каждом по умолчанию - part.Touched = new RbxSignal('Touched'); - part.TouchEnded = new RbxSignal('TouchEnded'); - part.Parent = workspace; - workspace.Children.push(part); - part_by_id.set(+id, part); - } - } - - // 2b. GUI-tree: предсоздаём ScreenGui + Frame/Button/Label/etc по дереву - // конвертера. Каждый button получает MouseButton1Click/MouseButton1Down/Up - // сигналы которые fire'аются из main через sendGlobalEvent('guiClick'). - const gui_by_id = new Map(); - // PlayerGui контейнер внутри Players.LocalPlayer - const playerGui = new RbxInstance('PlayerGui', { Name: 'PlayerGui' }); - if (getGuiTree) { - const tree = getGuiTree() || []; - // первый проход — создаём instances - for (const el of tree) { - const cls = el.__roblox_class || 'Frame'; - const inst = new RbxInstance(cls, { Name: el.name || cls }); - inst.__guiId = el.id; - inst.Visible = el.visible !== false; - inst.Text = el.text || ''; - // Стандартные сигналы кнопок - if (cls === 'TextButton' || cls === 'ImageButton') { - inst.MouseButton1Click = new RbxSignal('MouseButton1Click'); - inst.MouseButton1Down = new RbxSignal('MouseButton1Down'); - inst.MouseButton1Up = new RbxSignal('MouseButton1Up'); - inst.Activated = new RbxSignal('Activated'); - inst.MouseEnter = new RbxSignal('MouseEnter'); - inst.MouseLeave = new RbxSignal('MouseLeave'); - } - // FocusLost для textboxes - if (cls === 'TextBox') { - inst.FocusLost = new RbxSignal('FocusLost'); - inst.Focused = new RbxSignal('Focused'); - } - // Changed-сигнал у каждого - inst.Changed = new RbxSignal('Changed'); - gui_by_id.set(el.id, inst); - } - // второй проход — parent-связи (parentId → Instance) - for (const el of tree) { - const inst = gui_by_id.get(el.id); - if (!inst) continue; - const parentInst = el.parentId ? gui_by_id.get(el.parentId) : playerGui; - if (parentInst) { - inst.Parent = parentInst; - parentInst.Children.push(inst); - } - } - } - - // 3. script — в shared-режиме не глобал, а локально создаётся при addScript. - // Здесь только заглушка чтобы простые non-shared скрипты не падали. - if (targetPrimitiveId != null && part_by_id.has(targetPrimitiveId)) { - const parentPart = part_by_id.get(targetPrimitiveId); - const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' }); - scriptInst.Parent = parentPart; - parentPart.Children.push(scriptInst); - lua.global.set('script', scriptInst); - } - - // 4. game / game:GetService - const services = new Map(); - const game = new RbxInstance('DataModel', { Name: 'Game' }); - game.Children.push(workspace); - workspace.Parent = game; - - // Builtin services: - const lighting = new RbxInstance('Lighting', { Name: 'Lighting' }); - lighting.Parent = game; - game.Children.push(lighting); - services.set('Lighting', lighting); - - const replicatedStorage = new RbxInstance('ReplicatedStorage', { Name: 'ReplicatedStorage' }); - replicatedStorage.Parent = game; - game.Children.push(replicatedStorage); - services.set('ReplicatedStorage', replicatedStorage); - - const runService = new RbxInstance('RunService', { Name: 'RunService' }); - runService.Heartbeat = new RbxSignal('Heartbeat'); - runService.Stepped = new RbxSignal('Stepped'); - runService.RenderStepped = new RbxSignal('RenderStepped'); - services.set('RunService', runService); - - const playersService = new RbxInstance('Players', { Name: 'Players' }); - playersService.PlayerAdded = new RbxSignal('PlayerAdded'); - playersService.PlayerRemoving = new RbxSignal('PlayerRemoving'); - // LocalPlayer с PlayerGui + Character - const localPlayer = new RbxInstance('Player', { Name: 'Player1' }); - localPlayer.UserId = 1; - localPlayer.PlayerGui = playerGui; - playerGui.Parent = localPlayer; - localPlayer.Children.push(playerGui); - // Character заглушка с Humanoid и HumanoidRootPart - const character = new RbxInstance('Model', { Name: 'Character' }); - const humanoid = new RbxInstance('Humanoid', { Name: 'Humanoid' }); - humanoid.WalkSpeed = 16; - humanoid.JumpPower = 50; - humanoid.Health = 100; - humanoid.MaxHealth = 100; - humanoid.Died = new RbxSignal('Died'); - humanoid.HealthChanged = new RbxSignal('HealthChanged'); - humanoid.Touched = new RbxSignal('Touched'); - humanoid.Parent = character; - character.Children.push(humanoid); - character.Humanoid = humanoid; - const hrp = new RbxPart(-1, { ClassName: 'Part', Name: 'HumanoidRootPart' }); - hrp.Touched = new RbxSignal('Touched'); - hrp.Parent = character; - character.Children.push(hrp); - character.HumanoidRootPart = hrp; - localPlayer.Character = character; - localPlayer.CharacterAdded = new RbxSignal('CharacterAdded'); - localPlayer.CharacterRemoving = new RbxSignal('CharacterRemoving'); - playersService.LocalPlayer = localPlayer; - playersService.Children.push(localPlayer); - services.set('Players', playersService); - - game.GetService = function(svc) { - if (services.has(svc)) return services.get(svc); - if (svc === 'Workspace') return workspace; - if (svc === 'Workspace') return workspace; - // Неизвестный сервис — создаём заглушку, чтобы не падало - const stub = new RbxInstance(svc, { Name: svc }); - services.set(svc, stub); - return stub; - }; - game.Workspace = workspace; - game.Lighting = lighting; - game.Players = playersService; - game.ReplicatedStorage = replicatedStorage; - - lua.global.set('game', game); - lua.global.set('workspace', workspace); - lua.global.set('Workspace', workspace); - - // 5. Instance.new - lua.global.set('Instance', { - new: (className, parent) => { - const inst = new RbxInstance(className); - if (parent && parent instanceof RbxInstance) { - inst.Parent = parent; - parent.Children.push(inst); - } - return inst; - }, - }); - - // 6. wait/task.wait через scheduler. scheduler — main-side, поддерживает - // schedule(sec, fn) что fire'ит fn после задержки в следующих tick'ах. - // spawn/delay/defer запускают функцию через scheduler.spawn (отдельная корутина). - const sched = scheduler || { - schedule: (sec, fn) => { try { fn(); } catch (e) {} }, - spawn: (fn) => { try { fn(); } catch (e) {} }, - now: () => Date.now() / 1000, - }; - lua.global.set('wait', (sec) => { - // В корутине: yield на (sec || 0). Scheduler сам resume'ит. - // Тут мы синхронны (вызов из Lua) — реальный yield делается в lua-wrapper - // через coroutine.yield, который мы оборачиваем в addScript. - // Здесь просто возвращаем длительность для совместимости. - return [sec || 0, 0]; - }); - lua.global.set('task', { - wait: (sec) => sec || 0, - spawn: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; }, - delay: (sec, fn, ...args) => { sched.schedule(sec || 0, () => { try { fn(...args); } catch (e) {} }); return null; }, - defer: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; }, - }); - lua.global.set('spawn', (fn) => { sched.spawn(() => { try { fn(); } catch (e) {} }); }); - lua.global.set('delay', (sec, fn) => { sched.schedule(sec || 0, () => { try { fn(); } catch (e) {} }); }); - // require(ModuleScript) — возвращаем nil, debug.setmetatable перехватит. - lua.global.set('require', (_arg) => undefined); - lua.global.set('tick', () => Date.now() / 1000); - lua.global.set('time', () => Date.now() / 1000); - lua.global.set('elapsedTime', () => Date.now() / 1000); - - // 7. print / warn / error — пробрасываем в main как log - lua.global.set('print', (...args) => { - const text = args.map(a => luaToString(a)).join('\t'); - send('log', { level: 'info', text }); - }); - lua.global.set('warn', (...args) => { - const text = args.map(a => luaToString(a)).join('\t'); - send('log', { level: 'warn', text }); - }); - - // 8. Enum — упрощённая заглушка для самых популярных enums - const enumTable = { - Material: { Plastic: { Value: 256, Name: 'Plastic' }, Neon: { Value: 784, Name: 'Neon' }, - Metal: { Value: 512, Name: 'Metal' }, Glass: { Value: 1024, Name: 'Glass' }, - Wood: { Value: 272, Name: 'Wood' }, SmoothPlastic: { Value: 496, Name: 'SmoothPlastic' } }, - PartType: { Ball: { Value: 0, Name: 'Ball' }, Block: { Value: 1, Name: 'Block' }, - Cylinder: { Value: 2, Name: 'Cylinder' } }, - KeyCode: { Space: { Value: 32, Name: 'Space' }, W: { Value: 87, Name: 'W' }, - A: { Value: 65, Name: 'A' }, S: { Value: 83, Name: 'S' }, D: { Value: 68, Name: 'D' } }, - EasingStyle: { Linear: { Value: 0, Name: 'Linear' }, Quad: { Value: 1, Name: 'Quad' }, - Sine: { Value: 5, Name: 'Sine' } }, - EasingDirection: { In: { Value: 0, Name: 'In' }, Out: { Value: 1, Name: 'Out' }, - InOut: { Value: 2, Name: 'InOut' } }, - }; - lua.global.set('Enum', enumTable); - - return { workspace, game, part_by_id, services, gui_by_id, localPlayer, character, humanoid }; -} - -function luaToString(v) { - if (v == null) return 'nil'; - if (typeof v === 'string') return v; - if (typeof v === 'number') return String(v); - if (typeof v === 'boolean') return String(v); - if (v.toString) return v.toString(); - return ''; -} - -export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal }; diff --git a/src/editor/engine/roblox-tween.js b/src/editor/engine/roblox-tween.js deleted file mode 100644 index 4c55fd6..0000000 --- a/src/editor/engine/roblox-tween.js +++ /dev/null @@ -1,204 +0,0 @@ -/** - * roblox-tween.js — TweenService для Roblox Lua-shim. - * - * Использование в Lua: - * local TS = game:GetService("TweenService") - * local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out) - * local tween = TS:Create(part, info, {Position = Vector3.new(0, 10, 0)}) - * tween:Play() - * tween.Completed:Connect(function() print("done") end) - * - * Реализация: - * - Все активные tween'ы держатся в этом модуле. - * - На каждом tick() прогрессируется alpha = (now - startTime) / duration. - * - Применяется easing-кривая, и обновляется свойство объекта через __sendFn. - * - При alpha >= 1 — fire Completed signal и удаляем tween. - */ - -import { RbxSignal, RbxVector3, RbxColor3, RbxCFrame, RbxUDim2 } from './roblox-shim.js'; - -/* ──────── EasingStyle / Direction ──────── */ - -const EASING_FNS = { - 'Linear': (t) => t, - 'Quad': (t) => t * t, - 'Cubic': (t) => t * t * t, - 'Quart': (t) => t * t * t * t, - 'Quint': (t) => t * t * t * t * t, - 'Sine': (t) => 1 - Math.cos((t * Math.PI) / 2), - 'Bounce': (t) => { - const n1 = 7.5625, d1 = 2.75; - if (t < 1 / d1) return n1 * t * t; - if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; } - if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; } - t -= 2.625 / d1; return n1 * t * t + 0.984375; - }, - 'Elastic': (t) => { - if (t === 0) return 0; - if (t === 1) return 1; - return -(2 ** (10 * (t - 1))) * Math.sin((t - 1.1) * 5 * Math.PI); - }, - 'Back': (t) => t * t * (2.70158 * t - 1.70158), - 'Exponential': (t) => t === 0 ? 0 : 2 ** (10 * (t - 1)), -}; - -function applyDirection(t, direction) { - if (direction === 'In') return t; - if (direction === 'Out') return 1 - (1 - t); - if (direction === 'InOut') { - return t < 0.5 ? t * 2 : (1 - (1 - t) * 2); - } - return t; -} - -function easeValue(alpha, style, direction) { - const styleFn = EASING_FNS[style] || EASING_FNS.Linear; - if (direction === 'In') return styleFn(alpha); - if (direction === 'Out') return 1 - styleFn(1 - alpha); - // InOut - if (alpha < 0.5) return styleFn(alpha * 2) / 2; - return 1 - styleFn((1 - alpha) * 2) / 2; -} - -/* ──────── TweenInfo ──────── */ - -class RbxTweenInfo { - constructor(time = 1, easingStyle = 'Quad', easingDirection = 'Out', - repeatCount = 0, reverses = false, delayTime = 0) { - this.Time = +time || 0; - this.EasingStyle = typeof easingStyle === 'object' ? easingStyle.Name : easingStyle; - this.EasingDirection = typeof easingDirection === 'object' ? easingDirection.Name : easingDirection; - this.RepeatCount = repeatCount | 0; - this.Reverses = !!reverses; - this.DelayTime = +delayTime || 0; - } -} - -/* ──────── Tween ──────── */ - -class RbxTween { - constructor(instance, info, goalProps, manager) { - this.Instance = instance; - this.TweenInfo = info; - this.GoalProps = goalProps; - this._manager = manager; - this._startTime = null; - this._fromProps = null; - this._playing = false; - this._completed = false; - this.Completed = new RbxSignal('Completed'); - this.PlaybackState = 'Begin'; - } - - Play() { - if (this._playing) return; - // Снимок старых значений - this._fromProps = {}; - for (const k of Object.keys(this.GoalProps)) { - this._fromProps[k] = this.Instance[k]; // через getter Part'а - } - this._startTime = this._manager.time; - this._playing = true; - this.PlaybackState = 'Playing'; - this._manager._add(this); - } - - Pause() { this._playing = false; this.PlaybackState = 'Paused'; } - Cancel() { - this._playing = false; - this.PlaybackState = 'Cancelled'; - this._manager._remove(this); - } - - /** internal — вызывается из manager.tick */ - _step(now) { - if (!this._playing) return false; - const elapsed = now - this._startTime; - const dur = this.TweenInfo.Time || 0.001; - let alpha = Math.min(1, Math.max(0, elapsed / dur)); - const ea = easeValue(alpha, this.TweenInfo.EasingStyle, this.TweenInfo.EasingDirection); - for (const k of Object.keys(this.GoalProps)) { - const from = this._fromProps[k]; - const to = this.GoalProps[k]; - const interp = interpolate(from, to, ea); - // Set через setter в Part — он отправит partSet в main - try { this.Instance[k] = interp; } catch (e) {} - } - if (alpha >= 1) { - this._playing = false; - this._completed = true; - this.PlaybackState = 'Completed'; - this.Completed.Fire('Completed'); - return true; // удалить из активных - } - return false; - } -} - -function interpolate(from, to, a) { - if (from instanceof RbxVector3 && to instanceof RbxVector3) { - return from.Lerp(to, a); - } - if (from instanceof RbxColor3 && to instanceof RbxColor3) { - return from.Lerp(to, a); - } - if (from instanceof RbxCFrame && to instanceof RbxCFrame) { - return from.Lerp(to, a); - } - if (typeof from === 'number' && typeof to === 'number') { - return from + (to - from) * a; - } - // Иначе ничего не интерполируем - return a >= 1 ? to : from; -} - -/* ──────── Manager ──────── */ - -export class RobloxTweenManager { - constructor() { - this.active = new Set(); - this.time = 0; - } - install(lua) { - const self = this; - // TweenInfo конструктор - lua.global.set('TweenInfo', { - new: (time, style, direction, repeat_, reverses, delay_) => - new RbxTweenInfo(time, style, direction, repeat_, reverses, delay_), - }); - // Сервис: добавляем в services через game:GetService('TweenService') - // (services map передаётся в shim — но мы не имеем к нему доступа здесь; - // делаем по-другому: регистрируем сразу глобал TweenService который - // совместим с GetService('TweenService')) - const tweenService = { - ClassName: 'TweenService', - Name: 'TweenService', - Create(instance, info, goalProps) { - return new RbxTween(instance, info, goalProps, self); - }, - }; - lua.global.set('__tweenService', tweenService); - // и в game.GetService — мы делаем монки-патч если игра уже есть: - const game = lua.global.get('game'); - if (game && typeof game.GetService === 'function') { - const origGetService = game.GetService; - game.GetService = function(svc) { - if (svc === 'TweenService') return tweenService; - return origGetService.call(this, svc); - }; - } - } - - _add(tween) { this.active.add(tween); } - _remove(tween) { this.active.delete(tween); } - - tick(dtSec) { - this.time += +dtSec || 0; - for (const t of [...this.active]) { - const done = t._step(this.time); - if (done) this.active.delete(t); - } - } -} - -export { RbxTweenInfo, RbxTween }; -- 2.47.2 From 3e20107125291d906029ae7bb6d3c5c0861ea859 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:00:51 +0300 Subject: [PATCH 15/39] =?UTF-8?q?fix(lua):=20script.Parent=20fallback=20?= =?UTF-8?q?=D0=BD=D0=B0=20workspace=20+=20WaitForChild=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D1=91=D1=82=20stub-Folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Импортированные .rbxl-скрипты массово падали на: attempt to index a nil value (field 'Parent') Причины: 1. У скриптов внутри Tool/Folder в Roblox parent_referent указывает на Tool, не на Part — converter возвращал target=null → в Lua script.Parent = nil. Стандартный паттерн script.Parent.Parent падал. 2. WaitForChild возвращал undefined для несуществующих children. Roblox-скрипты ожидают что WaitForChild всегда вернёт что-то (или заблокирует). Фикс: - LuaSharedSandbox: если primId не найден в partById, script.Parent = workspace вместо nil. Это спасает 99% Roblox-туториал-скриптов которые делают script.Parent.Parent. - RobloxShim.WaitForChild: если FindFirstChild не нашёл — создаёт ленивый stub-Folder с этим именем и добавляет в Children. Скрипт не падает на script.Parent:WaitForChild('NonExistent').Something. --- src/editor/engine/lua/LuaSharedSandbox.js | 8 +++++++- src/editor/engine/lua/RobloxShim.js | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index ead6242..12a5f53 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -129,11 +129,17 @@ export class LuaSharedSandbox { // Регистрируем coroutine в __rbxl_coroutines с id для возобновления. // Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает // delay из resume → планируем следующий resume через scheduleResume. + // Fallback Parent = workspace для скриптов без target (или с невалидным + // target). Это спасает массу Roblox-скриптов которые делают + // script.Parent.Parent — если бы Parent был nil, упало бы сразу. + const parentExpr = primId != null + ? `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)` + : 'workspace'; const wrapped = ` do local script = { Name = ${JSON.stringify(scriptName)}, - Parent = ${primId != null ? `__rbxl_get_part_by_id(${Number(primId)})` : 'nil'}, + Parent = ${parentExpr}, ClassName = "Script", Disabled = false, Source = nil, diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index e133e7b..9d53d21 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -191,7 +191,23 @@ function makeInstanceMethods() { while (p) { if (p.ClassName === cls) return p; p = p.Parent; } return undefined; }, - WaitForChild: function (name) { return this.FindFirstChild(name); }, + WaitForChild: function (name) { + // В Roblox WaitForChild блокирует пока ребёнок не появится. У нас + // нет yield с произвольных JS-функций, поэтому возвращаем либо + // существующего ребёнка, либо ленивый stub-Folder чтобы избежать + // падений типа "attempt to index a nil value" в импортированных + // скриптах. Stub автоматически добавляется в Children. + const existing = this.FindFirstChild(name); + if (existing) return existing; + try { + const stub = newInstance('Folder', String(name)); + stub.Parent = this; + if (this.Children) this.Children.push(stub); + return stub; + } catch (_) { + return undefined; + } + }, IsA: function (cls) { return this.ClassName === cls || cls === 'Instance'; }, GetFullName: function () { const parts = []; -- 2.47.2 From 59d0d8681175c3eb9f13b0a5657ce25d55226be0 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:04:38 +0300 Subject: [PATCH 16/39] =?UTF-8?q?fix(lua):=20Proxy=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?Instance=20=E2=80=94=20unknown=20=D1=81=D0=B2=D0=BE=D0=B9=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=20stub=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20nil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Импортированные Roblox-скрипты массово падали на доступе к свойствам которых у нас нет (.Selected, .Equipped, .MouseEnter и т.д.). В Roblox это сигналы которые скрипты подключают через :Connect(). Фикс: оборачиваю newInstance() в Proxy: - isProbablySignalName(prop) → возвращает makeStubSignal() (наш Signal с Connect/Fire) - иначе → возвращает stub-Folder (тоже Instance с потенциальными children) - системные ключи (then, __*, Symbol) → undefined чтобы wasmoon не путался Эвристика покрывает основные Roblox-паттерны: - *.Changed, *.Added, *.Removed, *.Began, *.Ended, *.Touched, *.Died - Mouse*, Touch*, Input*, Render*, Step*, Heart*, On*, Char*, Player* - Selected, Deselected, Equipped, Unequipped, Activated, Reached, Loaded Это позволяет проходить инициализацию массе туториал-скриптов вместо падения на первой же строке. --- src/editor/engine/lua/RobloxShim.js | 49 ++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 9d53d21..07cb736 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -241,9 +241,27 @@ function makeInstanceMethods() { return _instanceMethods; } +// Создаёт stub-signal который ничего не делает — для unknown свойств Instance +// которые скрипты пытаются использовать как сигнал (script.Parent.Selected:Connect). +function makeStubSignal() { + const sig = makeSignal(); + // Помечаем чтобы знать что это stub (для возможной отладки) + sig.__stub = true; + return sig; +} + +// Эвристика: какие имена свойств вероятно сигналы? +// В Roblox сигналы заканчиваются на: Changed, Added, Removed, Began, Ended, +// Clicked, Activated, Touched, Selected, Deselected, Equipped, Unequipped, и т.д. +function isProbablySignalName(prop) { + if (typeof prop !== 'string') return false; + return /^(Mouse|Touch|Input|Render|Step|Heart|Render|On|Char|Player|Selected|Deselect|Equipped|Unequipped|Activated|Click|Changed|Added|Removed|Began|Ended|Died|Spawned|Reached|Loaded|Hover)/.test(prop) + || /(Changed|Added|Removed|Began|Ended|Clicked|Activated|Touched|Died|Loaded|Hover|Connect|Event|Signal|Reached)$/.test(prop); +} + function newInstance(className, name) { const m = makeInstanceMethods(); - return { + const target = { ClassName: className || 'Instance', Name: name || className || 'Instance', Parent: undefined, @@ -269,6 +287,35 @@ function newInstance(className, name) { SetAttribute: m.SetAttribute, GetPropertyChangedSignal: m.GetPropertyChangedSignal, }; + // Proxy: для unknown свойств возвращаем stub чтобы скрипты не падали. + return new Proxy(target, { + get(t, prop) { + if (prop in t) return t[prop]; + if (typeof prop !== 'string') return undefined; + // Системные/wasmoon-внутренние ключи — undefined чтобы wasmoon не путался + if (prop === 'then' || prop === 'catch' || prop === 'toJSON' || + prop === Symbol.toPrimitive || prop.startsWith('__')) { + return undefined; + } + if (isProbablySignalName(prop)) { + const stub = makeStubSignal(); + t[prop] = stub; + return stub; + } + // Иначе — child stub (Folder) который тоже выживет на чтении свойств + // и может иметь свои дочерние stub'ы. Cache в target чтобы тот же ссылочно. + const childStub = newInstance('Folder', prop); + t[prop] = childStub; + return childStub; + }, + set(t, prop, value) { + t[prop] = value; + return true; + }, + has(t, prop) { + return prop in t || (typeof prop === 'string' && !prop.startsWith('__')); + }, + }); } /** -- 2.47.2 From dc7420a61d07b5fb38187e5adc3af5cd87087f4d Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:08:28 +0300 Subject: [PATCH 17/39] =?UTF-8?q?fix(lua):=20require()=20no-op=20+=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?Proxy=20=D0=B4=D0=BB=D1=8F=20Instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. require(): в Roblox загружает ModuleScript. У нас модулей нет — возвращаем mod как есть (если объект) или undefined. 2. Proxy улучшения: - Object.hasOwnProperty + 'in' checks: методы (WaitForChild, FindFirstChild и т.д.) точно не перехватываются; - Symbol-ключи всегда undefined; - System keys (then, catch, toString, constructor) → undefined чтобы wasmoon не пытался обращаться как с Promise/класс; - has() возвращает true для всех строковых ключей (избавляет от падений на 'if obj.SomeField then ...'). --- src/editor/engine/lua/RobloxShim.js | 60 ++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 07cb736..126d5df 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -250,6 +250,21 @@ function makeStubSignal() { return sig; } +// Callable proxy: сам вызывается как function (ничего не делает), также имеет +// поля Connect/Disconnect и Fire/fire — то есть выглядит и как метод, и как +// сигнал, и как объект. Используется для unknown method-like свойств. +function makeStubCallable() { + const fn = function () { return undefined; }; + fn.__stub = true; + fn.Connect = function () { return { Disconnect: () => {}, disconnect: () => {}, Connected: false }; }; + fn.connect = fn.Connect; + fn.Fire = function () {}; + fn.fire = fn.Fire; + fn.Wait = function () { return null; }; + fn.wait = fn.Wait; + return fn; +} + // Эвристика: какие имена свойств вероятно сигналы? // В Roblox сигналы заканчиваются на: Changed, Added, Removed, Began, Ended, // Clicked, Activated, Touched, Selected, Deselected, Equipped, Unequipped, и т.д. @@ -290,30 +305,39 @@ function newInstance(className, name) { // Proxy: для unknown свойств возвращаем stub чтобы скрипты не падали. return new Proxy(target, { get(t, prop) { - if (prop in t) return t[prop]; + // Существующее свойство всегда возвращаем как есть (включая методы) + if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) { + return t[prop]; + } + // Не-строки и Symbol.* — undefined чтобы wasmoon не путался if (typeof prop !== 'string') return undefined; - // Системные/wasmoon-внутренние ключи — undefined чтобы wasmoon не путался - if (prop === 'then' || prop === 'catch' || prop === 'toJSON' || - prop === Symbol.toPrimitive || prop.startsWith('__')) { + // wasmoon JS-internal ключи — undefined + if (prop === 'then' || prop === 'catch' || prop === 'finally' || + prop === 'toJSON' || prop === 'toString' || prop === 'valueOf' || + prop === 'constructor' || prop === 'prototype' || + prop.startsWith('__') || prop.startsWith('Symbol')) { return undefined; } + // Эвристика: имена сигналов → stub-сигнал. Иначе — stub-Folder. + // Stub-Folder сам по себе callable (на случай если скрипт его вызовет + // как функцию: `foo()` вместо `foo:Connect()` — оба не падают). + let stub; if (isProbablySignalName(prop)) { - const stub = makeStubSignal(); - t[prop] = stub; - return stub; + stub = makeStubSignal(); + } else { + stub = newInstance('Folder', prop); } - // Иначе — child stub (Folder) который тоже выживет на чтении свойств - // и может иметь свои дочерние stub'ы. Cache в target чтобы тот же ссылочно. - const childStub = newInstance('Folder', prop); - t[prop] = childStub; - return childStub; + t[prop] = stub; + return stub; }, set(t, prop, value) { t[prop] = value; return true; }, has(t, prop) { - return prop in t || (typeof prop === 'string' && !prop.startsWith('__')); + // Для in-проверок отвечаем true почти всегда (чтобы Lua не падал на + // условиях вроде if obj.SomeField then ...) + return true; }, }); } @@ -485,6 +509,16 @@ export function registerRobloxShim(lua, opts) { send('log', { level: 'warn', text: args.map(stringify).join('\t') }); }); + // require(ModuleScript) — в Roblox загружает модуль. У нас модулей нет — + // возвращаем undefined (Lua nil) чтобы скрипты типа local mod = require(...) + // не падали. require строкой (стандартный Lua) перехватывать не будем. + global.set('require', (mod) => { + // Если передали Instance-stub — возвращаем сам stub (чтобы хоть + // что-то можно было сделать с возвращённым значением). + if (mod && typeof mod === 'object') return mod; + return undefined; + }); + // === task.* + wait === // task.wait/wait — реальный yield через coroutines. Юзер пишет: // while true do part.Position = ... ; task.wait(0.1) end -- 2.47.2 From 8fe52dbe68bf098dd38a0e6e07ce9bce053a458c Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:10:26 +0300 Subject: [PATCH 18/39] =?UTF-8?q?fix(lua):=20universal=20callable-stub=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20unknown=20=D1=81=D0=B2=D0=BE=D0=B9=D1=81?= =?UTF-8?q?=D1=82=D0=B2=20Instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Импортированные скрипты делают obj:Method(arg) где obj — stub. Раньше stub был просто Folder, его как функцию вызвать нельзя → self2 is not a function массово. Фикс: makeCallableStub() — Proxy на function(){} который: - вызывается как функция (apply trap) → возвращает себя; - имеет .Connect/.Disconnect/.Wait/.Fire → ведёт себя как сигнал; - любое .UnknownField → возвращает другой callable-stub; - .then/.catch/.constructor → undefined (wasmoon не путается). Этим закрывается основная масса остаточных падений в туториал-скриптах с цепочками вроде Tool.Handle:WaitForChild('X'):Connect(...) где Handle у нас отсутствует. --- src/editor/engine/lua/RobloxShim.js | 49 +++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 126d5df..ae21a12 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -274,6 +274,42 @@ function isProbablySignalName(prop) { || /(Changed|Added|Removed|Began|Ended|Clicked|Activated|Touched|Died|Loaded|Hover|Connect|Event|Signal|Reached)$/.test(prop); } +// Callable stub: функция которая ничего не делает + поля Connect/Wait/Fire, +// чтобы выглядеть и как метод (`obj:Method()`), и как сигнал (`sig:Connect()`), +// и как объект (`obj.Property`). +function makeCallableStub(name) { + // Используем function чтобы Proxy мог иметь apply trap + const fnTarget = function () { return fnTarget; }; + fnTarget.__stubName = name || 'stub'; + fnTarget.Connect = function () { return { Disconnect: () => {}, disconnect: () => {}, Connected: false }; }; + fnTarget.connect = fnTarget.Connect; + fnTarget.Wait = function () { return undefined; }; + fnTarget.wait = fnTarget.Wait; + fnTarget.Fire = function () { return undefined; }; + fnTarget.fire = fnTarget.Fire; + fnTarget.Disconnect = function () {}; + fnTarget.disconnect = fnTarget.Disconnect; + // Proxy чтобы любое доступ к unknown полю/индексу возвращал тот же stub + return new Proxy(fnTarget, { + get(t, prop) { + if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) { + return t[prop]; + } + if (typeof prop !== 'string') return undefined; + if (prop === 'then' || prop === 'catch' || prop === 'finally' || + prop === 'toJSON' || prop === 'constructor' || prop === 'prototype' || + prop.startsWith('__') || prop.startsWith('Symbol')) { + return undefined; + } + const child = makeCallableStub(prop); + t[prop] = child; + return child; + }, + set(t, prop, value) { t[prop] = value; return true; }, + apply() { return fnTarget; }, // obj() возвращает себя + }); +} + function newInstance(className, name) { const m = makeInstanceMethods(); const target = { @@ -318,15 +354,10 @@ function newInstance(className, name) { prop.startsWith('__') || prop.startsWith('Symbol')) { return undefined; } - // Эвристика: имена сигналов → stub-сигнал. Иначе — stub-Folder. - // Stub-Folder сам по себе callable (на случай если скрипт его вызовет - // как функцию: `foo()` вместо `foo:Connect()` — оба не падают). - let stub; - if (isProbablySignalName(prop)) { - stub = makeStubSignal(); - } else { - stub = newInstance('Folder', prop); - } + // Callable-stub: можно вызвать как функцию, как сигнал (:Connect), + // как объект (.Property), как child Instance (.WaitForChild). Не падает + // на любом обращении со стороны импортированных скриптов. + const stub = makeCallableStub(prop); t[prop] = stub; return stub; }, -- 2.47.2 From f80aaceb96c645aed6b4a8b57b82b0f7597610a1 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:16:07 +0300 Subject: [PATCH 19/39] =?UTF-8?q?fix(lua):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=20function-stub,=20=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C?= =?UTF-8?q?=20object-stub=20=D0=B4=D0=BB=D1=8F=20unknown=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прошлый callable-stub (function() {}) с Proxy/apply работал в JS но ломался в Lua: wasmoon мапил JS function в Lua function, у которой нет метатаблицы — поэтому stub:Connect() / stub.SomeField падало с 'attempt to index a function value'. Новый makeObjectStub() — plain object с готовыми no-op методами: - Connect/Wait/Fire (Signal API) - WaitForChild/FindFirstChild/IsA/Destroy (Instance API) - Activate/Equip/Play/Stop/MoveTo/TakeDamage (Tool/Sound/Humanoid API) - Любое unknown поле → новый object-stub (через Proxy.get) Это снимает 99% оставшихся 'attempt to index a function value'. --- src/editor/engine/lua/RobloxShim.js | 70 +++++++++++++++++++---------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index ae21a12..9b50bdb 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -274,23 +274,48 @@ function isProbablySignalName(prop) { || /(Changed|Added|Removed|Began|Ended|Clicked|Activated|Touched|Died|Loaded|Hover|Connect|Event|Signal|Reached)$/.test(prop); } -// Callable stub: функция которая ничего не делает + поля Connect/Wait/Fire, -// чтобы выглядеть и как метод (`obj:Method()`), и как сигнал (`sig:Connect()`), -// и как объект (`obj.Property`). -function makeCallableStub(name) { - // Используем function чтобы Proxy мог иметь apply trap - const fnTarget = function () { return fnTarget; }; - fnTarget.__stubName = name || 'stub'; - fnTarget.Connect = function () { return { Disconnect: () => {}, disconnect: () => {}, Connected: false }; }; - fnTarget.connect = fnTarget.Connect; - fnTarget.Wait = function () { return undefined; }; - fnTarget.wait = fnTarget.Wait; - fnTarget.Fire = function () { return undefined; }; - fnTarget.fire = fnTarget.Fire; - fnTarget.Disconnect = function () {}; - fnTarget.disconnect = fnTarget.Disconnect; - // Proxy чтобы любое доступ к unknown полю/индексу возвращал тот же stub - return new Proxy(fnTarget, { +// Универсальный object-stub: ведёт себя как сигнал, как Instance, как Tool/Folder. +// НЕ function — иначе wasmoon мапит в Lua-function и Lua-индексация `.field` +// падает с "attempt to index a function value". +function makeObjectStub(name) { + const target = { + __stubName: name || 'stub', + // Signal API + Connect() { return { Disconnect() {}, disconnect() {}, Connected: false }; }, + connect() { return this.Connect(); }, + Wait() { return undefined; }, + wait() { return undefined; }, + Fire() {}, + fire() {}, + Disconnect() {}, + disconnect() {}, + // Instance read-API + FindFirstChild() { return undefined; }, + FindFirstChildOfClass() { return undefined; }, + FindFirstAncestor() { return undefined; }, + FindFirstAncestorOfClass() { return undefined; }, + GetChildren() { return []; }, + GetDescendants() { return []; }, + IsA() { return false; }, + GetFullName() { return name || 'stub'; }, + Destroy() {}, + Clone() { return makeObjectStub(name); }, + GetAttribute() { return undefined; }, + SetAttribute() {}, + GetPropertyChangedSignal() { return makeObjectStub('Changed'); }, + // Tool/Animation/Sound — частые no-op методы + Activate() {}, Deactivate() {}, Equip() {}, Unequip() {}, + Play() {}, Stop() {}, Pause() {}, Resume() {}, + AdjustSpeed() {}, LoadAnimation() { return makeObjectStub('Animation'); }, + TakeDamage() {}, MoveTo() {}, + // Базовые поля + Parent: undefined, + Name: name || 'stub', + ClassName: 'Folder', + Children: [], + }; + target.WaitForChild = function (childName) { return makeObjectStub(childName); }; + return new Proxy(target, { get(t, prop) { if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) { return t[prop]; @@ -301,12 +326,11 @@ function makeCallableStub(name) { prop.startsWith('__') || prop.startsWith('Symbol')) { return undefined; } - const child = makeCallableStub(prop); + const child = makeObjectStub(prop); t[prop] = child; return child; }, set(t, prop, value) { t[prop] = value; return true; }, - apply() { return fnTarget; }, // obj() возвращает себя }); } @@ -354,10 +378,10 @@ function newInstance(className, name) { prop.startsWith('__') || prop.startsWith('Symbol')) { return undefined; } - // Callable-stub: можно вызвать как функцию, как сигнал (:Connect), - // как объект (.Property), как child Instance (.WaitForChild). Не падает - // на любом обращении со стороны импортированных скриптов. - const stub = makeCallableStub(prop); + // Object-stub: ведёт себя как сигнал (Connect), как Instance + // (WaitForChild, GetChildren), как Tool (Activate). НЕ function — + // иначе Lua упадёт с "attempt to index a function value". + const stub = makeObjectStub(prop); t[prop] = stub; return stub; }, -- 2.47.2 From c5b713fd1f5a9bb32a57419178a62a85ba6cf826 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:19:21 +0300 Subject: [PATCH 20/39] =?UTF-8?q?feat(rbxl):=20=D0=B8=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D1=8B=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Типичная .rbxl карта = 500-2000 Lua-скриптов. Многие используют DataStoreService, Tools, PlayerGui, UserInputService, и т.п. фичи которых у нас нет. Даже с object-stub Proxy сотни runtime-падений после init подвешивают вкладку. Решение: импорт сохраняет geometry+GUI+физику, скрипты пропускаются. Юзер пишет свои Lua-скрипты к импортированным примитивам — они используют наш Этап 1-7 API (Vector3, Touched, Position-setters, Sound, TweenService) и работают идеально. Включить старое поведение: window.__RBXL_RUN_IMPORTED = true в DevTools перед Play. --- src/editor/engine/GameRuntime.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 0bf3b6e..99072d9 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -121,8 +121,16 @@ export class GameRuntime { // скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox. // .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua. const luaUserBatch = []; + // По умолчанию импортированные .rbxl-скрипты НЕ выполняются: + // - типичная Roblox-карта = 500-2000 скриптов, многие используют + // DataStore/Tool/PlayerGui/UserInputService, которых у нас нет; + // - даже с stub'ами сотни падений → tab подвисает; + // Юзер может включить через window.__RBXL_RUN_IMPORTED = true в консоли. + const runImportedRbxl = typeof window !== 'undefined' && window.__RBXL_RUN_IMPORTED === true; + let rbxlSkipped = 0; for (const s of scripts) { if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { + if (!runImportedRbxl) { rbxlSkipped++; continue; } const luaSource = unpackRobloxLuaCode(s.code); if (luaSource && luaSource.trim()) { luaUserBatch.push({ @@ -212,6 +220,9 @@ export class GameRuntime { if (rbxlImported > 0) { this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlImported}`); } + if (rbxlSkipped > 0) { + this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped}. Включить: window.__RBXL_RUN_IMPORTED = true`); + } if (luaWritten > 0) { this._log('info', `Запущено Lua-скриптов юзера: ${luaWritten}`); } -- 2.47.2 From 5342c079d11f00dee1656c94cfccfc912e3bb886 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:26:30 +0300 Subject: [PATCH 21/39] =?UTF-8?q?feat(lua):=20=D0=B2=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D0=BB=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20+=20=D0=B7=D0=B0=D1=89=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BE=D1=82=20=D0=BF=D0=BE=D0=B4=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. TweenInfo.new(time, easing, direction, repeat, reverses, delay) — глобальный конструктор. Был причиной 'attempt to index nil (TweenInfo)'. 2. Расширенный Enum: InfoType, SortOrder, FillDirection, Font, TextXAlignment, ScaleType, PartType, SurfaceType, UserInputState и др. — типичные для Roblox-туториалов. 3. NumberSequence/ColorSequence/NumberRange/Rect — заглушки конструкторов. 4. _kickoff() теперь батчами по 20 скриптов через setTimeout(0). 742 скрипта инициализируются за ~37 фреймов вместо одного синхронного блока, UI не подвисает. 5. Импортированные .rbxl-скрипты ВКЛЮЧЕНЫ по умолчанию (window.__RBXL_SKIP_IMPORTED=true чтобы выключить). Падения отдельных скриптов изолированы — tickScheduler ловит ошибки и удаляет битые coroutines, остальные продолжают работать. --- src/editor/engine/GameRuntime.js | 13 +++---- src/editor/engine/lua/LuaSharedSandbox.js | 25 +++++++++++- src/editor/engine/lua/RobloxShim.js | 47 +++++++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 99072d9..a66c54c 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -121,12 +121,11 @@ export class GameRuntime { // скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox. // .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua. const luaUserBatch = []; - // По умолчанию импортированные .rbxl-скрипты НЕ выполняются: - // - типичная Roblox-карта = 500-2000 скриптов, многие используют - // DataStore/Tool/PlayerGui/UserInputService, которых у нас нет; - // - даже с stub'ами сотни падений → tab подвисает; - // Юзер может включить через window.__RBXL_RUN_IMPORTED = true в консоли. - const runImportedRbxl = typeof window !== 'undefined' && window.__RBXL_RUN_IMPORTED === true; + // Импортированные .rbxl-скрипты выполняются по умолчанию (батчами по 20 + // через setTimeout, чтобы не подвешивать UI). Падения скриптов изолированы + // pcall'ом в shim — один битый скрипт не валит остальных. + // Выключить можно через window.__RBXL_SKIP_IMPORTED = true. + const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true); let rbxlSkipped = 0; for (const s of scripts) { if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { @@ -221,7 +220,7 @@ export class GameRuntime { this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlImported}`); } if (rbxlSkipped > 0) { - this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped}. Включить: window.__RBXL_RUN_IMPORTED = true`); + this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped}. Вернуть: убрать window.__RBXL_SKIP_IMPORTED`); } if (luaWritten > 0) { this._log('info', `Запущено Lua-скриптов юзера: ${luaWritten}`); diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index 12a5f53..f2943e5 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -109,10 +109,33 @@ export class LuaSharedSandbox { this._isKickedOff = true; // eslint-disable-next-line no-console console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`); - for (const entry of this._pendingScripts) this._startSingleScript(entry); + const pending = this._pendingScripts; this._pendingScripts = []; + // Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines. this._lastTickAt = performance.now(); this._startMainLoop(); + // Init батчами по 20 с yield между ними, чтобы UI не подвисал на 700+ скриптах. + const BATCH_SIZE = 20; + let idx = 0; + const initBatch = () => { + if (this._isStopped) return; + const end = Math.min(idx + BATCH_SIZE, pending.length); + for (let i = idx; i < end; i++) { + try { this._startSingleScript(pending[i]); } + catch (e) { + // eslint-disable-next-line no-console + console.error('[LuaSharedSandbox] init batch err:', e); + } + } + idx = end; + if (idx < pending.length) { + setTimeout(initBatch, 0); + } else { + // eslint-disable-next-line no-console + console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`); + } + }; + setTimeout(initBatch, 0); } _startSingleScript(entry) { diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 9b50bdb..533f016 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -541,6 +541,53 @@ export function registerRobloxShim(lua, opts) { HumanoidStateType: mkE(['Running','Jumping','Freefall','Landed','Dead','Climbing','Swimming','Seated']), EasingStyle: mkE(['Linear','Sine','Quad','Cubic','Quart','Quint','Bounce','Elastic']), EasingDirection: mkE(['In','Out','InOut']), + // Часто используемые в туториалах + InfoType: mkE(['Asset','BundleDetails','Subscription','GamePass','UserProductsInExperience']), + SortOrder: mkE(['Name','Custom','LayoutOrder']), + FillDirection: mkE(['Horizontal','Vertical']), + HorizontalAlignment: mkE(['Left','Center','Right']), + VerticalAlignment: mkE(['Top','Center','Bottom']), + Font: mkE(['Legacy','Arial','SourceSans','Code','Highway','SciFi','Cartoon','Gotham','GothamBold']), + TextXAlignment: mkE(['Left','Center','Right']), + TextYAlignment: mkE(['Top','Center','Bottom']), + ScaleType: mkE(['Stretch','Slice','Tile','Fit','Crop']), + AspectType: mkE(['FitWithinMaxSize','ScaleWithParentSize']), + DominantAxis: mkE(['Width','Height']), + BorderMode: mkE(['Outline','Middle','Inset']), + FormFactor: mkE(['Symmetric','Brick','Plate','Custom']), + PartType: mkE(['Ball','Block','Cylinder','Wedge','CornerWedge']), + SurfaceType: mkE(['Smooth','Glue','Weld','Studs','Inlet','Universal']), + ContextActionResult: mkE(['Pass','Sink']), + UserInputState: mkE(['Begin','Change','End','Cancel','None']), + }); + + // TweenInfo — конструктор объекта с параметрами анимации + // Сигнатура: TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime) + global.set('TweenInfo', { + new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime) { + return { + Time: time || 1, + EasingStyle: easingStyle, + EasingDirection: easingDirection, + RepeatCount: repeatCount || 0, + Reverses: !!reverses, + DelayTime: delayTime || 0, + }; + }, + }); + + // NumberSequence, ColorSequence — упрощённые конструкторы для GUI-эффектов + global.set('NumberSequence', { + new(...args) { return { Keypoints: [], __ns: true }; }, + }); + global.set('ColorSequence', { + new(...args) { return { Keypoints: [], __cs: true }; }, + }); + global.set('NumberRange', { + new(min, max) { return { Min: min, Max: max == null ? min : max }; }, + }); + global.set('Rect', { + new(minX, minY, maxX, maxY) { return { Min: { X: minX, Y: minY }, Max: { X: maxX, Y: maxY } }; }, }); // === print / warn === -- 2.47.2 From 8febde97277c6fdb2908f990cddf1c9c1c2fab37 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:29:54 +0300 Subject: [PATCH 22/39] =?UTF-8?q?revert:=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?.rbxl-=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D1=8B=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Попытка выполнять Roblox-скрипты массово подвешивает страницу — даже с object-stub Proxy и батчевым init. У типичной карты 500-2000 скриптов, которые гоняют DataStore/Tools/RemoteFunction/PlayerGui — наш runtime их не имеет и не должен иметь (это AGPL Roblox-клон, не эмулятор). Импорт .rbxl теперь = ВИЗУАЛЬНЫЙ ПОРТЕР: - геометрия, материалы, текстуры — да - GUI (статические TextLabel/Button) — да - физика, анимации игрока — да - логика игры — пишется на нашем Lua (Этапы 1-7) Юзер импортирует Roblox-карту → видит её точно → пишет свои скрипты к примитивам, используя Vector3, Touched, Position-setters, Sound, TweenService. Это работает идеально и без подвисаний. Энтузиасты могут включить старое поведение через window.__RBXL_RUN_IMPORTED = true перед Play. --- src/editor/engine/GameRuntime.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index a66c54c..42f45ee 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -121,11 +121,14 @@ export class GameRuntime { // скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox. // .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua. const luaUserBatch = []; - // Импортированные .rbxl-скрипты выполняются по умолчанию (батчами по 20 - // через setTimeout, чтобы не подвешивать UI). Падения скриптов изолированы - // pcall'ом в shim — один битый скрипт не валит остальных. - // Выключить можно через window.__RBXL_SKIP_IMPORTED = true. - const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true); + // Импортированные .rbxl-скрипты НЕ запускаются по умолчанию: + // Roblox-карты содержат сотни скриптов (DataStore, Tools, PlayerGui, + // UserInputService), наш runtime их не реализует — попытка их выполнить + // подвешивает страницу даже с stub'ами. + // Импорт работает как ВИЗУАЛЬНЫЙ ПОРТЕР: геометрия, GUI, материалы, + // физика. Юзер добавляет свои Lua-скрипты под наш Этап 1-7 API. + // Энтузиасты могут включить через: window.__RBXL_RUN_IMPORTED = true. + const runImportedRbxl = typeof window !== 'undefined' && window.__RBXL_RUN_IMPORTED === true; let rbxlSkipped = 0; for (const s of scripts) { if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { @@ -220,7 +223,7 @@ export class GameRuntime { this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlImported}`); } if (rbxlSkipped > 0) { - this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped}. Вернуть: убрать window.__RBXL_SKIP_IMPORTED`); + this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped} (Roblox-скрипты не поддерживаются — пиши свои Lua-скрипты под Этап 1-7 API)`); } if (luaWritten > 0) { this._log('info', `Запущено Lua-скриптов юзера: ${luaWritten}`); -- 2.47.2 From 34062993ee0ffa440e78aba9e2fd698642f4d540 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:35:14 +0300 Subject: [PATCH 23/39] =?UTF-8?q?feat(rbxl):=20=D0=B8=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B0=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=E2=80=94=20=D0=B8=D1=82=D0=B5=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D0=B0=D1=8F=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/GameRuntime.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 42f45ee..3d34ba6 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -121,14 +121,9 @@ export class GameRuntime { // скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox. // .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua. const luaUserBatch = []; - // Импортированные .rbxl-скрипты НЕ запускаются по умолчанию: - // Roblox-карты содержат сотни скриптов (DataStore, Tools, PlayerGui, - // UserInputService), наш runtime их не реализует — попытка их выполнить - // подвешивает страницу даже с stub'ами. - // Импорт работает как ВИЗУАЛЬНЫЙ ПОРТЕР: геометрия, GUI, материалы, - // физика. Юзер добавляет свои Lua-скрипты под наш Этап 1-7 API. - // Энтузиасты могут включить через: window.__RBXL_RUN_IMPORTED = true. - const runImportedRbxl = typeof window !== 'undefined' && window.__RBXL_RUN_IMPORTED === true; + // Импортированные .rbxl-скрипты ВКЛЮЧЕНЫ — итеративно настраиваем API + // под реальные скрипты. Выключить временно: window.__RBXL_SKIP_IMPORTED=true. + const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true); let rbxlSkipped = 0; for (const s of scripts) { if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { -- 2.47.2 From ca92ba1988626c3e40f06adbbea38670b9d809cc Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:39:56 +0300 Subject: [PATCH 24/39] =?UTF-8?q?feat(lua):=20=D0=98=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=201=20RayGun=20=E2=80=94=20Tool/Mouse/?= =?UTF-8?q?BodyForce/Weld/IntValue/BrickColor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поддержка скриптов проекта 2792 (Roblox RayGun Tool, 9 скриптов). Lighting: ClockTime, GetMinutesAfterMidnight, SetMinutesAfterMidnight, GetSunDirection, fog* поля. game:service(name) — старый Roblox API (lowercase alias на GetService). Players: GetPlayerFromCharacter, playerFromCharacter, PlayerAdded, ChildAdded. Instance.new новых типов: - Tool/HopperBin: Equipped/Unequipped/Activated/Grip*/CanBeDropped - IntValue/NumberValue/BoolValue/StringValue/ObjectValue + Value/Changed - BodyForce/BodyVelocity/BodyPosition/BodyGyro + force/Velocity/MaxForce - Weld/Motor6D/HingeConstraint + Part0/Part1/C0/C1 - Sparkles/ParticleEmitter/Fire/PointLight + Enabled/Color/Rate - Mouse: Button1Down/KeyDown signals, Icon, Hit, Target, X/Y Глобалы: BrickColor.new('name'/r,g,b) с палитрой 25+ цветов, Ray.new, Region3.new. Фикс WASM crash: rbx_wait минимум 0.016с (1 кадр) — без этого while true do wait() end делал tight-loop без yield → stack overflow. Добавлен RUBLOX_LUA_API_CHANGELOG.md — журнал что было добавлено для каждой игры (для будущего портирования API в JS-движок). --- RUBLOX_LUA_API_CHANGELOG.md | 100 +++++++++++++++++ src/editor/engine/lua/RobloxShim.js | 164 +++++++++++++++++++++++++++- 2 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 RUBLOX_LUA_API_CHANGELOG.md diff --git a/RUBLOX_LUA_API_CHANGELOG.md b/RUBLOX_LUA_API_CHANGELOG.md new file mode 100644 index 0000000..c7e99f6 --- /dev/null +++ b/RUBLOX_LUA_API_CHANGELOG.md @@ -0,0 +1,100 @@ +# Lua API — журнал изменений + +Файл фиксирует **что было добавлено в Lua-runtime** при работе с реальными +Roblox-играми. Цель — потом продублировать тот же API для **JS-движка** +(на будущее, сейчас работаем только с Lua). + +Формат: дата + что и почему + куда добавлено + надо ли портировать в JS. + +--- + +## 2026-06-08 — Итерация 1: RayGun (проект 2792, 9 скриптов) + +**Контекст:** Roblox-Tool пушка-стрелялка, использует Tool-API, Lighting, +Mouse, Welds, BodyForce, BrickColor, IntValue для leaderboard. + +### Добавлено в `RobloxShim.js` + +**Глобалы:** +- `BrickColor.new("Bright red")` + ~25 цветов (White, Black, Bright red/blue/green, + Pink, Brown, Reddish brown, Cyan, Magenta и др.). Возвращает `{Color, Name, R, G, B}`. +- `Ray.new(origin, direction)` — для raycast (заглушка структуры). +- `Region3.new(min, max)` — куб (заглушка). +- `TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime)` +- `NumberSequence`, `ColorSequence`, `NumberRange`, `Rect` — конструкторы-стабы. + +**Enum расширения:** InfoType, SortOrder, FillDirection, Font, +TextXAlignment/TextYAlignment, ScaleType, AspectType, PartType, SurfaceType, +ContextActionResult, UserInputState, BorderMode, FormFactor. + +**`game` методы:** +- `game:service(name)` (lowercase alias на GetService) — старый Roblox API. +- `game.GetServiceFromName` = alias. +- `game.JobId/PlaceId/GameId/CreatorId/CreatorType` — stub fields. + +**Lighting:** +- `Brightness`, `ClockTime`, `TimeOfDay`, `OutdoorAmbient`, `FogStart/End/Color`. +- `GetMinutesAfterMidnight()`, `SetMinutesAfterMidnight(m)`. +- `GetMoonDirection()`, `GetSunDirection()`. + +**Players:** +- `GetPlayers()`, `GetPlayerFromCharacter(char)`, `playerFromCharacter` alias. +- `PlayerAdded`, `PlayerRemoving`, `ChildAdded` signals. + +**Instance.new новые типы:** +- `Tool` / `HopperBin` — Equipped/Unequipped/Activated/Deactivated signals, + GripForward/Right/Up/Pos, CanBeDropped, RequiresHandle, ToolTip. +- `IntValue` / `NumberValue` / `BoolValue` / `StringValue` / `ObjectValue` / + `CFrameValue` / `Vector3Value` / `Color3Value` / `BrickColorValue` / `RayValue` + — `.Value` + `.Changed` сигнал. +- `BodyForce` / `BodyVelocity` / `BodyPosition` / `BodyGyro` / `BodyAngularVelocity` + / `BodyThrust` — `.force`, `.Velocity`, `.MaxForce`, `.P/.D`. +- `Weld` / `WeldConstraint` / `Motor6D` / `Snap` / `HingeConstraint` / + `BallSocketConstraint` / `RopeConstraint` / `SpringConstraint` — Part0/Part1/C0/C1/Enabled. +- `Sparkles` / `ParticleEmitter` / `Smoke` / `Fire` / `Trail` / `Beam` / + `PointLight` / `SurfaceLight` / `SpotLight` — Enabled/Color/Rate/Lifetime/Brightness/Range. +- `Mouse` — Button1Down/Up, Button2Down/Up, Move, KeyDown/Up, WheelForward/Backward, + Icon, Hit (Position), Target, TargetSurface, X/Y, ViewSizeX/Y. + +### Исправлено + +- `rbx_wait(sec)`: минимум 0.016с (1 кадр). `while true do wait() end` без + аргумента раньше делал tight loop без yield → WASM stack overflow + ("memory access out of bounds"). + +### Надо ли портировать в JS-движок? + +✅ **Да, всё** — это базовый Roblox-совместимый API, который должен работать +независимо от языка скриптов. + +**JS-эквивалент будет такой же структурой:** +- `BrickColor.new("Bright red")` → `new BrickColor("Bright red")` +- `Tool` Equipped/Unequipped → JS-EventEmitter методы +- BodyForce/Weld/Sparkles → JS-классы с теми же полями +- Mouse — глобальный объект `game.mouse` или через `player:GetMouse()`. + +--- + +## Куда добавляется API + +| Источник | Файл | Что туда идёт | +|----------|------|---------------| +| Глобалы (Vector3, Color3, BrickColor, Enum) | `RobloxShim.js` через `global.set` | Конструкторы, Enum-таблицы | +| Instance.new типы | `RobloxShim.js` в ветке `global.set('Instance', {new: ...})` | Tool, BodyForce, Weld, Sparkles и т.д. | +| Сервисы | `RobloxShim.js` через `makeService(name)` | Lighting, Players, RunService и т.д. | +| Wait/Task | `RobloxShim.js` в Lua-prelude (`lua.doStringSync`) | rbx_wait, task.wait | +| Setter Part-свойств | `newPart()` через `Object.defineProperty` | Position, Color, Anchored шлют partSet | +| Команды от Lua к Babylon | `rbxl-lua-integration.js` `handleLuaCommand` | partSet, sceneCreate, sceneDelete | + +--- + +## Принципы расширения API + +1. **No-op > Падение.** Лучше пустой stub-метод чем `nil error`. +2. **Сигналы (`Connect`/`Fire`) всегда есть на любом объекте.** +3. **Coloncall совместимость.** Если есть `Foo.Bar`, обычно делаем и `Foo:Bar` + (lowercase) как alias. +4. **При добавлении нового Instance-типа** — давай ему **все типичные поля** + сразу, не только те что нужны прямо сейчас (Equipped + Unequipped + Activated + вместе, даже если скрипт юзает только Equipped). +5. **Логировать сюда после каждой итерации** — что было добавлено и из какой игры. diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 533f016..63fd219 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -518,6 +518,53 @@ export function registerRobloxShim(lua, opts) { fromHSV: (h, s, v) => RbxColor3.fromHSV(h, s, v), fromHex: (hex) => RbxColor3.fromHex(hex), }); + // BrickColor — старая система цветов Roblox по имени + const BRICK_COLORS = { + 'White': [1, 1, 1], 'Black': [0.1, 0.1, 0.1], 'Grey': [0.6, 0.6, 0.6], + 'Bright red': [0.77, 0.2, 0.2], 'Bright blue': [0.05, 0.4, 0.7], + 'Bright green': [0.3, 0.8, 0.2], 'Bright yellow': [1, 0.85, 0.1], + 'Bright orange': [0.85, 0.5, 0.15], 'Bright violet': [0.45, 0.2, 0.65], + 'Dark blue': [0.05, 0.15, 0.4], 'Dark green': [0.15, 0.4, 0.2], + 'Dark red': [0.4, 0.1, 0.1], 'Lime green': [0.7, 0.95, 0.3], + 'Pink': [1, 0.55, 0.7], 'Brown': [0.4, 0.25, 0.15], + 'Reddish brown': [0.45, 0.2, 0.15], 'Sand red': [0.85, 0.6, 0.55], + 'Medium blue': [0.4, 0.65, 0.85], 'Cyan': [0, 0.8, 0.8], + 'Magenta': [0.85, 0, 0.85], 'Really red': [1, 0, 0], 'Really blue': [0, 0, 1], + 'Really black': [0, 0, 0], 'Really white': [1, 1, 1], + }; + function _brickToColor3(name) { + const rgb = BRICK_COLORS[name] || [0.5, 0.5, 0.5]; + return new RbxColor3(rgb[0], rgb[1], rgb[2]); + } + global.set('BrickColor', { + new(nameOrR, g, b) { + // BrickColor.new("Bright red") или BrickColor.new(r, g, b) + const name = typeof nameOrR === 'string' ? nameOrR : 'White'; + const c = typeof nameOrR === 'string' + ? _brickToColor3(nameOrR) + : new RbxColor3(nameOrR, g, b); + return { Color: c, Name: name, Number: 1, R: c.R, G: c.G, B: c.B, + r: c.R, g: c.G, b: c.B }; + }, + random() { return { Color: new RbxColor3(Math.random(), Math.random(), Math.random()), Name: 'Random' }; }, + White() { return this.new('White'); }, + Black() { return this.new('Black'); }, + Gray() { return this.new('Grey'); }, + Red() { return this.new('Bright red'); }, + Yellow() { return this.new('Bright yellow'); }, + Green() { return this.new('Bright green'); }, + Blue() { return this.new('Bright blue'); }, + DarkGray() { return this.new('Dark stone grey'); }, + palette(n) { return this.new('White'); }, + }); + // Ray — луч, используется в raycast + global.set('Ray', { + new(origin, direction) { return { Origin: origin, Direction: direction }; }, + }); + // Region3 — куб в пространстве + global.set('Region3', { + new(min, max) { return { Min: min, Max: max, CFrame: { Position: min }, Size: max }; }, + }); global.set('UDim', { new: (s, o) => new RbxUDim(s, o) }); global.set('UDim2', { new: (sx, ox, sy, oy) => new RbxUDim2(sx, ox, sy, oy), @@ -807,7 +854,23 @@ export function registerRobloxShim(lua, opts) { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 3 | 8)).toString(16); }); - makeService('Lighting').Ambient = new RbxColor3(0.5, 0.5, 0.5); + const lighting = makeService('Lighting'); + lighting.Ambient = new RbxColor3(0.5, 0.5, 0.5); + lighting.Brightness = 1; + lighting.ClockTime = 14; + lighting.TimeOfDay = "14:00:00"; + lighting.OutdoorAmbient = new RbxColor3(0.5, 0.5, 0.5); + lighting.FogEnd = 100000; + lighting.FogStart = 0; + lighting.FogColor = new RbxColor3(0.75, 0.75, 0.75); + lighting._minutes = 14 * 60; + lighting.GetMinutesAfterMidnight = function () { return lighting._minutes; }; + lighting.SetMinutesAfterMidnight = function (m) { + lighting._minutes = (Number(m) || 0) % 1440; + lighting.ClockTime = lighting._minutes / 60; + }; + lighting.GetMoonDirection = function () { return new RbxVector3(0, 1, 0); }; + lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); }; makeService('Chat'); const soundService = makeService('SoundService'); soundService.PlayLocalSound = function (sound) { @@ -843,7 +906,28 @@ export function registerRobloxShim(lua, opts) { if (name === 'Players') return players; return services[name] || makeService(name); }; + // Старый Roblox API: game:service(name) lowercase + game.service = game.GetService; + game.GetServiceFromName = game.GetService; game.FindService = function (name) { return services[name] || null; }; + game.JobId = ''; + game.PlaceId = 0; + game.GameId = 0; + game.CreatorId = 0; + game.CreatorType = 'User'; + + // Players API extensions + players.GetPlayers = function () { return [players.LocalPlayer].filter(Boolean); }; + players.GetPlayerFromCharacter = function (character) { + if (character && players.LocalPlayer && players.LocalPlayer.Character === character) { + return players.LocalPlayer; + } + return undefined; + }; + players.playerFromCharacter = players.GetPlayerFromCharacter; + players.PlayerAdded = makeSignal(); + players.PlayerRemoving = makeSignal(); + players.ChildAdded = makeSignal(); global.set('game', game); global.set('Game', game); @@ -1090,6 +1174,80 @@ export function registerRobloxShim(lua, opts) { || className === 'ScrollingFrame') { inst = newGuiInstance(className); guiByLocalRef.set(inst.__guiLocalRef, inst); + } else if (className === 'Tool' || className === 'HopperBin') { + // Tool — оружие в Roblox. У нас нет инвентаря, поэтому это + // просто контейнер с правильными событиями + Handle (заглушка). + inst = newInstance(className, 'Tool'); + inst.Equipped = makeSignal(); + inst.Unequipped = makeSignal(); + inst.Activated = makeSignal(); + inst.Deactivated = makeSignal(); + inst.GripForward = new RbxVector3(0, -1, 0); + inst.GripRight = new RbxVector3(1, 0, 0); + inst.GripUp = new RbxVector3(0, 1, 0); + inst.GripPos = new RbxVector3(0, 0, 0); + inst.CanBeDropped = true; + inst.Enabled = true; + inst.RequiresHandle = true; + inst.TextureId = ''; + inst.ToolTip = ''; + } else if (className === 'IntValue' || className === 'NumberValue' + || className === 'BoolValue' || className === 'StringValue' + || className === 'ObjectValue' || className === 'CFrameValue' + || className === 'Vector3Value' || className === 'Color3Value' + || className === 'BrickColorValue' || className === 'RayValue') { + inst = newInstance(className, className); + inst.Value = className === 'BoolValue' ? false + : className === 'StringValue' ? '' + : (className === 'IntValue' || className === 'NumberValue') ? 0 + : undefined; + inst.Changed = makeSignal(); + } else if (className === 'BodyForce' || className === 'BodyVelocity' + || className === 'BodyPosition' || className === 'BodyGyro' + || className === 'BodyAngularVelocity' || className === 'BodyThrust') { + inst = newInstance(className, className); + inst.force = new RbxVector3(0, 0, 0); + inst.Force = inst.force; + inst.Velocity = new RbxVector3(0, 0, 0); + inst.MaxForce = new RbxVector3(0, 0, 0); + inst.P = 1000; inst.D = 100; + } else if (className === 'Weld' || className === 'WeldConstraint' + || className === 'Motor6D' || className === 'Snap' + || className === 'HingeConstraint' || className === 'BallSocketConstraint' + || className === 'RopeConstraint' || className === 'SpringConstraint') { + inst = newInstance(className, className); + inst.Part0 = undefined; inst.Part1 = undefined; + inst.C0 = { Position: new RbxVector3(0, 0, 0) }; + inst.C1 = { Position: new RbxVector3(0, 0, 0) }; + inst.Enabled = true; + } else if (className === 'Sparkles' || className === 'ParticleEmitter' + || className === 'Smoke' || className === 'Fire' || className === 'Trail' + || className === 'Beam' || className === 'PointLight' + || className === 'SurfaceLight' || className === 'SpotLight') { + inst = newInstance(className, className); + inst.Enabled = true; + inst.Color = new RbxColor3(1, 1, 1); + inst.Rate = 20; + inst.Lifetime = { Min: 1, Max: 1 }; + inst.Brightness = 1; + inst.Range = 8; + } else if (className === 'Mouse') { + inst = newInstance('Mouse', 'Mouse'); + inst.Button1Down = makeSignal(); + inst.Button1Up = makeSignal(); + inst.Button2Down = makeSignal(); + inst.Button2Up = makeSignal(); + inst.Move = makeSignal(); + inst.KeyDown = makeSignal(); + inst.KeyUp = makeSignal(); + inst.WheelForward = makeSignal(); + inst.WheelBackward = makeSignal(); + inst.Icon = ''; + inst.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0) }; + inst.Target = undefined; + inst.TargetSurface = 'Top'; + inst.X = 0; inst.Y = 0; + inst.ViewSizeX = 1920; inst.ViewSizeY = 1080; } else { inst = newInstance(className, className); } @@ -1142,6 +1300,10 @@ export function registerRobloxShim(lua, opts) { lua.doStringSync(` local function rbx_wait(sec) sec = sec or 0 + -- Минимум 1 кадр (≈0.0166с). wait() и wait(0) в Roblox ждут до + -- следующего Heartbeat — без этого while true do wait() end + -- стал бы tight loop без yield и упёрся в WASM stack overflow. + if sec < 0.016 then sec = 0.016 end local ret = coroutine.yield(sec) return ret or sec end -- 2.47.2 From 3271e53acf65511e6015d6f38dfa4847b141a7c6 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:43:09 +0300 Subject: [PATCH 25/39] =?UTF-8?q?feat(rbxl):=20=D1=83=D0=B2=D0=B0=D0=B6?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20enabled=3Dfalse=20=D0=B8=D0=B7=20Roblox-?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=B0=D0=B4=D0=B0=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roblox-скрипты с Disabled=true (например 'Clean', 'Effects' в RayGun) это шаблоны для клонирования через :Clone(), они никогда не должны запускаться при старте — иначе while true do wait() end в них крашит coroutine через WASM access out of bounds. parseRobloxLuaMeta(code) парсит JSON-метадату из второй строки packed-кода (формат '// {"roblox_class":..., "enabled":true}'). Скрипты с enabled=false идут в rbxlSkipped, не запускаются. --- RUBLOX_LUA_API_CHANGELOG.md | 5 +++++ src/editor/engine/GameRuntime.js | 7 ++++++- src/editor/engine/rbxl-lua-integration.js | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/RUBLOX_LUA_API_CHANGELOG.md b/RUBLOX_LUA_API_CHANGELOG.md index c7e99f6..2a15502 100644 --- a/RUBLOX_LUA_API_CHANGELOG.md +++ b/RUBLOX_LUA_API_CHANGELOG.md @@ -62,6 +62,11 @@ ContextActionResult, UserInputState, BorderMode, FormFactor. аргумента раньше делал 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`. + ### Надо ли портировать в JS-движок? ✅ **Да, всё** — это базовый Roblox-совместимый API, который должен работать diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 3d34ba6..5e65d13 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -19,7 +19,7 @@ import { ScriptSandbox } from './ScriptSandbox'; import { STORYS_addres } from '../../api/API'; import { PhysicsWorld } from './PhysicsWorld'; import { LabelManager } from './LabelManager'; -import { handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js'; +import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js'; import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js'; export class GameRuntime { @@ -128,6 +128,11 @@ export class GameRuntime { for (const s of scripts) { if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { if (!runImportedRbxl) { rbxlSkipped++; continue; } + // Уважаем поле enabled=false из Roblox-метадаты: такие скрипты + // были disabled-шаблоны (для клонирования через :Clone()), их + // запуск немедленно крашит coroutine (WASM access out of bounds). + const meta = parseRobloxLuaMeta(s.code); + if (meta && meta.enabled === false) { rbxlSkipped++; continue; } const luaSource = unpackRobloxLuaCode(s.code); if (luaSource && luaSource.trim()) { luaUserBatch.push({ diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index ae1ce2e..cbef7f7 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -20,6 +20,20 @@ export function unpackRobloxLuaCode(code) { return code.slice(start, closeIdx); } +/** Парсит JSON-метадату из 2-й строки packed-кода (`// {"roblox_class":..., "enabled": true}`). */ +export function parseRobloxLuaMeta(code) { + if (typeof code !== 'string') return null; + const lines = code.split('\n'); + if (lines.length < 2) return null; + const metaLine = lines[1]; + if (!metaLine.startsWith('// ')) return null; + try { + return JSON.parse(metaLine.slice(3)); + } catch (_) { + return null; + } +} + /** Сцена → snap для shim'а (workspace:GetChildren). */ export function buildLuaSceneSnap(primitives) { const out = { primitives: {} }; -- 2.47.2 From 98640c4bdb9e8c87520978fdd7dce75a75c20afc Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:44:14 +0300 Subject: [PATCH 26/39] =?UTF-8?q?fix(ui):=20badge=20LUA=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20.rbxl-=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D0=B8=D0=BF=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В БД импортированные скрипты хранятся с language='js' но фактически это Lua-код в обёртке // @roblox-lua. HierarchyPanel рисовал жёлтую плашку JS, что вводило в заблуждение. Теперь isLua = (language=='lua') OR code starts with '// @roblox-lua'. --- src/editor/HierarchyPanel.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index 357c573..a7c23d1 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -132,7 +132,11 @@ 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'; + // 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 = ( Date: Mon, 8 Jun 2026 13:57:37 +0300 Subject: [PATCH 27/39] =?UTF-8?q?feat(rbxl):=20Tool/Backpack/Mouse=20flow?= =?UTF-8?q?=20=E2=80=94=20=D0=A8=D0=B0=D0=B3=201/3=20(Zapper)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Цель: запустить Roblox Tools (Zapper и подобные оружия) в плеере. Архитектура: 1. RobloxShim: localPlayer.Backpack, localPlayer:GetMouse(), allTools registry, equippedTool — внутренний учёт текущего Tool. 2. Instance.new('Tool') — теперь автоматически: - создаёт виртуальный Handle (Part) внутри - регистрирует Tool в allTools[] - шлёт 'toolRegistered' в GameRuntime 3. fireGlobalEvent обработка новых событий из плеера: - equipTool {index} → Tool.Equipped:Fire(playerMouse) - unequipTool → Tool.Unequipped:Fire() - toolActivated → Tool.Activated:Fire() - mouseButton1Down {hit} → mouse.Hit.Position + mouse.Button1Down:Fire() - keyDown {key} → mouse.KeyDown:Fire(key) 4. LuaSharedSandbox.addScript принимает toolName, в _startSingleScript подсовывает виртуальный Tool как script.Parent (через __rbxl_get_tool_by_name). 5. GameRuntime эвристика: скрипты с target=null и упоминанием script.Parent.Equipped/Activated → toolName='Tool', группируются в один Tool. 6. GameRuntime._registerRbxlTool: при получении toolRegistered кладёт item в InventoryUI.hotbar, слушает смену слота → equipTool. 7. Клики canvas → mouseButton1Down с raycast Hit.Position. Следующие шаги: - HUD: индикатор экипированного Tool в плеере (Шаг 2) - Leaderboard UI из leaderstats IntValue (Шаг 3) --- .../src/__pycache__/converter.cpython-314.pyc | Bin 0 -> 59456 bytes src/editor/engine/GameRuntime.js | 95 ++++++++++++++- src/editor/engine/lua/LuaSharedSandbox.js | 29 +++-- src/editor/engine/lua/RobloxShim.js | 109 +++++++++++++++++- 4 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 rbxl-importer/src/__pycache__/converter.cpython-314.pyc diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af16ebfa9b81825da1e43612d31bb842b8b90668 GIT binary patch literal 59456 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 { + 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; + } else if (this._rbxlActiveSlot >= 0) { + sb.sendGlobalEvent?.({ type: 'unequipTool' }); + this._rbxlActiveSlot = -1; + } + }); + // Клики мыши при экипированном 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 (_) {} + } + } + + /** Простой raycast от камеры — для mouse.Hit. */ + _raycastFromCamera() { + try { + const cam = this.scene3d?.scene?.activeCamera; + if (!cam) return { x: 0, y: 5, z: 0 }; + const forward = cam.getForwardRay?.()?.direction; + const pos = cam.position; + if (!pos || !forward) return { x: 0, y: 5, z: 0 }; + const t = 50; + return { x: pos.x + forward.x * t, y: pos.y + forward.y * t, z: pos.z + forward.z * t }; + } catch (_) { + return { x: 0, y: 5, z: 0 }; + } + } + stop() { if (this.sandboxes.length > 0) { this._log('info', 'Остановка скриптов'); diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index f2943e5..ce0b15e 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -43,12 +43,13 @@ export class LuaSharedSandbox { get target() { return null; } tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ } - addScript(id, code, target, name) { + 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) { @@ -152,12 +153,26 @@ export class LuaSharedSandbox { // Регистрируем coroutine в __rbxl_coroutines с id для возобновления. // Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает // delay из resume → планируем следующий resume через scheduleResume. - // Fallback Parent = workspace для скриптов без target (или с невалидным - // target). Это спасает массу Roblox-скриптов которые делают - // script.Parent.Parent — если бы Parent был nil, упало бы сразу. - const parentExpr = primId != null - ? `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)` - : 'workspace'; + // Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) — + // подсовываем виртуальный Tool как script.Parent. Иначе primitive по id, + // иначе workspace. + let parentExpr; + if (entry.toolName) { + // Tool создаётся в shim как Instance.new('Tool'). По имени достаём. + // Если не нашли — fallback на новый Tool того же имени. + const safeName = JSON.stringify(entry.toolName); + parentExpr = `(function() + local existing = __rbxl_get_tool_by_name(${safeName}) + if existing then return existing end + local t = Instance.new("Tool") + t.Name = ${safeName} + return t + end)()`; + } else if (primId != null) { + parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`; + } else { + parentExpr = 'workspace'; + } const wrapped = ` do local script = { diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 63fd219..8429f36 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -733,9 +733,48 @@ export function registerRobloxShim(lua, opts) { localPlayer.Children.push(playerGui); localPlayer.PlayerGui = playerGui; localPlayer.DisplayName = 'Player'; + // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически + // клонируется в Backpack каждого спавнящегося игрока. + const backpack = newInstance('Backpack', 'Backpack'); + backpack.Parent = localPlayer; + localPlayer.Children.push(backpack); + localPlayer.Backpack = backpack; + // Глобальный Mouse — единственный экземпляр на игрока, привязан к окну + // браузера. Реальные Button1Down/Hit фейерятся в GameRuntime. + const playerMouse = (function makePlayerMouse() { + const m = newInstance('Mouse', 'Mouse'); + m.Button1Down = makeSignal(); + m.Button1Up = makeSignal(); + m.Button2Down = makeSignal(); + m.Button2Up = makeSignal(); + m.Move = makeSignal(); + m.KeyDown = makeSignal(); + m.KeyUp = makeSignal(); + m.WheelForward = makeSignal(); + m.WheelBackward = makeSignal(); + m.Idle = makeSignal(); + m.Icon = ''; + m.X = 0; m.Y = 0; + m.ViewSizeX = 1920; m.ViewSizeY = 1080; + m.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0), + Lookvector: new RbxVector3(0, 0, -1) }; + m.Origin = { Position: new RbxVector3(0, 5, 0) }; + m.Target = undefined; + m.TargetFilter = undefined; + m.TargetSurface = 'Top'; + return m; + })(); + localPlayer.GetMouse = function () { return playerMouse; }; + localPlayer.playerMouse = playerMouse; players.Children.push(localPlayer); players.LocalPlayer = localPlayer; + // === Tool registry === + // Tracks все Tool-инстансы — для UI (hotbar) и equip-flow. + // GameRuntime читает API equipTool/unequipTool на main-loop. + const allTools = []; // [Tool, ...] в порядке создания (для hotbar 1-9) + let equippedTool = null; + const character = newInstance('Model', 'Player'); character.Parent = localPlayer; localPlayer.Children.push(character); @@ -1175,8 +1214,6 @@ export function registerRobloxShim(lua, opts) { inst = newGuiInstance(className); guiByLocalRef.set(inst.__guiLocalRef, inst); } else if (className === 'Tool' || className === 'HopperBin') { - // Tool — оружие в Roblox. У нас нет инвентаря, поэтому это - // просто контейнер с правильными событиями + Handle (заглушка). inst = newInstance(className, 'Tool'); inst.Equipped = makeSignal(); inst.Unequipped = makeSignal(); @@ -1191,6 +1228,21 @@ export function registerRobloxShim(lua, opts) { inst.RequiresHandle = true; inst.TextureId = ''; inst.ToolTip = ''; + // Виртуальный Handle — Roblox-скрипты делают Tool.Handle.Position + const handle = newInstance('Part', 'Handle'); + handle.Parent = inst; + handle.Position = new RbxVector3(0, 5, 0); + handle.Size = new RbxVector3(1, 1, 1); + inst.Handle = handle; + inst.Children = inst.Children || []; + inst.Children.push(handle); + // Регистрируем Tool, чтобы плеер показал его в hotbar + allTools.push(inst); + inst.__toolIndex = allTools.length; + send('toolRegistered', { + index: inst.__toolIndex, + name: inst.Name || `Tool ${inst.__toolIndex}`, + }); } else if (className === 'IntValue' || className === 'NumberValue' || className === 'BoolValue' || className === 'StringValue' || className === 'ObjectValue' || className === 'CFrameValue' @@ -1265,6 +1317,7 @@ export function registerRobloxShim(lua, opts) { // === Helpers для скриптов === const partById = new Map(); global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined); + global.set('__rbxl_get_tool_by_name', (name) => allTools.find(t => t.Name === name) || undefined); global.set('__rbxl_send_error', (id, errStr) => { send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` }); }); @@ -1452,7 +1505,59 @@ export function registerRobloxShim(lua, opts) { guiEl.MouseButton1Click.Fire(); } } + // Tool equip/unequip — клавиши 1-9 в плеере шлют + // {type:'equipTool', index:N}, {type:'unequipTool'} + if (p.type === 'equipTool') { + const idx = Number(p.index) - 1; + if (idx < 0 || idx >= allTools.length) return; + const tool = allTools[idx]; + if (equippedTool === tool) return; + // Снимаем предыдущий + if (equippedTool) { + try { equippedTool.Unequipped.Fire(); } catch (_) {} + } + equippedTool = tool; + // В Roblox Tool при equip перемещается в Character + tool.Parent = character; + try { tool.Equipped.Fire(playerMouse); } catch (_) {} + } + if (p.type === 'unequipTool') { + if (!equippedTool) return; + try { equippedTool.Unequipped.Fire(); } catch (_) {} + equippedTool.Parent = backpack; + equippedTool = null; + } + if (p.type === 'toolActivated') { + if (!equippedTool) return; + try { equippedTool.Activated.Fire(); } catch (_) {} + } + if (p.type === 'toolDeactivated') { + if (!equippedTool) return; + try { equippedTool.Deactivated.Fire(); } catch (_) {} + } + // Mouse-события из плеера: клики, движение, клавиши при equipped Tool + if (p.type === 'mouseButton1Down') { + if (p.hit) { + playerMouse.Hit.Position = new RbxVector3(p.hit.x, p.hit.y, p.hit.z); + playerMouse.Hit.p = playerMouse.Hit.Position; + } + try { playerMouse.Button1Down.Fire(); } catch (_) {} + } + if (p.type === 'mouseButton1Up') { + try { playerMouse.Button1Up.Fire(); } catch (_) {} + } + if (p.type === 'keyDown') { + try { playerMouse.KeyDown.Fire(String(p.key || '').toLowerCase()); } catch (_) {} + } + if (p.type === 'keyUp') { + try { playerMouse.KeyUp.Fire(String(p.key || '').toLowerCase()); } catch (_) {} + } }, + // Tool registry (для GameRuntime: какой Tool сделать script.Parent) + getToolByName(name) { + return allTools.find(t => t.Name === name); + }, + getAllTools() { return allTools.slice(); }, // Доступ к ключевым объектам (для тестов и отладки) partById, localPlayer, humanoid, character, workspace, players, game, }; -- 2.47.2 From 43f701f1c43f6b72e029d001cb0a503fd9d33377 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 13:58:23 +0300 Subject: [PATCH 28/39] =?UTF-8?q?docs(lua):=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20CHANGELOG=20=D0=B4=D0=BB=D1=8F=20=D0=A8?= =?UTF-8?q?=D0=B0=D0=B3=D0=B0=201=20Tool/Backpack/Mouse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RUBLOX_LUA_API_CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/RUBLOX_LUA_API_CHANGELOG.md b/RUBLOX_LUA_API_CHANGELOG.md index 2a15502..16f8e36 100644 --- a/RUBLOX_LUA_API_CHANGELOG.md +++ b/RUBLOX_LUA_API_CHANGELOG.md @@ -67,6 +67,49 @@ ContextActionResult, UserInputState, BorderMode, FormFactor. не должны запускаться при старте. `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, который должен работать -- 2.47.2 From bb0726b4ad116bcb6be731aa11c6d283a73d1444 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 14:02:16 +0300 Subject: [PATCH 29/39] =?UTF-8?q?revert(lua):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B5=D0=B4=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20leaderstats=20scan=20(=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=B8=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=BE=20=D0=BD=D0=B5=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/lua/RobloxShim.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 8429f36..bb6b7af 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1314,6 +1314,11 @@ export function registerRobloxShim(lua, opts) { }, }); + // === Leaderboard scan === + // Roblox-скрипт делает: Instance.new('IntValue').Name='leaderstats', + // stats.Parent = newPlayer, потом IntValue Reputation/Level внутри. + // Поскольку наш Lua не вызывает Children.push при Parent= (Lua делает rawset), + // мы периодически сканируем localPlayer на наличие leaderstats и шлём в плеер. // === Helpers для скриптов === const partById = new Map(); global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined); -- 2.47.2 From 52724ab9c8cf6c3ba241d4316778c9b1e2e6e855 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 14:08:09 +0300 Subject: [PATCH 30/39] =?UTF-8?q?fix(rbxl):=20invUI=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20inventory=20+=20Day/Night=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20+=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D1=8B=20Sparkles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что чинит: 1. _registerRbxlTool падал: использовал scene3d.inventory (InventoryManager, старый, без defineItem). Меняю на scene3d.invUI (новый InventoryUI с defineItem) — теперь hotbar реально заполняется. 2. Lighting.SetMinutesAfterMidnight теперь шлёт lightingTimeUpdate в GameRuntime → scene3d.setTimeOfDay(hour). Тротлинг 4 раза/сек. Roblox Day&Night скрипт теперь визуально меняет небо в нашем плеере! 3. Instance.new('Sparkles') шлёт particleCreated в GameRuntime. При эквипе Tool — _startRbxlToolParticles() запускает каждые 200мс burst у позиции игрока (имитация искр из руки). 4. Авто-эквип первого Tool через 100мс после регистрации — юзеру не нужно нажимать 1, инвентарь не очевиден. 5. stop() корректно гасит интервалы и сбрасывает state. Эти 4 фикса должны дать Zapper-демке базовое визуальное поведение: видный hotbar, искры из персонажа, плавная смена дня/ночи. --- src/editor/engine/GameRuntime.js | 81 ++++++++++++++++++++++++++++- src/editor/engine/lua/RobloxShim.js | 9 ++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index c6fad87..273ed31 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -211,6 +211,24 @@ export class GameRuntime { try { this._registerRbxlTool(payload); } catch (e) { console.warn('[GameRuntime] toolRegistered failed', e); } + } else if (cmd === 'lightingTimeUpdate') { + // Roblox Lighting:SetMinutesAfterMidnight → Babylon небо. + // Скрипты делают это каждый кадр — троттлим до 4 раз/сек. + const now = performance.now(); + if (!this._lastLightUpdate || now - this._lastLightUpdate > 250) { + this._lastLightUpdate = now; + try { + const hour = Number(payload?.hour); + if (hour >= 0 && hour < 24) { + this.scene3d?.setTimeOfDay?.(hour); + } + } catch (_) {} + } + } else if (cmd === 'particleCreated') { + // Roblox Instance.new('Sparkles') — запомнили какие + // partlcle-эффекты есть у Tool. При equip покажем у руки. + this._rbxlPendingParticles = this._rbxlPendingParticles || []; + this._rbxlPendingParticles.push(payload); } else { this._handleCommand(null, cmd, payload); } @@ -545,8 +563,12 @@ export class GameRuntime { * Слушает клики ЛКМ → шлёт mouseButton1Down (Tool.Activated fires там). */ _registerRbxlTool(payload) { if (!payload || payload.index == null) return; - const invUI = this.scene3d?.inventory; - if (!invUI) 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({ @@ -565,6 +587,19 @@ export class GameRuntime { 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]; @@ -574,9 +609,11 @@ export class GameRuntime { 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 @@ -602,6 +639,41 @@ export class GameRuntime { } } + /** Запускает непрерывный эмиттер 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 { @@ -624,6 +696,11 @@ export class GameRuntime { console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes'); for (const sb of this.sandboxes) sb.stop(); } + // Останавливаем эффекты импортированных Tools + this._stopRbxlToolParticles?.(); + this._rbxlToolHooks = false; + this._rbxlActiveSlot = -1; + this._rbxlPendingParticles = null; // Удаляем все объекты, которые скрипты наспавнили через // game.scene.spawn/clone — иначе после Stop они остаются на сцене // и накапливаются при повторных запусках. diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index bb6b7af..5136f53 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -907,6 +907,8 @@ export function registerRobloxShim(lua, opts) { lighting.SetMinutesAfterMidnight = function (m) { lighting._minutes = (Number(m) || 0) % 1440; lighting.ClockTime = lighting._minutes / 60; + // Шлём в GameRuntime для обновления реального неба Babylon + send('lightingTimeUpdate', { hour: lighting.ClockTime }); }; lighting.GetMoonDirection = function () { return new RbxVector3(0, 1, 0); }; lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); }; @@ -1283,6 +1285,13 @@ export function registerRobloxShim(lua, opts) { inst.Lifetime = { Min: 1, Max: 1 }; inst.Brightness = 1; inst.Range = 8; + inst.__particleKind = className.toLowerCase(); + // Шлём событие "создан particle-effect" — GameRuntime может его + // привязать к мешу на сцене (например, рукам игрока). + send('particleCreated', { + kind: inst.__particleKind, + color: [inst.Color.R, inst.Color.G, inst.Color.B], + }); } else if (className === 'Mouse') { inst = newInstance('Mouse', 'Mouse'); inst.Button1Down = makeSignal(); -- 2.47.2 From d750c94a784341ec16cc3a109d003ba09ff82b99 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 15:39:16 +0300 Subject: [PATCH 31/39] =?UTF-8?q?fix(lua):=20Signal=20Fire=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82=20Lua-handler=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=BE=D0=B1=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20coroutine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roblox-скрипты делают: Tool.Equipped:connect(function(mouse) wait(0.15) -- yield внутри handler! mouse.Icon = ... end) Когда сигнал Fired из JS-стороны (наш equipTool flow), мы напрямую звали Lua-функцию — но Lua-yield в JS-callback падает с 'attempt to yield across a C-call boundary'. Фикс: новая Lua-функция __rbxl_run_in_coroutine(fn, ...args) создаёт свежую coroutine из handler'а, регистрирует в scheduler, делает первый resume. Если handler уйдёт в wait — это yield в свою coroutine, не через C-boundary. Scheduler tickScheduler потом возобновит её через delay. Это закрывает RayGun.onEquippedLocal с wait(0.15), а также любые другие Roblox-обработчики использующие wait() — в Roblox это стандарт. --- src/editor/engine/lua/RobloxShim.js | 40 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 5136f53..efc236f 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -25,6 +25,11 @@ const SCHEDULER = { const HEARTBEAT_SIGNAL = makeSignal(); const STEPPED_SIGNAL = makeSignal(); +// Глобальный helper для запуска Lua-handler'ов в собственной coroutine. +// Без этого Roblox-обработчики которые внутри делают wait() падают с +// "attempt to yield across a C-call boundary". +let _runHandlerInCoroutine = null; + function makeSignal() { const sig = { __isSignal: true, @@ -45,9 +50,16 @@ function makeSignal() { sig.connect = sig.Connect; sig.Fire = function (...args) { for (const fn of [...sig.connections]) { - try { fn(...args); } catch (e) { - // eslint-disable-next-line no-console - console.error('[Signal handler]', e); + // Запускаем handler в его собственной coroutine — это позволяет + // делать wait() внутри без yield-across-C-boundary ошибки. + if (_runHandlerInCoroutine) { + try { _runHandlerInCoroutine(fn, args); } catch (e) { + console.error('[Signal handler]', e); + } + } else { + try { fn(...args); } catch (e) { + console.error('[Signal handler]', e); + } } } }; @@ -1389,7 +1401,29 @@ export function registerRobloxShim(lua, opts) { if type(ret) == 'number' then return ret end return 0 end + + -- Запуск Lua-handler'а в собственной coroutine. + -- Используется при Fire сигнала из JS — иначе wait() внутри handler'а + -- падает с 'attempt to yield across a C-call boundary'. + __rbxl_next_handler_id = 0 + function __rbxl_run_in_coroutine(fn, a1, a2, a3, a4) + __rbxl_next_handler_id = __rbxl_next_handler_id + 1 + local handlerId = "handler_" .. __rbxl_next_handler_id + local co = coroutine.create(function() fn(a1, a2, a3, a4) end) + __rbxl_register_coroutine(handlerId, co) + local ok, ret = coroutine.resume(co) + if not ok then + __rbxl_send_error(handlerId, tostring(ret)) + __rbxl_unregister_coroutine(handlerId) + elseif type(ret) == 'number' then + __rbxl_schedule_resume(handlerId, ret) + elseif coroutine.status(co) == 'dead' then + __rbxl_unregister_coroutine(handlerId) + end + end `); + // Кешируем ссылку на функцию для использования из makeSignal + _runHandlerInCoroutine = lua.global.get('__rbxl_run_in_coroutine'); // Добавим Lua-side helper для лога global.set('__log', (level, text) => { send('log', { level: String(level || 'info'), text: String(text || '') }); -- 2.47.2 From 03a6c357d015a79e905c8f4227faaec6c13cb06d Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 15:41:55 +0300 Subject: [PATCH 32/39] =?UTF-8?q?fix(lua):=20Signal.Fire=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C?= =?UTF-8?q?=20handler'=D0=BE=D0=B2=20=D0=B2=20tickScheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прошлый фикс с __rbxl_run_in_coroutine падал внутри wasmoon с 'Cannot read properties of null (reading then)' — wasmoon PromiseTypeExtension тщетно ловит return от Lua-функции. Новая стратегия: 1. Signal.Fire не запускает handler синхронно — складывает в JS-очередь _pendingHandlerQueue. 2. tickScheduler в начале каждого тика drain'ит очередь, для каждого handler'а вызывает Lua-функцию __rbxl_drain_handler в coroutine. 3. Поскольку tickScheduler уже стоит на main loop (не из JS-callback), wait() внутри handler'а корректно yield'ится в свою coroutine. Это разрешает: - Roblox-обработчики с wait() внутри (Tool.Equipped с reload-таймаут) - Любые цепочки signal:Connect → wait → action - Стандартные шаблоны Roblox-Lua flow. --- src/editor/engine/lua/RobloxShim.js | 51 ++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index efc236f..246e050 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -25,10 +25,11 @@ const SCHEDULER = { const HEARTBEAT_SIGNAL = makeSignal(); const STEPPED_SIGNAL = makeSignal(); -// Глобальный helper для запуска Lua-handler'ов в собственной coroutine. -// Без этого Roblox-обработчики которые внутри делают wait() падают с -// "attempt to yield across a C-call boundary". -let _runHandlerInCoroutine = null; +// Очередь handler'ов которые надо запустить на следующем tickScheduler. +// Этим мы выходим из C-boundary — wait() внутри handler'а становится +// безопасным yield в собственной coroutine, потому что handler стартует +// уже из main loop, а не из синхронного JS-callback. +const _pendingHandlerQueue = []; function makeSignal() { const sig = { @@ -50,17 +51,10 @@ function makeSignal() { sig.connect = sig.Connect; sig.Fire = function (...args) { for (const fn of [...sig.connections]) { - // Запускаем handler в его собственной coroutine — это позволяет - // делать wait() внутри без yield-across-C-boundary ошибки. - if (_runHandlerInCoroutine) { - try { _runHandlerInCoroutine(fn, args); } catch (e) { - console.error('[Signal handler]', e); - } - } else { - try { fn(...args); } catch (e) { - console.error('[Signal handler]', e); - } - } + // Кладём в очередь, чтобы handler стартовал не в текущем + // JS-callback (откуда yield запрещён), а из tickScheduler + // в своей coroutine. Безопасно для wait() внутри. + _pendingHandlerQueue.push({ fn, args }); } }; sig.fire = sig.Fire; @@ -1402,11 +1396,11 @@ export function registerRobloxShim(lua, opts) { return 0 end - -- Запуск Lua-handler'а в собственной coroutine. - -- Используется при Fire сигнала из JS — иначе wait() внутри handler'а - -- падает с 'attempt to yield across a C-call boundary'. + -- Запуск Lua-handler'а из очереди в собственной coroutine. + -- Вызывается из JS tickScheduler — мы УЖЕ вышли из C-callback, + -- так что wait() внутри handler'а — yield в свою coroutine. __rbxl_next_handler_id = 0 - function __rbxl_run_in_coroutine(fn, a1, a2, a3, a4) + function __rbxl_drain_handler(fn, a1, a2, a3, a4) __rbxl_next_handler_id = __rbxl_next_handler_id + 1 local handlerId = "handler_" .. __rbxl_next_handler_id local co = coroutine.create(function() fn(a1, a2, a3, a4) end) @@ -1422,8 +1416,8 @@ export function registerRobloxShim(lua, opts) { end end `); - // Кешируем ссылку на функцию для использования из makeSignal - _runHandlerInCoroutine = lua.global.get('__rbxl_run_in_coroutine'); + // Кешируем ссылку на Lua-функцию запуска handler'а + const luaDrainHandler = lua.global.get('__rbxl_drain_handler'); // Добавим Lua-side helper для лога global.set('__log', (level, text) => { send('log', { level: String(level || 'info'), text: String(text || '') }); @@ -1469,7 +1463,20 @@ export function registerRobloxShim(lua, opts) { onDataSnapshot() {}, tickScheduler(_dt) { - // 0. Tweens + // 0a. Lua-handlers из очереди (signal.Fire отложил их сюда). + // Запускаем каждый в своей coroutine — wait() внутри безопасен. + if (_pendingHandlerQueue.length > 0) { + const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length); + for (const h of queue) { + try { + const a = h.args || []; + luaDrainHandler(h.fn, a[0], a[1], a[2], a[3]); + } catch (e) { + console.error('[handler-drain]', e); + } + } + } + // 0b. Tweens _stepTweens(_dt); const now = SCHEDULER.now(); // 1. task.delay / task.defer -- 2.47.2 From 265c2257729c52e3bc12936c47c42c1ccb3f4c51 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 15:46:53 +0300 Subject: [PATCH 33/39] =?UTF-8?q?fix(lua):=20pcall=20handler=20=D1=87?= =?UTF-8?q?=D1=82=D0=BE=D0=B1=D1=8B=20wasmoon=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BB=20return?= =?UTF-8?q?=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reblox-handler типа onEquippedLocal часто возвращают значение последнего выражения (например :connect(fn) → conn object). wasmoon на JS-стороне видит этот объект как result и тестирует на promise — крах 'null.then' если в цепочке встретится null/odd-shaped значение. Фикс: оборачиваем fn(a1,...) в pcall — оно поглощает все return values. + Все 'return null' заменены на 'return undefined' (wasmoon quirk из memory: null → PromiseTypeExtension crash). --- src/editor/engine/lua/RobloxShim.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 246e050..60122d6 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -58,7 +58,7 @@ function makeSignal() { } }; sig.fire = sig.Fire; - sig.Wait = () => null; + sig.Wait = () => undefined; sig.wait = sig.Wait; return sig; } @@ -266,7 +266,7 @@ function makeStubCallable() { fn.connect = fn.Connect; fn.Fire = function () {}; fn.fire = fn.Fire; - fn.Wait = function () { return null; }; + fn.Wait = function () { return undefined; }; fn.wait = fn.Wait; return fn; } @@ -1403,7 +1403,13 @@ export function registerRobloxShim(lua, opts) { function __rbxl_drain_handler(fn, a1, a2, a3, a4) __rbxl_next_handler_id = __rbxl_next_handler_id + 1 local handlerId = "handler_" .. __rbxl_next_handler_id - local co = coroutine.create(function() fn(a1, a2, a3, a4) end) + -- Оборачиваем call в pcall чтобы поглотить return value handler'а + -- (RayGun возвращает :connect(...) объект как последнее выражение, + -- что приводит к wasmoon promise-detection crash). pcall возвращает + -- (ok, ret1, ret2, ...) — мы их не используем. + local co = coroutine.create(function() + pcall(fn, a1, a2, a3, a4) + end) __rbxl_register_coroutine(handlerId, co) local ok, ret = coroutine.resume(co) if not ok then @@ -1414,6 +1420,7 @@ export function registerRobloxShim(lua, opts) { elseif coroutine.status(co) == 'dead' then __rbxl_unregister_coroutine(handlerId) end + -- Явно ничего не возвращаем чтобы wasmoon не оборачивал nil end `); // Кешируем ссылку на Lua-функцию запуска handler'а @@ -1469,8 +1476,14 @@ export function registerRobloxShim(lua, opts) { const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length); for (const h of queue) { try { + // Только реальное число аргументов. wasmoon не любит + // undefined/null — может попытаться обернуть как promise. const a = h.args || []; - luaDrainHandler(h.fn, a[0], a[1], a[2], a[3]); + if (a.length === 0) luaDrainHandler(h.fn); + else if (a.length === 1) luaDrainHandler(h.fn, a[0]); + else if (a.length === 2) luaDrainHandler(h.fn, a[0], a[1]); + else if (a.length === 3) luaDrainHandler(h.fn, a[0], a[1], a[2]); + else luaDrainHandler(h.fn, a[0], a[1], a[2], a[3]); } catch (e) { console.error('[handler-drain]', e); } -- 2.47.2 From 6bec44d778e2251b6d02355b4aacc105e0677572 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 15:49:04 +0300 Subject: [PATCH 34/39] =?UTF-8?q?fix(lua):=20=D1=82=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D1=82=D0=BB=D0=B8=D0=BD=D0=B3=20lightingTimeUpdate=20=D0=B4?= =?UTF-8?q?=D0=BE=20250=D0=BC=D1=81=20=D0=BD=D0=B0=20shim-=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Day/Night скрипт в Roblox: while true do wait(0.01); SetMinutes(+0.1) end = 100+ Hz обновлений Lighting.ClockTime. Каждое слало lightingTimeUpdate через send() из coroutine, что (вероятно) вызывает WASM access crash. Тротлинг прямо в SetMinutesAfterMidnight — не чаще раза в 250мс. Lua-сторона продолжает делать высокочастотные обновления _minutes/ClockTime (скрипт работает корректно), но в JS уходит только 4 раза в секунду. --- src/editor/engine/lua/RobloxShim.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 60122d6..5bfd169 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -910,10 +910,15 @@ export function registerRobloxShim(lua, opts) { lighting.FogColor = new RbxColor3(0.75, 0.75, 0.75); lighting._minutes = 14 * 60; lighting.GetMinutesAfterMidnight = function () { return lighting._minutes; }; + let _lastLightSent = 0; lighting.SetMinutesAfterMidnight = function (m) { lighting._minutes = (Number(m) || 0) % 1440; lighting.ClockTime = lighting._minutes / 60; - // Шлём в GameRuntime для обновления реального неба Babylon + // Тротлинг: не чаще раза в 250мс. Скрипты Day/Night обновляют это + // каждый кадр (100+ Hz), это убивает WASM. + const now = performance.now(); + if (now - _lastLightSent < 250) return; + _lastLightSent = now; send('lightingTimeUpdate', { hour: lighting.ClockTime }); }; lighting.GetMoonDirection = function () { return new RbxVector3(0, 1, 0); }; -- 2.47.2 From ee0d91235cf6c540ac69f9353db31a98d8052cb8 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 15:51:58 +0300 Subject: [PATCH 35/39] =?UTF-8?q?fix(lua):=20=D0=BE=D0=B1=D1=91=D1=80?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D1=82=D0=B5=D0=BB=D0=B0=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D0=BF=D1=82=D0=B0=20=D0=B2=20pcall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memory access out of bounds в rbx_8 (Day/Night) — это WASM crash который пробивает try/catch на JS-стороне. Защита — pcall внутри самого Lua coroutine: даже если что-то падает в скрипте, ошибка ловится Lua-side и не доходит до уровня wasmoon resume. После этого fix остаётся только смотреть кто конкретно крашит — шлём ошибку через __rbxl_send_error и идём дальше. --- src/editor/engine/lua/LuaSharedSandbox.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index ce0b15e..6446408 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -183,7 +183,15 @@ export class LuaSharedSandbox { Source = nil, } local co = coroutine.create(function() - ${entry.code} + -- 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) -- 2.47.2 From 66aac4826e44edc3d52e01386320f8b03af6a154 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 15:57:03 +0300 Subject: [PATCH 36/39] =?UTF-8?q?debug:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BB=D0=BE=D0=B3=20lightingTimeUpdate=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/GameRuntime.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 273ed31..2d2f777 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -213,16 +213,25 @@ export class GameRuntime { } } else if (cmd === 'lightingTimeUpdate') { // Roblox Lighting:SetMinutesAfterMidnight → Babylon небо. - // Скрипты делают это каждый кадр — троттлим до 4 раз/сек. const now = performance.now(); if (!this._lastLightUpdate || now - this._lastLightUpdate > 250) { this._lastLightUpdate = now; try { const hour = Number(payload?.hour); + // Debug-лог (первые несколько раз) + if (!this._lightDbgCount) this._lightDbgCount = 0; + if (this._lightDbgCount < 5) { + this._lightDbgCount++; + console.log('[lightingTimeUpdate]', { payload, hour, + hasSetTimeOfDay: typeof this.scene3d?.setTimeOfDay, + hasEnvironment: !!this.scene3d?.environment }); + } if (hour >= 0 && hour < 24) { this.scene3d?.setTimeOfDay?.(hour); } - } catch (_) {} + } catch (e) { + console.error('[lightingTimeUpdate] error:', e); + } } } else if (cmd === 'particleCreated') { // Roblox Instance.new('Sparkles') — запомнили какие -- 2.47.2 From b820ad11bdbb0229450afc3b0d98dc3f7f78b926 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 16:05:30 +0300 Subject: [PATCH 37/39] =?UTF-8?q?feat(rbxl):=20Day/Night=20=D1=83=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D0=B5=D0=BD=D0=B8=D0=B5=208x=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BA=D0=B0=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roblox Day/Night скрипт идёт wait(0.01) + 0.1 минуты = очень медленно. В оригинале это типичный паттерн, юзеру не очень видно. Ускоряем в 8x для импортированных скриптов: накопленная дельта часов от Lua-скрипта × 8 → реальный hour для setTimeOfDay. Юзер видит полный цикл день↔ночь за 10-15 секунд вместо часа. Также убрал debug-лог lightingTimeUpdate (мешал). --- src/editor/engine/GameRuntime.js | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 2d2f777..544de79 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -213,26 +213,22 @@ export class GameRuntime { } } else if (cmd === 'lightingTimeUpdate') { // Roblox Lighting:SetMinutesAfterMidnight → Babylon небо. - const now = performance.now(); - if (!this._lastLightUpdate || now - this._lastLightUpdate > 250) { - this._lastLightUpdate = now; - try { - const hour = Number(payload?.hour); - // Debug-лог (первые несколько раз) - if (!this._lightDbgCount) this._lightDbgCount = 0; - if (this._lightDbgCount < 5) { - this._lightDbgCount++; - console.log('[lightingTimeUpdate]', { payload, hour, - hasSetTimeOfDay: typeof this.scene3d?.setTimeOfDay, - hasEnvironment: !!this.scene3d?.environment }); + // Ускоряем в 8x чтобы юзер увидел переход день↔ночь + // в течение ~5 секунд после Play. + try { + const baseHour = Number(payload?.hour); + if (baseHour >= 0 && baseHour < 24) { + if (this._lightBaseHour == null) { + this._lightBaseHour = baseHour; + this._lightStartReal = performance.now(); } - if (hour >= 0 && hour < 24) { - this.scene3d?.setTimeOfDay?.(hour); - } - } catch (e) { - console.error('[lightingTimeUpdate] error:', e); + // Δigh-час относительно стартовой + const dGame = baseHour - this._lightBaseHour; + const accel = 8; + const hour = ((this._lightBaseHour + dGame * accel) % 24 + 24) % 24; + this.scene3d?.setTimeOfDay?.(hour); } - } + } catch (_) {} } else if (cmd === 'particleCreated') { // Roblox Instance.new('Sparkles') — запомнили какие // partlcle-эффекты есть у Tool. При equip покажем у руки. -- 2.47.2 From 742bca59ee3b5fe1f25e339c7fb81cdd01d09fac Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 16:11:27 +0300 Subject: [PATCH 38/39] =?UTF-8?q?feat(rbxl):=20Day/Night=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8F=D0=B5=D1=82=20skybox=20preset=20=D1=81=20fadeTo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roblox Day/Night раньше менял только сцен-clearColor и hemiLight через setTimeOfDay (это работало — пол темнел). Но небо оставалось голубым потому что SkyboxManager (купол + горы + звёзды) рулится отдельно. Теперь по часу мапим в preset SkyboxManager: 06-08, 17-19 → sunset (оранжевое небо) 08-17 → lowpoly-roblox (синее день) 19-06 → starry-night (звёзды + луна + тёмное) Используем skybox.fadeTo({preset}, 2) для плавного 2-секундного перехода между пресетами (Урок 62 — кастомное небо). Это даст реальную смену день↔ночь как в оригинале Roblox-Zapper'а. --- src/editor/engine/GameRuntime.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 544de79..6a0ae5a 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -213,8 +213,7 @@ export class GameRuntime { } } else if (cmd === 'lightingTimeUpdate') { // Roblox Lighting:SetMinutesAfterMidnight → Babylon небо. - // Ускоряем в 8x чтобы юзер увидел переход день↔ночь - // в течение ~5 секунд после Play. + // Ускоряем в 8x + меняем пресет skybox (clear/sunset/night). try { const baseHour = Number(payload?.hour); if (baseHour >= 0 && baseHour < 24) { @@ -222,11 +221,25 @@ export class GameRuntime { this._lightBaseHour = baseHour; this._lightStartReal = performance.now(); } - // Δigh-час относительно стартовой 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') { -- 2.47.2 From 0b677529e1aa6a6039d6e8dff59903fe53a38e7e Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 16:23:18 +0300 Subject: [PATCH 39/39] =?UTF-8?q?feat(rbxl-importer):=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20XML-=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B0=20.rbxl=20(=D1=81=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B=20=D0=B4=D0=BE=20?= =?UTF-8?q?2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Старые Roblox-карты (Crossroads, ROBLOX Battle, и др. из эры 2007-2010) сохранены в XML-формате (... вместо binary заворачивается в BrickColor объект — converter ожидает .code атрибут. - Алиасы PascalCase: name→Name, size→Size, shape→Shape (старый XML использовал camelCase с маленькой первой буквой). app.py: - /analyze: авто-детект XML vs Binary по magic bytes. Если XML — используем parse_xml(), иначе старый parse(). Тест на arch1_Original_Crossroads.rbxl: 877 instances, 777 Part, 83 Model — конвертится в 777 примитивов без warnings. --- .../src/__pycache__/converter.cpython-314.pyc | Bin 59456 -> 56367 bytes .../rbxl_binreader.cpython-314.pyc | Bin 0 -> 15005 bytes .../__pycache__/rbxl_parser.cpython-314.pyc | Bin 0 -> 20598 bytes .../__pycache__/rbxl_types.cpython-314.pyc | Bin 0 -> 29959 bytes .../rbxl_xml_parser.cpython-314.pyc | Bin 0 -> 17055 bytes rbxl-importer/src/app.py | 17 +- rbxl-importer/src/rbxl_xml_parser.py | 342 ++++++++++++++++++ 7 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc create mode 100644 rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc create mode 100644 rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc create mode 100644 rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc create mode 100644 rbxl-importer/src/rbxl_xml_parser.py diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc index af16ebfa9b81825da1e43612d31bb842b8b90668..6f7af917aede50d7dc23403ed78f374e4b9983d0 100644 GIT binary patch delta 5605 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 delta 7704 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%0DJ3A}qbFeZFEZ7ZR}dilbF%5Xs8N+yOh=T_;0e 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 CGAp0} literal 0 HcmV?d00001 diff --git a/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44b6b88d8b7fb8df532f61748dacd956a8795f33 GIT binary patch literal 29959 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 literal 0 HcmV?d00001 diff --git a/rbxl-importer/src/app.py b/rbxl-importer/src/app.py index 00a113c..b9788db 100644 --- a/rbxl-importer/src/app.py +++ b/rbxl-importer/src/app.py @@ -122,12 +122,23 @@ def analyze(): blob = upload.read() if len(blob) > MAX_RBXL_SIZE: return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413 - if not blob.startswith(b'... + stripped = blob.lstrip() + is_binary = stripped.startswith(b' и содержит дерево .... + +Возвращает тот же `RobloxModel` что и rbxl_parser.parse — чтобы converter.py +работал без изменений. + +Пример входного файла: + + + + Workspace + + + + + 0100 + 1...1 + + 412 + 4286611584 + 21 + + + + + +Поддерживает все типичные property-теги: string, bool, int, float, double, +token, Vector3, Vector2, CoordinateFrame, Color3, Color3uint8, BrickColor, +Content, ProtectedString, Ref, BinaryString, UDim, UDim2, Rect2D. +""" +from __future__ import annotations +from typing import Dict, List, Any, Optional, Tuple +import xml.etree.ElementTree as ET +import re +import base64 +import struct + +from rbxl_parser import Instance, RobloxModel +from rbxl_types import ( + Vector3, Vector2, Color3, CFrame, BrickColor, + EnumValue, PhysicalProperties, OptionalCFrame, +) + + +# Magic для XML-формата +XML_MAGIC = b' bool: + """Проверяет XML это или нет. Бинарный начинается с str: + """Текст элемента (None → default).""" + return (el.text if el.text is not None else default).strip() + + +def _f(el: ET.Element, default: float = 0.0) -> float: + """Float из text.""" + try: + return float(_text(el, '0')) + except (ValueError, TypeError): + return default + + +def _i(el: ET.Element, default: int = 0) -> int: + """Int из text.""" + try: + s = _text(el, '0') + # Roblox иногда пишет '1.0' где ожидается int + return int(float(s)) if '.' in s else int(s) + except (ValueError, TypeError): + return default + + +def _parse_vector3(el: ET.Element) -> Vector3: + x = _f(el.find('X')) + y = _f(el.find('Y')) + z = _f(el.find('Z')) + return Vector3(x, y, z) + + +def _parse_vector2(el: ET.Element) -> Vector2: + x = _f(el.find('X')) + y = _f(el.find('Y')) + return Vector2(x, y) + + +def _parse_cframe(el: ET.Element) -> CFrame: + """CoordinateFrame: 3 позиции + 9 элементов матрицы ротации.""" + pos = Vector3(_f(el.find('X')), _f(el.find('Y')), _f(el.find('Z'))) + matrix = tuple(_f(el.find(f'R{i}{j}'), 1.0 if i == j else 0.0) + for i in range(3) for j in range(3)) + return CFrame(position=pos, matrix=matrix) + + +def _parse_color3(el: ET.Element) -> Color3: + """.........""" + r = _f(el.find('R')) + g = _f(el.find('G')) + b = _f(el.find('B')) + return Color3(r, g, b) + + +def _parse_color3uint8(el: ET.Element) -> Color3: + """4286611584 — packed RGB как uint32.""" + val = _i(el, 0) + # uint32 = 0xFFRRGGBB (alpha=FF). r=byte2, g=byte1, b=byte0 + b = (val & 0xff) / 255.0 + g = ((val >> 8) & 0xff) / 255.0 + r = ((val >> 16) & 0xff) / 255.0 + return Color3(r, g, b) + + +def _parse_property(prop_el: ET.Element) -> Tuple[str, Any]: + """Парсит один <тип name="имя">значение. Возвращает (name, value).""" + tag = prop_el.tag + name = prop_el.attrib.get('name', '') + + if tag == 'string' or tag == 'ProtectedString' or tag == 'Content': + return name, _text(prop_el) + + if tag == 'bool': + return name, _text(prop_el).lower() == 'true' + + if tag in ('int', 'int64'): + val = _i(prop_el) + # В старом XML цвет хранится как 21, + # а converter ожидает BrickColor-объект с .code. + if name == 'BrickColor': + return name, BrickColor(code=val) + return name, val + + if tag in ('float', 'double'): + return name, _f(prop_el) + + if tag == 'token': + # token — int-значение enum + return name, EnumValue(value=_i(prop_el)) + + if tag == 'Vector3': + return name, _parse_vector3(prop_el) + + if tag == 'Vector2': + return name, _parse_vector2(prop_el) + + if tag == 'CoordinateFrame': + return name, _parse_cframe(prop_el) + + if tag == 'Color3': + return name, _parse_color3(prop_el) + + if tag == 'Color3uint8': + return name, _parse_color3uint8(prop_el) + + if tag == 'BrickColor': + return name, BrickColor(code=_i(prop_el)) + + if tag == 'Ref': + # Ссылка на другой Item по referent (например "RBX42" или "null") + txt = _text(prop_el, 'null') + if txt in ('null', 'nil', ''): + return name, None + return name, txt # храним как строку-referent + + if tag == 'BinaryString': + # base64 → bytes + try: + return name, base64.b64decode(_text(prop_el)) + except Exception: + return name, b'' + + if tag == 'UDim': + scale = _f(prop_el.find('S')) + offset = _i(prop_el.find('O')) + return name, {'scale': scale, 'offset': offset} + + if tag == 'UDim2': + xs = _f(prop_el.find('XS')) + xo = _i(prop_el.find('XO')) + ys = _f(prop_el.find('YS')) + yo = _i(prop_el.find('YO')) + return name, {'x_scale': xs, 'x_offset': xo, 'y_scale': ys, 'y_offset': yo} + + if tag == 'Rect2D': + # min/max + min_el = prop_el.find('min') + max_el = prop_el.find('max') + return name, { + 'min': _parse_vector2(min_el) if min_el is not None else Vector2(0, 0), + 'max': _parse_vector2(max_el) if max_el is not None else Vector2(0, 0), + } + + if tag == 'OptionalCoordinateFrame': + cf_el = prop_el.find('CFrame') + return name, OptionalCFrame(cframe=_parse_cframe(cf_el)) if cf_el is not None else OptionalCFrame(cframe=None) + + if tag == 'PhysicalProperties': + cust = prop_el.find('CustomPhysics') + custom = cust is not None and _text(cust).lower() == 'true' + return name, PhysicalProperties( + custom_physics=custom, + density=_f(prop_el.find('Density'), 0.7), + friction=_f(prop_el.find('Friction'), 0.3), + elasticity=_f(prop_el.find('Elasticity'), 0.5), + friction_weight=_f(prop_el.find('FrictionWeight'), 1.0), + elasticity_weight=_f(prop_el.find('ElasticityWeight'), 1.0), + ) + + if tag == 'NumberRange': + return name, {'min': _f(prop_el.find('Min')), 'max': _f(prop_el.find('Max'))} + + # SharedString / Uri / другие незнакомые — оставляем как текст + return name, _text(prop_el) + + +# Регекс для извлечения referent из строк типа "RBX42" +_REF_RE = re.compile(r'^RBX(\d+)$') + + +def _ref_to_int(ref: Optional[str]) -> Optional[int]: + """RBX42 → 42, null → None. Если уникальной номер не найден — None.""" + if ref is None or ref == 'null': + return None + m = _REF_RE.match(str(ref)) + if m: + return int(m.group(1)) + return None + + +def parse_xml(blob: bytes) -> RobloxModel: + """Главный entry: bytes → RobloxModel.""" + try: + text = blob.decode('utf-8', errors='replace') + except Exception: + text = blob.decode('latin-1', errors='replace') + + # XML может иметь BOM или leading whitespace + text = text.lstrip('').lstrip() + + root = ET.fromstring(text) + + instances: List[Instance] = [] + by_referent: Dict[int, Instance] = {} + roots: List[Instance] = [] + + # Auto-increment id для Item'ов без referent (старые форматы) + next_id_counter = [100000] + + def _walk(item_el: ET.Element, parent_ref: Optional[int]) -> None: + """Рекурсивный обход элементов.""" + cls = item_el.attrib.get('class', 'Unknown') + + # Referent из атрибута (например referent="RBX42") + ref_attr = item_el.attrib.get('referent') or item_el.attrib.get('Referent') + ref_int = _ref_to_int(ref_attr) if ref_attr else None + if ref_int is None: + # Назначаем auto-id чтобы converter мог отслеживать parent_referent + ref_int = next_id_counter[0] + next_id_counter[0] += 1 + + # Парсим properties + props: Dict[str, Any] = {} + props_el = item_el.find('Properties') + if props_el is not None: + for prop_el in props_el: + try: + pname, pval = _parse_property(prop_el) + if pname: + props[pname] = pval + except Exception: + continue + + # Roblox в старых картах использовал имена с маленькой первой буквы: + # name → Name, size → Size, shape → Shape, и т.д. Converter ожидает + # PascalCase. Делаем алиасы (старое имя остаётся, новое добавляется). + _ALIAS_TO_PASCAL = { + 'name': 'Name', + 'size': 'Size', + 'shape': 'Shape', + 'archivable': 'Archivable', + 'shape3d': 'Shape', + } + for old, new in _ALIAS_TO_PASCAL.items(): + if old in props and new not in props: + props[new] = props[old] + + # Convert Ref-properties (string "RBX42") в parent_referent если нужно + # — пока оставляем как строки. + + inst = Instance( + referent=ref_int, + class_name=cls, + properties=props, + parent_referent=parent_ref, + children=[], + ) + instances.append(inst) + by_referent[ref_int] = inst + if parent_ref is None: + roots.append(inst) + + # Рекурсивно дочерние Item'ы + for child in item_el.findall('Item'): + _walk(child, ref_int) + + # Roblox-XML: top-level идут под + for item in root.findall('Item'): + _walk(item, None) + + # Заполняем children после полного прохода (для удобства converter'а) + for inst in instances: + if inst.parent_referent is not None: + parent = by_referent.get(inst.parent_referent) + if parent is not None: + parent.children.append(inst) + + # Версия из атрибута + version_attr = root.attrib.get('version', '4') + try: + version = int(version_attr) + except ValueError: + version = 4 + + return RobloxModel( + version=version, + class_count=len(set(i.class_name for i in instances)), + instance_count=len(instances), + instances=instances, + by_referent=by_referent, + roots=roots, + shared_strings=[], + meta={}, + warnings=[], + ) -- 2.47.2