feat(lua): этапы 1+2 — Lua-скрипты в Рублоксе

Этап 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 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-08 09:57:12 +03:00
parent d45a55031f
commit 06df77cc97
11 changed files with 1569 additions and 30 deletions

434
RUBLOX_LUA_SUPPORT_PLAN.md Normal file
View File

@ -0,0 +1,434 @@
# План: Полная поддержка Lua-скриптов в Рублоксе
**Цель:** Пользователь Рублокс-студии создаёт скрипт → выбирает язык **JS** или **Lua** → пишет код → в плеере оба языка работают параллельно. Lua-скрипты совместимы с Roblox API настолько, чтобы код из Roblox-игр работал без модификаций.
**Зачем:** В Roblox экосистеме сотни тысяч разработчиков, привыкших к Lua + Roblox API (Vector3, CFrame, Instance, RemoteEvent, etc.). Сейчас они не могут перенести свои игры. С этой фичей — могут.
**Срок:** ~6 недель полного времени. Можно делать поэтапно (после каждого этапа есть полезный результат).
**Юр.риск:** "юр риски беру на себя" (МИН, 2026-06-08).
---
## Архитектурное решение
### Текущая ситуация
- Скрипты хранятся как `{id, code, target, name}` в БД
- `GameRuntime` запускает каждый JS-скрипт в Web Worker `ScriptSandboxWorker.js`
- API игре доступен через `game.*` объект (события, scene, player, gui, save, etc.)
- Скриптов в игре могут быть **сотни**, каждый — отдельный Worker
### Целевая
- Скрипты получают поле `language: 'js' | 'lua'` (default `'js'`)
- В редакторе — переключатель в `ScriptEditor.jsx` в шапке: **JS / Lua**
- Monaco-editor подсвечивает Lua (есть встроенный язык в `monaco-editor`)
- В runtime: JS-скрипты идут через старый sandbox, Lua — через новый `LuaSandbox.js`
- Lua-runtime построен поверх **wasmoon** (Lua 5.4 в WebAssembly)
- Lua-скрипты делятся на **один shared VM** на игру (а не Web Worker на скрипт) — иначе OOM при 100+ скриптах
- Lua-API проксирует Roblox API → нашу game.* плюс полную **DataModel** иерархию
### Ключевая идея
**Lua-скрипты не вызывают `game.move(...)`. Они вызывают `script.Parent.Position = Vector3.new(1,2,3)`** — как в Roblox. Lua-shim под капотом переводит это в `partSet` команды для движка Рублокса. Юзер пишет идиоматичный Roblox-Lua, движок Рублокса исполняет.
---
## Этап 1: UI и хранение языка (3 дня)
### 1.1 Миграция БД
- Добавить поле `language VARCHAR(8) DEFAULT 'js' NOT NULL` в таблицу `scripts` (есть на storys API)
- API endpoints (`POST/PATCH /scripts/...`) принимают и сохраняют `language`
- Старые скрипты без поля → `'js'` по умолчанию
### 1.2 ScriptEditor.jsx
- Переключатель в шапке редактора: `[ JS | Lua ]` (segmented control)
- При смене языка — confirm("Сменить язык? Текущий код будет очищен"), затем код сбрасывается на шаблон-заглушку нового языка
- Monaco language switch: `javascript``lua`
- Подсветка/автодополнение Lua встроены в Monaco (`monaco-editor/esm/vs/basic-languages/lua`)
- Линтер ошибок Lua через **luaparse** (npm пакет, ~50KB, parse-only) — показываем красные подчёркивания
- Шаблон Lua для нового скрипта на target=Part:
```lua
-- Скрипт на части. script.Parent = эта часть.
local part = script.Parent
part.Touched:Connect(function(hit)
print("Касание:", hit.Name)
end)
```
- Шаблон Lua для глобального скрипта (target=nil):
```lua
local Players = game:GetService("Players")
Players.PlayerAdded:Connect(function(player)
print("Игрок зашёл:", player.Name)
end)
```
### 1.3 Иконка языка в HierarchyPanel
- Рядом с именем скрипта — маленький бейдж `JS` или `Lua` (синий / голубой)
- Помогает не путаться при сотне скриптов
### Что готово в конце Этапа 1
- Юзер может **создать Lua-скрипт**, написать код, сохранить
- Код **не исполняется** — пока только хранится и редактируется
- В плеере Lua-скрипты молча игнорируются
---
## Этап 2: Базовый Lua-runtime (5 дней)
### 2.1 LuaSharedSandbox.js — новый sandbox-класс
Полная архитектура шаринг-VM (один wasmoon-state на все Lua-скрипты игры):
```
GameRuntime
├── ScriptSandbox (JS-скрипт, Web Worker, как сейчас)
├── ScriptSandbox (JS-скрипт)
├── LuaSharedSandbox ← НОВЫЙ
│ ├── LuaSharedWorker.js
│ │ └── wasmoon VM (один на всю игру)
│ │ ├── Roblox API shim
│ │ ├── DataModel tree (game.Workspace, Players, ...)
│ │ └── все Lua-скрипты как сопрограммы (coroutines)
│ └── проксирует partSet/sceneCreate/event обратно в main thread
```
Файлы:
- `src/editor/engine/LuaSharedSandbox.js` (main thread): API совместимый с ScriptSandbox (`sendSceneSnapshot`, `sendGlobalEvent`, etc.)
- `src/editor/engine/LuaSharedWorker.js` (Web Worker): держит wasmoon, исполняет скрипты, шлёт командные сообщения
- `src/editor/engine/RobloxLuaShim.js` (worker side): объявление всех Roblox-классов и сервисов
### 2.2 Минимальный Roblox shim в первой итерации
- `Vector3.new(x,y,z)`, `+`, `-`, `*`, `:Magnitude()`, `:Dot()`, `:Cross()`, `:Lerp()`, `:Normalize()`
- `Color3.new(r,g,b)`, `Color3.fromRGB(r,g,b)`, `:Lerp()`
- `CFrame.new(x,y,z)`, `CFrame.lookAt()`, `CFrame.fromEulerAnglesXYZ()`, операторы `*` и `:Inverse()`, `:ToWorldSpace()`
- `UDim2.new(sx,ox,sy,oy)`, `UDim.new(s,o)`
- `Enum.KeyCode.W`, `Enum.UserInputType.MouseButton1`, etc. (через generated table)
- `print()` → console + onLog event в студии
- `wait(secs)` / `task.wait(secs)` — через coroutine + scheduler в main loop
- `tick()`, `os.time()`, `os.clock()`, `math.*`, `string.*`, `table.*` (стандартные Lua)
- `pcall`, `xpcall`, `error`, `assert` (стандартные)
### 2.3 GameRuntime интеграция
- При старте игры — пробежать по `scripts[]`, разделить на `jsScripts` и `luaScripts`
- Для JS — старый путь (по сэндбоксу на скрипт)
- Для Lua — один `LuaSharedSandbox`, в него `addScript(luaSource)` каждым
- LuaSharedSandbox шлёт назад те же команды что JS sandbox: `partSet`, `sceneCreate`, `chatSay`, `guiSet`, etc.
### Что готово в конце Этапа 2
- Lua-скрипты **исполняются**
- Можно использовать `Vector3`, `Color3`, `print()`, `wait()`, `math/string/table`
- Скрипт **ещё не видит** Workspace, Player, GUI
---
## Этап 3: DataModel — game.Workspace и иерархия (5 дней)
### 3.1 Что такое DataModel
В Roblox любая игра — **дерево объектов**. Корень = `game`. У него детки = сервисы: `Workspace`, `Players`, `ReplicatedStorage`, `Lighting`, `StarterGui`, `RunService`, etc. У каждого деток — свои детки.
У нас сейчас сцена плоская: `primitives`, `blocks`, `models`. Нужно **виртуальное дерево DataModel** поверх плоской сцены.
### 3.2 Виртуальное дерево
Файл: `src/editor/engine/datamodel/DataModelTree.js`
При старте Lua-runtime, для текущей сцены строится виртуальное дерево:
```
game (RbxGame)
├── Workspace (RbxWorkspace)
│ ├── Part_0 ← обёртка над primitive id=0
│ ├── Part_1 ← обёртка над primitive id=1
│ ├── Model_5 ← обёртка над model id=5 (с Children)
│ │ └── Part_inner
│ ├── Camera
│ └── Terrain
├── Players (RbxPlayers)
│ └── LocalPlayer (RbxPlayer)
│ ├── Character (RbxCharacter)
│ │ ├── Humanoid (RbxHumanoid)
│ │ └── HumanoidRootPart (RbxPart)
│ └── PlayerGui (RbxScreenGui-контейнер)
│ └── (Lua-скрипты могут спавнить GUI через Instance.new)
├── ReplicatedStorage (RbxFolder)
├── ServerStorage (RbxFolder)
├── Lighting (RbxLighting)
├── StarterGui (RbxFolder)
├── StarterPlayer (RbxFolder)
│ └── StarterCharacter (RbxFolder)
├── RunService (RbxRunService с :Heartbeat, :Stepped, :RenderStepped)
├── UserInputService (события InputBegan/Ended/Changed)
├── TweenService (:Create, :GetService для tweens)
├── HttpService (заглушка либо проксируем через нашего бэка)
├── DataStoreService (проксируем через game.save)
└── MarketplaceService (заглушка)
```
### 3.3 Instance-классы
`src/editor/engine/datamodel/Instance.js`:
```js
class RbxInstance {
Name = "Instance";
ClassName = "Instance";
Parent = null;
Children = [];
// Свойства которые юзер может ставить через __newindex (metatable)
// отслеживаются — при изменении посылается команда в main thread
// для синхронизации с Babylon-сценой
GetChildren() { return [...this.Children]; }
FindFirstChild(name, recursive) { ... }
WaitForChild(name, timeout) { ... } // через coroutine + yield
FindFirstAncestor(name) { ... }
FindFirstChildOfClass(class) { ... }
Destroy() { ... }
Clone() { ... }
IsA(class) { ... }
GetFullName() { ... }
GetAttribute(name) / SetAttribute(name, value)
GetPropertyChangedSignal(name) → RbxSignal
}
class RbxPart extends RbxInstance {
Position // setter: партиклс в Babylon (primitiveManager.setPosition)
Size // setter
Color // setter
Material // setter (mapping Roblox materials → наши)
Anchored // setter
CanCollide // setter
Touched // RbxSignal — Fire когда BabylonScene детектит overlap
TouchEnded // RbxSignal
CFrame // computed property — Position + rotation
}
class RbxModel extends RbxInstance {
PrimaryPart
GetPrimaryPartCFrame() / SetPrimaryPartCFrame()
PivotTo(cframe) // MoveTo + Rotate
GetBoundingBox()
}
class RbxHumanoid extends RbxInstance {
Health = 100
MaxHealth = 100
WalkSpeed = 16
JumpPower = 50
Died, HealthChanged, Touched, StateChanged — signals
TakeDamage(amount)
MoveTo(pos) // simulates Roblox NPC pathing
LoadAnimation(anim) → RbxAnimationTrack
}
class RbxScript extends RbxInstance {
Source // источник Lua (read-only обычно)
Disabled // bool
RunContext // Server / Client / Legacy
}
class RbxRemoteEvent extends RbxInstance {
OnServerEvent : RbxSignal
OnClientEvent : RbxSignal
FireServer(...args)
FireClient(player, ...args)
FireAllClients(...args)
}
```
### 3.4 Метатаблицы Lua
Каждая JS-обёртка `RbxPart` экспортируется в Lua как **table с metatable**:
- `__index` — чтение свойства, либо метод
- `__newindex` — запись свойства, триггерит side-effects (синхронизация сцены)
- `__tostring` — для `print(part)` показывает `"Part_0"`
Это **критично** для совместимости с Roblox-скриптами.
### 3.5 script.Parent для каждого Lua-скрипта
- Если скрипт привязан к `target=42` (primitive id 42) — `script.Parent = workspace:FindFirstChild по primId(42)`
- Если глобальный — `script.Parent = nil`
- В скриптовом контексте: `script` это таблица `{Name=..., Parent=..., ClassName="Script"}`
### Что готово в конце Этапа 3
- Lua-скрипт может пройтись по `game.Workspace:GetChildren()`
- `script.Parent.Touched:Connect(...)` работает (KillBrick = реально)
- `local player = game.Players.LocalPlayer; player.Character.Humanoid.Health = 0` убивает игрока
- `Instance.new("Part", workspace)` создаёт примитив
---
## Этап 4: Полный Roblox API (10 дней)
Закрываем "длинный хвост" API. Каждый день — 1-2 сервиса.
### 4.1 Services
- **RunService**: `.Heartbeat`, `.Stepped`, `.RenderStepped` — RbxSignals, фаер в main loop tick
- **UserInputService**: `.InputBegan`, `.InputChanged`, `.InputEnded` — события KeyCode/MouseButton/Touch
- **TweenService**: `:Create(instance, TweenInfo, propertyTable)` → возвращает RbxTween; `Tween:Play()/Pause()/Cancel()`; интерполируется в main loop
- **DataStoreService**: проксируем через наш `game.save` (sync version)
- `:GetDataStore(name)` → объект с `:GetAsync(key)`, `:SetAsync(key, value)`, `:UpdateAsync(key, fn)`
- Async-методы через coroutine.yield + наш save API
- **MarketplaceService**: заглушки `PromptPurchase`, `GetProductInfo` (бизнес-логика через наш интерфейс)
- **HttpService**: `:JSONEncode`, `:JSONDecode`, `:GenerateGUID` — простая встроенная реализация; `:GetAsync/PostAsync` проксируем через ограниченный список разрешённых доменов
- **Players**: `LocalPlayer`, `:GetPlayers()`, `PlayerAdded`/`PlayerRemoving` сигналы
- **Lighting**: read-only сейчас (через `Lighting.Ambient`, `Lighting.OutdoorAmbient` ставить значения нашему envManager)
- **Workspace**: `:Raycast(origin, direction, params)` → реальный raycast через PhysicsAABB; `:GetServerTimeNow()`; `CurrentCamera`
### 4.2 GUI (важно!)
- `Instance.new("ScreenGui")` → если `Parent = playerGui`, регистрируется в нашем GuiManager
- `TextLabel`, `TextButton`, `ImageLabel`, `ImageButton`, `Frame` — все мапятся на наш GuiOverlay
- `MouseButton1Click`, `MouseEnter`, `MouseLeave`, `Activated` — сигналы
- `UDim2`, `Vector2` для позиций/размеров
- При установке `gui.Position = UDim2.new(0.5, 0, 0.5, 0)` — пересылается в GuiManager и обновляется DOM
### 4.3 Sound
- `Instance.new("Sound", part)` с `SoundId = "rbxassetid://12345"` или с нашим URL
- `:Play()`, `:Stop()`, `:Pause()`, `Volume`, `Pitch`, `Looped`
- Под капотом — наш SoundManager
### 4.4 Animation
- `Instance.new("Animation")` с `AnimationId` (наши собственные ID анимаций R15)
- `humanoid:LoadAnimation(anim) → AnimationTrack`
- `:Play()`, `:Stop()`, `:AdjustSpeed()`, `:GetMarkerReachedSignal()`
- Связь с нашим R15Animator
### 4.5 Tools / Backpack
- `Tool` Instance: `Activated`, `Equipped`, `Unequipped` сигналы
- `player.Backpack:GetChildren()` — Lua видит инвентарь
- Реализация через наш HotbarManager + InventoryService
### 4.6 ProximityPrompt, ClickDetector
- ProximityPrompt: `Triggered` сигнал, `:Show/:Hide`, ActionText, ObjectText
- ClickDetector: `MouseClick`, `MouseHoverEnter/Leave` сигналы
### 4.7 Networking-эмуляция (single-player)
- RemoteEvent / RemoteFunction работают **локально** (поскольку у нас singleplayer на client-only)
- `FireServer/InvokeServer` запускают handlers в том же VM, но в other-side контексте
- Это позволяет копировать многопользовательские скрипты Roblox без изменений (хоть мультиплеера и нет)
- Когда у Рублокса появится мультиплеер — `RemoteEvent` уже будет работать «по-настоящему» без изменений в скриптах юзера
### Что готово в конце Этапа 4
- **~90% типовых Roblox-скриптов работают** без модификаций
- DataStore сохраняет прогресс
- TweenService плавно двигает объекты
- GUI создаётся скриптом
- Анимации играются
---
## Этап 5: Импорт .rbxl + конвертер юзер-кода (5 дней)
### 5.1 Изменение импортера
Сейчас импортер сохраняет Lua-исходник в JS-комментарии и пытается завернуть в JS-обёртку. **Это устаревает.**
Новый путь:
- Импортер сохраняет Lua-source **как есть** (без обёрток)
- В записи скрипта `language = 'lua'`
- target = primitiveId или null
- В GameRuntime Lua-скрипт идёт сразу в LuaSharedSandbox
### 5.2 Конвертация ассетов
- Roblox MeshId/TextureId через наш ImageProxy → ассеты сохраняются в minio + на CDN
- `rbxassetid://12345` → resolve в наш asset_id
- Сохранение в БД ссылок на ассеты
### 5.3 Поведение при импорте
- При импорте .rbxl карты — все Lua-скрипты сохраняются как Lua-скрипты Рублокса (не пытаемся конвертить в JS)
- Юзер открывает игру → редактирует → видит в Hierarchy `Script (Lua)` рядом с `Script (JS)` — может писать на любом
### Что готово в конце Этапа 5
- Импорт Roblox-карты работает **бесшовно**
- Юзер может править Lua-код в редакторе и видеть результат
---
## Этап 6: Производительность и стабильность (5 дней)
### 6.1 Profiling
- Замерить FPS на картах с 100/500/1000 Lua-скриптов
- Если падает — переезд на **fengari** (pure-JS Lua interp, в 5-10× медленнее wasmoon но без WASM overhead на старте) либо на **собственный Lua-bytecode runtime** для горячих скриптов
### 6.2 Memory
- Каждый wasmoon VM ~10-15MB. Один на игру = ОК. Если придётся разделять на части (server/client), нужно бенчить.
### 6.3 Песочница (security)
- Lua не должен дёргать `io.*`, `os.execute`, `loadstring(внешний код)`, etc.
- Whitelist стандартной библиотеки. Запрещаем всё что может выйти из браузера.
### 6.4 Ошибки
- Lua-ошибки в скрипте — показываются в Output-панели студии (как JS-ошибки)
- Stack trace с правильными номерами строк (не из обёртки)
- Если скрипт зациклился — kill через `debug.sethook` после N инструкций без yield
### 6.5 Тесты
- 50 unit-тестов на Roblox API (Vector3 операции, CFrame, Instance.new, Touched, RunService, TweenService)
- 10 интеграционных: импортировать тест-rbxl, запустить, проверить что нужное случилось
- CI: тесты прогоняются в Gitea Actions при PR
### Что готово в конце Этапа 6
- Lua-runtime production-ready
- Можно объявить публично «теперь в Рублоксе пишут на Lua с Roblox-совместимостью»
---
## Этап 7: Документация (3 дня)
### 7.1 Раздел вики
- `wiki/lua-intro` — введение в Lua для Рублокса (для пользователей, которые приходят с Roblox — короткое)
- `wiki/lua-vs-js` — таблица: «то же самое на JS и на Lua» для типичных задач
- `wiki/roblox-api-supported` — список того что работает / не работает / отличается
- `wiki/lua-examples` — 20 готовых сниппетов (KillBrick, TeleportPad, Checkpoint, Coin, NPCFollower, etc.)
### 7.2 Migration guide
- «Как перенести свою Roblox-игру в Рублокс» — пошаговое
- Список известных несовместимостей и их обходов
### 7.3 PR-материал
- Пост в /developer на team.rublox.pro: «Lua-поддержка теперь GA»
- Если есть бюджет — короткий ролик на YouTube/TikTok
---
## Этапы целиком
| Этап | Длительность | Содержание |
|------|--------------|------------|
| 1 | 3 дня | UI и хранение языка |
| 2 | 5 дней | Базовый Lua-runtime + минимальный shim |
| 3 | 5 дней | DataModel (game.Workspace и иерархия) |
| 4 | 10 дней | Полный Roblox API (services, GUI, Sound, Animation) |
| 5 | 5 дней | Импорт .rbxl и асетов |
| 6 | 5 дней | Производительность, безопасность, тесты |
| 7 | 3 дня | Документация и публикация |
| **Итого** | **~36 рабочих дней** | **~6 недель полного времени** |
---
## Что-то можно делать раньше, чтобы получить пользу
- **MVP (Этапы 1+2):** через **8 дней** — юзер может писать Lua-скрипты с минимальным API (Vector3, Color3, print, wait). Уже видит что фича есть.
- **Beta (Этапы 1-3):** через **13 дней** — KillBrick'и работают, можно делать простые игры на Lua.
- **GA (все этапы):** через **6 недель** — продакшен.
После каждого этапа можно делать релиз и собирать фидбек.
---
## Решения которые нужны от тебя перед стартом
1. **wasmoon vs fengari** — wasmoon быстрее но WASM-heavy, fengari проще но медленнее. Предлагаю wasmoon (уже используем для импорта).
2. **Один shared VM на игру** — согласен или разделять server/client? Предлагаю один в singleplayer-фазе, разделение — позже когда будет мультиплеер.
3. **Бэкенд изменения** — нужна миграция БД (поле `language`). У нас сейчас S2 + S1 + auto-backup, ничего страшного, но согласовать момент апдейта.
4. **Roblox API trademark/copyright** — мы делаем API-compatible runtime. Названия классов `Workspace`, `Humanoid`, etc. это API names. Юр.риск есть. Ты сказал берёшь — фиксируем.
5. **Приоритет** — этот план делать **вместо** других задач (тогда параллельные фичи стопаются) или **после** текущего бэклога?
---
## Связанные документы
- `RUBLOX_PROJECT.md` — общий план Рублокса
- `RUBLOX_EDITOR_ROADMAP.md` — куда движется редактор
- `INFO_PROCESS.md` — лог реализации (будет апдейтиться по ходу)
---
**Создано:** 2026-06-08, Claude (Opus 4.7) совместно с МИНом.
**Статус:** план готов, ждём решения по 5 вопросам перед стартом.

View File

@ -37,7 +37,7 @@ const renderRowIcon = (val) => {
const ItemRow = ({
icon, label, title, depth = 0, selected, plusItems,
onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
extraStyle, selId,
extraStyle, selId, badge,
}) => {
const [hovered, setHovered] = useState(false);
const rowRef = React.useRef(null);
@ -84,6 +84,9 @@ const ItemRow = ({
>
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{renderRowIcon(icon)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{label}</span>
{badge && (
<span style={{ flexShrink: 0, display: 'inline-flex' }}>{badge}</span>
)}
{plusItems && plusItems.length > 0 && (
<HoverPlusMenu visible={hovered} items={plusItems} />
)}
@ -129,15 +132,35 @@ const GroupRow = ({ icon, label, open, onToggle, plusItems }) => {
/** Строка скрипта внутри иерархии. */
const ScriptRow = ({ script, depth, selected, onSelect, onDelete, onRename, onContextMenu, onStartRename }) => {
const displayName = script.name || (script.id === 'demo' ? 'Демо-скрипт' : script.id);
const isLua = script.language === 'lua';
const badge = (
<span
style={{
fontSize: 9,
fontWeight: 800,
padding: '1px 5px',
borderRadius: 4,
lineHeight: 1.4,
letterSpacing: 0.4,
marginRight: 4,
background: isLua ? '#2196f3' : '#f7df1e',
color: isLua ? '#fff' : '#1a1a1c',
}}
title={isLua ? 'Lua (Roblox API)' : 'JavaScript (game.* API)'}
>
{isLua ? 'LUA' : 'JS'}
</span>
);
return (
<ItemRow
icon="📜"
label={displayName}
title={`${displayName} (id: ${script.id})`}
title={`${displayName} (id: ${script.id}, язык: ${isLua ? 'Lua' : 'JavaScript'})`}
depth={depth}
selected={selected}
onClick={onSelect}
onContextMenu={onContextMenu}
badge={badge}
plusItems={[
{
id: 'rename', label: 'Переименовать', icon: '✏️',

View File

@ -3324,8 +3324,14 @@ const KubikonEditor = () => {
scriptId={sc.id}
value={sc.code}
target={sc.target}
language={sc.language || 'js'}
flushRef={scriptEditorFlushRef}
isSoloRunning={soloScriptId === sc.id}
onLanguageChange={(lang, newCode) => {
sceneRef.current?.upsertScript(sc.id, newCode, undefined, undefined, lang);
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
}}
onSave={(code) => {
sceneRef.current?.upsertScript(sc.id, code, sc.target);
setScriptsList(sceneRef.current?.getScripts?.() || []);

View File

@ -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 или глобальный).
// Используется при смене языка JSLua когда текущий код выглядит «пустым».
const LUA_TEMPLATE_PART = `-- Скрипт привязан к Part. script.Parent = эта часть.
local part = script.Parent
part.Touched:Connect(function(hit)
print("Касание:", hit.Name)
end)
`;
const LUA_TEMPLATE_GLOBAL = `-- Глобальный Lua-скрипт. Работает на стороне сервера/клиента.
local Players = game:GetService("Players")
Players.PlayerAdded:Connect(function(player)
print("Игрок зашёл:", player.Name)
end)
`;
const JS_TEMPLATE_GLOBAL = `// Глобальный JS-скрипт. Подробнее: см. game.* API в /справочник.
game.onPlayerJoined((player) => {
game.chat.say('Привет, ' + player.name + '!');
});
`;
function isCodeLikelyEmptyTemplate(code) {
if (!code) return true;
const trimmed = code.trim();
if (trimmed.length === 0) return true;
// Содержит ТОЛЬКО комментарии и пустые строки
const lines = trimmed.split('\n').map(l => l.trim()).filter(Boolean);
return lines.every(l =>
l.startsWith('//') || l.startsWith('--') ||
l.startsWith('/*') || l.startsWith('*/') || l.startsWith('*')
);
}
function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, language, onLanguageChange, onClose, flushRef }) {
const currentLanguage = language === 'lua' ? 'lua' : 'js';
// Локальный буфер кода то что в редакторе сейчас.
// Синхронизируется с external value только при смене scriptId.
const [localCode, setLocalCode] = useState(value || '');
@ -282,6 +317,64 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
border: '1px solid rgba(79, 116, 255, 0.35)',
}}>{targetLabel}</span>
)}
{/* Переключатель языка JS / Lua */}
<span style={{
display: 'inline-flex',
background: '#1a1a1c',
border: '1px solid #3a3a3a',
borderRadius: 8,
padding: 2,
}}>
{['js', 'lua'].map((lang) => {
const active = currentLanguage === lang;
return (
<button
key={lang}
onClick={() => {
if (active) return;
if (!onLanguageChange) return;
let nextCode = localCode;
if (isCodeLikelyEmptyTemplate(localCode)) {
nextCode = lang === 'lua'
? (target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL)
: JS_TEMPLATE_GLOBAL;
setLocalCode(nextCode);
} else {
const ok = window.confirm(
'Сменить язык скрипта на ' + (lang === 'lua' ? 'Lua' : 'JavaScript') +
'?\n\nКод сохранится как есть — синтаксис прежнего языка перестанет подсвечиваться. Можно переключиться обратно.'
);
if (!ok) return;
}
onLanguageChange(lang, nextCode);
}}
style={{
padding: '4px 12px',
fontSize: 11,
fontWeight: 800,
fontFamily: 'inherit',
border: 'none',
borderRadius: 6,
cursor: active ? 'default' : 'pointer',
background: active
? (lang === 'lua'
? 'linear-gradient(135deg, #2196f3 0%, #1565c0 100%)'
: 'linear-gradient(135deg, #f7df1e 0%, #d4b500 100%)')
: 'transparent',
color: active
? (lang === 'lua' ? '#fff' : '#1a1a1c')
: '#9a9a9e',
letterSpacing: 0.3,
}}
title={lang === 'lua'
? 'Lua с Roblox-совместимым API (Vector3, CFrame, Instance)'
: 'JavaScript с game.* API Рублокса'}
>
{lang === 'lua' ? 'Lua' : 'JS'}
</button>
);
})}
</span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>
{/* Фаза 6.1.4: кнопка «Проверить» включает семантический анализ TS
на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.).
@ -394,10 +487,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
<div style={{ flex: 1, minHeight: 0 }}>
<Editor
height="100%"
defaultLanguage="javascript"
defaultLanguage={currentLanguage === 'lua' ? 'lua' : 'javascript'}
language={currentLanguage === 'lua' ? 'lua' : 'javascript'}
theme="vs-dark"
value={localCode}
path={`script_${scriptId}.js`}
path={`script_${scriptId}.${currentLanguage === 'lua' ? 'lua' : 'js'}`}
onChange={handleChange}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}

View File

@ -3035,9 +3035,18 @@ export class BabylonScene {
// Без этого onTouch финиша/плитки не срабатывает (игрок встал).
const EPS = 0.25;
// Диагностика раз в секунду через time-based throttle
if (!this._touchDbgT0) this._touchDbgT0 = performance.now();
const _nowDbg = performance.now();
if (_nowDbg - this._touchDbgT0 > 1000) {
this._touchDbgT0 = _nowDbg;
console.warn(`[TouchDbg] pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)}) scripts=${scripts.length}`);
}
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
let _firedThisFrame = 0;
for (const s of scripts) {
if (!s.target) continue;
try {
const key = 's:' + s.id;
seen.add(key);
const aabb = this._targetAABB(s.target);
@ -3051,10 +3060,20 @@ export class BabylonScene {
this._touchState.set(key, true);
rt.routeEvent(s.target, 'touch', {});
rt.routeGlobalEvent('playerTouch', { target: s.target });
_firedThisFrame++;
if (_firedThisFrame === 1) {
console.warn(`[Touch FIRE] scriptId=${s.id} target=${s.target} pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)})`);
}
} else if (!overlap && wasTouching) {
this._touchState.set(key, false);
rt.routeEvent(s.target, 'untouch', {});
}
} catch (e) {
if (!this._touchDetectErrored) {
this._touchDetectErrored = true;
console.error('[TouchDetect] error', e, 'on script', s);
}
}
}
// 2) Касания примитивов-триггеров (type === 'trigger') БЕЗ скрипта —
@ -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',
}));
}
// Окружение (время суток, скайбокс, туман)

View File

@ -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'ы. Не перезаписываем существующий обработчик —

View File

@ -114,9 +114,20 @@ export class RobloxLuaSharedSandbox {
if (!payload || typeof payload !== 'object') return;
const type = payload.type;
// playerTouch: BabylonScene уже детектит касания → Touched на Part
if (type === 'playerTouch' && payload.target) {
const m = /^primitive:(\d+)$/.exec(String(payload.target));
if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; }
if (type === 'playerTouch' && payload.target != null) {
const t = payload.target;
// target может быть: число (импортированный rbxl), {id|ref}, 'primitive:<id>'
let primId = null;
if (typeof t === 'number') primId = t;
else if (typeof t === 'object') primId = t.id ?? t.ref ?? null;
else if (typeof t === 'string') {
const m = /^primitive:(\d+)$/.exec(t);
if (m) primId = +m[1];
}
if (primId != null) {
this.fireEvent('touched', { primId, isPlayer: true });
return;
}
}
// GUI click — Rublox GuiOverlay шлёт guiClick с id
if (type === 'guiClick' && (payload.id || payload.localId)) {

View File

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

View File

@ -0,0 +1,215 @@
/**
* LuaSharedSandbox обёртка над одним wasmoon-Worker для ВСЕХ Lua-скриптов игры.
*
* Идея:
* - Создаётся ОДИН экземпляр на всю игру (а не на скрипт, как ScriptSandbox)
* - Все Lua-скрипты добавляются через addScript(id, code, target)
* - Worker внутри держит ОДИН wasmoon Lua-state, в котором живут:
* * полный Roblox API shim (Vector3, CFrame, Color3, Instance, ...)
* * виртуальное DataModel дерево (game.Workspace, Players, ...)
* * все скрипты как coroutines (потому что Roblox-Lua так работает)
* - При партии команд (partSet/sceneCreate/event/log) пересылка в main
* с тем же интерфейсом что у ScriptSandbox
*
* Совместимость с GameRuntime:
* методы sendEvent / sendGlobalEvent / sendSceneSnapshot / sendGuiSnapshot /
* sendDataSnapshot / sendSkinsSnapshot / sendTerrainHeightmap / stop /
* setOnCommand поведение совпадает с ScriptSandbox.
*
* Отличия:
* - addScript(id, code, target) можно вызывать много раз ДО start() все
* скрипты добавляются батчем и потом запускаются вместе.
* - После start() можно вызывать addScript() для live-добавления (например,
* Instance.new("Script", workspace) с переданным Source).
*/
import LuaSharedWorker from './LuaSharedWorker.js?worker';
let _ipcId = 0;
export class LuaSharedSandbox {
constructor() {
this.worker = null;
this._onCommand = null;
this._isReady = false;
this._isStopped = false;
// Скрипты добавленные до start() — буферизуются, отправляются батчем при start()
this._pendingScripts = [];
// Снапшоты пришедшие до ready — отправляются после ready
this._pendingSceneSnapshot = null;
this._pendingGuiSnapshot = null;
this._pendingDataSnapshot = null;
this._pendingSkinsSnapshot = null;
this._pendingTerrainHM = null;
}
setOnCommand(cb) { this._onCommand = cb; }
/** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */
addScript(id, code, target) {
const entry = {
id: String(id),
source: String(code || ''),
target: target == null ? null : target,
};
if (!this.worker) {
this._pendingScripts.push(entry);
return;
}
// Live-добавление после start()
try {
this.worker.postMessage({ cmd: 'addScript', payload: entry });
} catch (_) {}
}
/** Удалить Lua-скрипт по id (для случая когда в студии его удалили в Play-mode редко). */
removeScript(id) {
if (!this.worker) {
this._pendingScripts = this._pendingScripts.filter(s => s.id !== String(id));
return;
}
try {
this.worker.postMessage({ cmd: 'removeScript', payload: { id: String(id) } });
} catch (_) {}
}
/**
* Запустить worker и инициализировать VM.
* После start() Lua-runtime готов принимать события и снапшоты.
*/
start() {
if (this.worker) return;
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] starting Lua VM, pending scripts:', this._pendingScripts.length);
this.worker = new LuaSharedWorker();
this.worker.onmessage = (e) => this._handleMessage(e);
this.worker.onerror = (err) => {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox] Worker error', err);
this._emit('log', {
level: 'error',
text: `Lua-runtime error: ${err.message || err}`,
});
};
this.worker.postMessage({
cmd: 'init',
payload: { ipcId: ++_ipcId },
});
}
_handleMessage(e) {
if (this._isStopped) return;
const { cmd, payload } = e.data || {};
if (cmd === 'boot') return;
if (cmd === 'ready') {
this._isReady = true;
// Отправляем накопленные скрипты батчем
if (this._pendingScripts.length > 0) {
try {
this.worker.postMessage({ cmd: 'addScriptsBatch', payload: { scripts: this._pendingScripts } });
} catch (_) {}
this._pendingScripts = [];
}
// Отправляем snapshot'ы
if (this._pendingSceneSnapshot) {
try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: this._pendingSceneSnapshot }); } catch (_) {}
this._pendingSceneSnapshot = null;
}
if (this._pendingGuiSnapshot) {
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (_) {}
this._pendingGuiSnapshot = null;
}
if (this._pendingDataSnapshot) {
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (_) {}
this._pendingDataSnapshot = null;
}
if (this._pendingSkinsSnapshot) {
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (_) {}
this._pendingSkinsSnapshot = null;
}
if (this._pendingTerrainHM) {
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (_) {}
this._pendingTerrainHM = null;
}
// Запустить главный loop (фаер RunService.Heartbeat/Stepped + резюм coroutines)
try { this.worker.postMessage({ cmd: 'kickoff' }); } catch (_) {}
return;
}
// Любая другая команда — прокинуть наружу как partSet/sceneCreate/log/etc
// _onCommand обработчик в GameRuntime разруливает их так же как от ScriptSandbox
this._emit(cmd, payload);
}
_emit(cmd, payload) {
if (typeof this._onCommand === 'function') {
try { this._onCommand({ cmd, payload }); } catch (_) {}
}
}
/** Событие target-attached скрипта (touch/untouch/click/etc). */
sendEvent(payload) {
if (!this.worker) return;
if (!this._isReady) return;
try { this.worker.postMessage({ cmd: 'event', payload }); } catch (_) {}
}
/** Глобальное событие игры (playerTouch/guiClick/keydown/etc). */
sendGlobalEvent(payload) {
if (!this.worker) return;
if (!this._isReady) return;
try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (_) {}
}
sendSceneSnapshot(snapshot) {
if (!this.worker) {
this._pendingSceneSnapshot = snapshot;
return;
}
if (!this._isReady) {
this._pendingSceneSnapshot = snapshot;
return;
}
try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: snapshot }); } catch (_) {}
}
sendGuiSnapshot(snapshot) {
if (!this.worker || !this._isReady) {
this._pendingGuiSnapshot = snapshot;
return;
}
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (_) {}
}
sendDataSnapshot(snapshot) {
if (!this.worker || !this._isReady) {
this._pendingDataSnapshot = snapshot;
return;
}
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (_) {}
}
sendSkinsSnapshot(snapshot) {
if (!this.worker || !this._isReady) {
this._pendingSkinsSnapshot = snapshot;
return;
}
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (_) {}
}
sendTerrainHeightmap(hm) {
if (!this.worker || !this._isReady) {
this._pendingTerrainHM = hm;
return;
}
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: hm }); } catch (_) {}
}
stop() {
this._isStopped = true;
try { this.worker?.terminate(); } catch (_) {}
this.worker = null;
this._isReady = false;
}
}
export default LuaSharedSandbox;

View File

@ -0,0 +1,242 @@
/* eslint-disable no-restricted-globals */
/**
* LuaSharedWorker Web Worker, держит ОДИН wasmoon Lua-state на всю игру.
*
* Жизненный цикл:
* 1. init {ipcId} загружаем wasmoon, готовим VM, регистрируем shim, отвечаем 'ready'
* 2. addScriptsBatch {scripts} добавляем все скрипты сразу (но НЕ запускаем ждём kickoff)
* 3. sceneSnapshot/guiSnapshot накопить состояние сцены до запуска
* 4. kickoff запустить main loop (RunService.Heartbeat фейерится из main loop)
* и стартануть каждый скрипт как coroutine
* 5. event/globalEvent проксировать в Lua-signal (RbxSignal.Fire)
* 6. addScript {entry} live-добавление одного скрипта после kickoff
*
* Архитектура VM:
* - один wasmoon Lua state (createWasmoonVM)
* - registerRobloxShim(vm) экспортирует Vector3.new, Color3.new, print, wait,
* game (с минимальным DataModel), Instance.new и проч.
* - state.scripts = Map<id, {coroutine, target, source}>
* - state.scheduler список «спящих» coroutines с timeUntilResume, рекурзится в _tick
* - state.signals RbxSignal-объекты для events; Worker слушает 'event' от main
* и вызывает Lua-side Fire по соответствующему signal'у
*/
let _wasmoon = null;
// Главное состояние VM (на весь life-cycle Worker'а)
const state = {
ipcId: null,
vm: null,
api: null, // объект который вернул registerRobloxShim
isReady: false,
isKickedOff: false,
pendingScripts: [], // скрипты которые ждут kickoff
scriptsById: new Map(), // id → {coroutine, target, source, name}
scenes: { primitives: null, blocks: null, models: null },
guiTree: null,
skins: null,
data: null,
terrainHM: null,
// tick clock
lastTickAt: 0,
};
self.onmessage = async (e) => {
const { cmd, payload } = e.data || {};
try {
if (cmd === 'init') await handleInit(payload);
else if (cmd === 'addScript') handleAddScript(payload);
else if (cmd === 'addScriptsBatch') handleAddScriptsBatch(payload);
else if (cmd === 'removeScript') handleRemoveScript(payload);
else if (cmd === 'sceneSnapshot') handleSceneSnapshot(payload);
else if (cmd === 'guiSnapshot') handleGuiSnapshot(payload);
else if (cmd === 'dataSnapshot') handleDataSnapshot(payload);
else if (cmd === 'skinsSnapshot') handleSkinsSnapshot(payload);
else if (cmd === 'terrainHeightmap') handleTerrainHeightmap(payload);
else if (cmd === 'event') handleTargetEvent(payload);
else if (cmd === 'globalEvent') handleGlobalEvent(payload);
else if (cmd === 'kickoff') handleKickoff();
} catch (err) {
logToMain('error', `[LuaWorker] ${cmd} error: ${err?.message || err}`);
}
};
function send(cmd, payload) {
try { self.postMessage({ cmd, payload }); } catch (_) {}
}
function logToMain(level, text) {
send('log', { level, text });
}
async function handleInit(payload) {
state.ipcId = payload?.ipcId || 0;
send('boot', { ipcId: state.ipcId });
// Загрузить wasmoon
_wasmoon = await import(/* @vite-ignore */ 'wasmoon');
const { LuaFactory } = _wasmoon;
const factory = new LuaFactory();
state.vm = await factory.createEngine({ openStandardLibs: true });
// Регистрируем минимальный Roblox shim
const { registerRobloxShim } = await import('./RobloxShim.js');
state.api = registerRobloxShim(state.vm, {
send,
getSceneSnapshot: () => state.scenes,
getGuiTree: () => state.guiTree,
scheduleWait: (sec) => scheduleWait(sec),
});
state.isReady = true;
send('ready', {});
}
function handleAddScriptsBatch(payload) {
const arr = Array.isArray(payload?.scripts) ? payload.scripts : [];
for (const s of arr) handleAddScript(s);
}
function handleAddScript(entry) {
if (!entry || typeof entry.source !== 'string') return;
const id = String(entry.id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`);
state.scriptsById.set(id, {
id,
source: entry.source,
target: entry.target == null ? null : entry.target,
coroutine: null,
name: entry.name || null,
});
// Если мы уже kickoff'нулись — стартанём сразу
if (state.isKickedOff) startSingleScript(id);
else state.pendingScripts.push(id);
}
function handleRemoveScript(payload) {
const id = String(payload?.id || '');
if (!id) return;
state.scriptsById.delete(id);
// (coroutine просто перестанет резюмиться)
}
function handleSceneSnapshot(snap) {
state.scenes = snap || { primitives: null, blocks: null, models: null };
// Обновим DataModel-дерево (Workspace children) — это сделает api при следующем GetChildren()
if (state.api?.onSceneSnapshot) {
try { state.api.onSceneSnapshot(state.scenes); } catch (_) {}
}
}
function handleGuiSnapshot(g) {
state.guiTree = g || null;
if (state.api?.onGuiSnapshot) {
try { state.api.onGuiSnapshot(state.guiTree); } catch (_) {}
}
}
function handleDataSnapshot(d) {
state.data = d || null;
if (state.api?.onDataSnapshot) {
try { state.api.onDataSnapshot(state.data); } catch (_) {}
}
}
function handleSkinsSnapshot(s) {
state.skins = s || null;
}
function handleTerrainHeightmap(hm) {
state.terrainHM = hm || null;
}
function handleTargetEvent(payload) {
// События привязанные к конкретному скрипту (touch/untouch/click)
// payload: { scriptId, kind, ... }
if (!state.api?.fireTargetEvent) return;
try { state.api.fireTargetEvent(payload); } catch (e) {
logToMain('error', `[LuaWorker] fireTargetEvent: ${e.message || e}`);
}
}
function handleGlobalEvent(payload) {
if (!state.api?.fireGlobalEvent) return;
try { state.api.fireGlobalEvent(payload); } catch (e) {
logToMain('error', `[LuaWorker] fireGlobalEvent: ${e.message || e}`);
}
}
function handleKickoff() {
if (state.isKickedOff) return;
state.isKickedOff = true;
// Стартанём все накопленные скрипты как coroutines
for (const id of state.pendingScripts) startSingleScript(id);
state.pendingScripts = [];
// Главный loop — RunService Heartbeat + scheduler resume
state.lastTickAt = performance.now();
startMainLoop();
}
function startSingleScript(id) {
const entry = state.scriptsById.get(id);
if (!entry) return;
// Каждый скрипт — coroutine. В нём script — это таблица {Name, Parent, ClassName="Script"}.
// Создаём в Lua wrapped chunk:
// coroutine.create(function() local script = ...; <user_source> end)
const safeId = id.replace(/[^a-zA-Z0-9_]/g, '_');
const targetLuaExpr = entry.target == null
? 'nil'
: (typeof entry.target === 'number'
? `__rbxl_get_part_by_id(${entry.target})`
: 'nil'); // для object-target (на будущее)
const name = entry.name || `Script_${safeId}`;
const wrapped = `
local co = coroutine.create(function()
local script = {
Name = ${JSON.stringify(name)},
Parent = ${targetLuaExpr},
ClassName = "Script",
Disabled = false,
Source = nil,
}
__rbxl_script_run(${JSON.stringify(id)}, script, function()
${entry.source}
end)
end)
__rbxl_register_coroutine(${JSON.stringify(id)}, co)
coroutine.resume(co)
`;
try {
state.vm.doStringSync(wrapped);
} catch (err) {
logToMain('error', `[Lua ${id}] init error: ${err.message || err}`);
}
}
/**
* Главный loop:
* - вызывается раз в ~16мс (60 Гц), резюмит спящие coroutines у которых истёк wait
* - фейерит RunService.Heartbeat (dt секундах)
* - фейерит RunService.Stepped
*/
function startMainLoop() {
const tick = () => {
if (!state.isKickedOff) return;
try {
const now = performance.now();
const dt = Math.min(0.1, (now - state.lastTickAt) / 1000);
state.lastTickAt = now;
// 1) Резюм coroutines, которым подошёл срок wait
if (state.api?.tickScheduler) state.api.tickScheduler(dt);
// 2) Heartbeat и Stepped сигналы
if (state.api?.fireHeartbeat) state.api.fireHeartbeat(dt);
} catch (e) {
logToMain('error', `[Lua tick] ${e.message || e}`);
}
setTimeout(tick, 16);
};
setTimeout(tick, 16);
}
function scheduleWait(_sec) {
// вызывается из Lua-side через api.scheduleWait. Реальная реализация —
// в RobloxShim.js (он держит scheduler).
}

View File

@ -0,0 +1,447 @@
/**
* RobloxShim экспорт минимально-достаточного Roblox API в wasmoon-VM.
*
* Этап 2 (текущий): базовый shim без DataModel-дерева.
* - Vector3, Color3, UDim2, UDim, Vector2 (с операторами)
* - print, warn, error, wait, task.wait, task.spawn, task.delay
* - RbxSignal (Connect/connect, Disconnect, Wait, Fire/fire)
* - scheduler для wait через coroutines (NB: используется внешний tick из Worker)
* - примитивная game-table с workspace, Players (заглушки) расширится в Этапе 3
*
* Возвращает объект api с методами:
* onSceneSnapshot(snap) обновить понимание сцены (для DataModel в Этапе 3)
* onGuiSnapshot(g) обновить GUI tree
* onDataSnapshot(d) обновить data (save)
* tickScheduler(dt) резюм coroutines с истёкшим wait
* fireHeartbeat(dt) фейр RunService.Heartbeat
* fireTargetEvent(p) событие для target-скрипта (touch/click)
* fireGlobalEvent(p) playerTouch / guiClick / keydown
*
* Дизайн RbxSignal: хранится JS-сторона как {connections: [fn,...]}.
* Lua видит обёртку {Connect=fn, connect=fn, Wait=fn, Fire=fn}.
*/
const SCHEDULER = {
sleeping: [], // [{coroutine, wakeAt}], wakeAt = performance.now()+ms
now: () => performance.now(),
};
const HEARTBEAT_SIGNAL = makeSignal();
const STEPPED_SIGNAL = makeSignal();
function makeSignal() {
const connections = [];
return {
__isSignal: true,
connections,
Fire(...args) { for (const fn of [...connections]) { try { fn(...args); } catch (_) {} } },
Connect(fn) {
if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {} };
connections.push(fn);
return {
Disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); },
disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); },
Connected: true,
};
},
Wait() {
// в реальной реализации — coroutine.yield пока не fire'нется
return null;
},
};
}
// --- Vector3 ---
class RbxVector3 {
constructor(x = 0, y = 0, z = 0) {
this.X = +x; this.Y = +y; this.Z = +z;
}
static new(x, y, z) { return new RbxVector3(x, y, z); }
Magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
get magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
Unit() {
const m = this.Magnitude() || 1;
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
}
Normalize() { return this.Unit(); }
Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; }
Cross(b) {
return new RbxVector3(
this.Y * b.Z - this.Z * b.Y,
this.Z * b.X - this.X * b.Z,
this.X * b.Y - this.Y * b.X,
);
}
Lerp(b, t) {
return new RbxVector3(
this.X + (b.X - this.X) * t,
this.Y + (b.Y - this.Y) * t,
this.Z + (b.Z - this.Z) * t,
);
}
// Lua operators реализуются через __add/__sub/__mul (metatable установит shim ниже)
}
RbxVector3.zero = new RbxVector3(0, 0, 0);
RbxVector3.one = new RbxVector3(1, 1, 1);
RbxVector3.xAxis = new RbxVector3(1, 0, 0);
RbxVector3.yAxis = new RbxVector3(0, 1, 0);
RbxVector3.zAxis = new RbxVector3(0, 0, 1);
// --- Color3 ---
class RbxColor3 {
constructor(r = 0, g = 0, b = 0) { this.R = +r; this.G = +g; this.B = +b; }
static new(r, g, b) { return new RbxColor3(r, g, b); }
static fromRGB(r, g, b) { return new RbxColor3((r || 0) / 255, (g || 0) / 255, (b || 0) / 255); }
static fromHSV(h, s, v) {
const i = Math.floor(h * 6); const f = h * 6 - i;
const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s);
const [r, g, b] = [
[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q],
][i % 6];
return new RbxColor3(r, g, b);
}
Lerp(b, t) {
return new RbxColor3(
this.R + (b.R - this.R) * t,
this.G + (b.G - this.G) * t,
this.B + (b.B - this.B) * t,
);
}
}
// --- UDim / UDim2 / Vector2 ---
class RbxUDim {
constructor(s = 0, o = 0) { this.Scale = +s; this.Offset = +o; }
static new(s, o) { return new RbxUDim(s, o); }
}
class RbxUDim2 {
constructor(sx = 0, ox = 0, sy = 0, oy = 0) {
this.X = new RbxUDim(sx, ox);
this.Y = new RbxUDim(sy, oy);
}
static new(sx, ox, sy, oy) { return new RbxUDim2(sx, ox, sy, oy); }
static fromScale(sx, sy) { return new RbxUDim2(sx, 0, sy, 0); }
static fromOffset(ox, oy) { return new RbxUDim2(0, ox, 0, oy); }
}
class RbxVector2 {
constructor(x = 0, y = 0) { this.X = +x; this.Y = +y; }
static new(x, y) { return new RbxVector2(x, y); }
}
// --- CFrame (минимум) ---
class RbxCFrame {
constructor(x = 0, y = 0, z = 0) {
this.X = +x; this.Y = +y; this.Z = +z;
this.Position = new RbxVector3(x, y, z);
// Полная матрица 3×3 на этапе 3
}
static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); }
static lookAt(eye, target) {
// упрощение — возвращаем cframe в позиции eye
const cf = new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0);
cf._lookAt = target;
return cf;
}
static Angles(_rx, _ry, _rz) { return new RbxCFrame(); }
static fromEulerAnglesXYZ(_rx, _ry, _rz) { return new RbxCFrame(); }
}
/**
* Главная регистрация. Возвращает api-объект используемый Worker'ом.
*/
export function registerRobloxShim(lua, opts) {
const { send, getSceneSnapshot, getGuiTree, scheduleWait } = opts;
const global = lua.global;
// ------ Vector3 ------
// Lua: local v = Vector3.new(1,2,3); v.X; v + v; v.Magnitude
const Vector3Table = {
new: (x, y, z) => new RbxVector3(x, y, z),
zero: RbxVector3.zero,
one: RbxVector3.one,
xAxis: RbxVector3.xAxis,
yAxis: RbxVector3.yAxis,
zAxis: RbxVector3.zAxis,
FromNormalId: () => new RbxVector3(),
};
global.set('Vector3', Vector3Table);
// ------ Color3 ------
global.set('Color3', {
new: (r, g, b) => new RbxColor3(r, g, b),
fromRGB: (r, g, b) => RbxColor3.fromRGB(r, g, b),
fromHSV: (h, s, v) => RbxColor3.fromHSV(h, s, v),
fromHex: (hex) => {
const s = String(hex || '').replace('#', '');
if (s.length !== 6) return new RbxColor3();
return new RbxColor3(
parseInt(s.slice(0, 2), 16) / 255,
parseInt(s.slice(2, 4), 16) / 255,
parseInt(s.slice(4, 6), 16) / 255,
);
},
});
// ------ UDim / UDim2 / Vector2 / CFrame ------
global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
global.set('UDim2', {
new: (sx, ox, sy, oy) => new RbxUDim2(sx, ox, sy, oy),
fromScale: (sx, sy) => RbxUDim2.fromScale(sx, sy),
fromOffset: (ox, oy) => RbxUDim2.fromOffset(ox, oy),
});
global.set('Vector2', { new: (x, y) => new RbxVector2(x, y) });
global.set('CFrame', {
new: (x, y, z) => RbxCFrame.new(x, y, z),
lookAt: (e, t) => RbxCFrame.lookAt(e, t),
Angles: RbxCFrame.Angles,
fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
});
// ------ Enum (минимум) ------
global.set('Enum', {
KeyCode: Object.fromEntries([
'W', 'A', 'S', 'D', 'Space', 'LeftShift', 'LeftControl', 'F', 'E', 'Q',
'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'G', 'H', 'J', 'K', 'L', 'Z', 'X',
'C', 'V', 'B', 'N', 'M', 'Tab', 'Return', 'Escape', 'Backspace',
'Up', 'Down', 'Left', 'Right',
'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Zero',
].map(k => [k, { Name: k, Value: k }])),
UserInputType: Object.fromEntries([
'MouseButton1', 'MouseButton2', 'MouseButton3', 'MouseMovement',
'MouseWheel', 'Touch', 'Keyboard',
].map(k => [k, { Name: k, Value: k }])),
Material: Object.fromEntries([
'Plastic', 'Wood', 'Metal', 'Neon', 'Glass', 'Sand', 'Ice', 'Grass', 'Concrete',
].map(k => [k, { Name: k, Value: k }])),
HumanoidStateType: Object.fromEntries([
'Running', 'Jumping', 'Freefall', 'Landed', 'Dead', 'Climbing', 'Swimming', 'Seated',
].map(k => [k, { Name: k, Value: k }])),
EasingStyle: Object.fromEntries([
'Linear', 'Sine', 'Quad', 'Cubic', 'Quart', 'Quint', 'Bounce', 'Elastic',
].map(k => [k, { Name: k, Value: k }])),
EasingDirection: Object.fromEntries([
'In', 'Out', 'InOut',
].map(k => [k, { Name: k, Value: k }])),
});
// ------ print / warn / error логируются в студию ------
global.set('print', (...args) => {
const text = args.map(luaTostring).join('\t');
send('log', { level: 'info', text });
});
global.set('warn', (...args) => {
const text = args.map(luaTostring).join('\t');
send('log', { level: 'warn', text });
});
// Stdlib error — оставлен (бросает Lua-error). Дополнительно — наш logToMain в pcall.
// ------ task.* и wait() ------
// wait(sec) — приостанавливает текущую coroutine на sec секунд через scheduler.
// task.wait, task.spawn, task.delay — современные эквиваленты.
const taskTable = {
wait: (sec) => luaWait(sec),
spawn: (fn) => {
// task.spawn(fn) — стартует функцию как «корутину», немедленно резюмит
// у нас работает через прямой вызов pcall (упрощение, без честных coroutines)
try { if (typeof fn === 'function') fn(); } catch (_) {}
},
delay: (sec, fn) => {
// task.delay(sec, fn) — отложенный спавн
if (typeof fn !== 'function') return;
// Добавляем в scheduler
const wakeAt = SCHEDULER.now() + (Number(sec) || 0) * 1000;
SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } });
},
defer: (fn) => {
if (typeof fn === 'function') {
const wakeAt = SCHEDULER.now() + 0;
SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } });
}
},
synchronize: () => {},
desynchronize: () => {},
};
global.set('task', taskTable);
global.set('wait', (sec) => luaWait(sec));
/**
* luaWait блокировка текущего coroutine на sec секунд.
* NB: использует lua-side coroutine.yield + Worker scheduler.
* Здесь упрощение: возвращаем как обычный вызов (без honest yield) для MVP.
* Honest реализация прийдёт когда intgrate с DataModel в Этапе 3.
*/
function luaWait(_sec) {
return null;
}
// ------ game (минимум) ------
// На Этапе 2 это пустой стаб — реальный DataModel будет на Этапе 3.
// Но для совместимости с скриптами которые делают `game:GetService(...)`
// возвращаем заглушку которая на всё отвечает безопасными no-op.
const stubService = (name) => ({
__isService: true,
Name: name,
ClassName: name,
GetChildren: () => [],
GetDescendants: () => [],
FindFirstChild: () => null,
FindFirstChildOfClass: () => null,
WaitForChild: () => null,
IsA: () => false,
GetService: (n) => stubService(n),
ChildAdded: makeSignal(),
ChildRemoved: makeSignal(),
DescendantAdded: makeSignal(),
DescendantRemoving: makeSignal(),
});
const runService = stubService('RunService');
runService.Heartbeat = HEARTBEAT_SIGNAL;
runService.Stepped = STEPPED_SIGNAL;
runService.RenderStepped = HEARTBEAT_SIGNAL; // упрощённо
const gameTable = {
__isGame: true,
Name: 'game',
ClassName: 'DataModel',
GetService(name) {
if (name === 'RunService') return runService;
return stubService(name);
},
FindService(name) {
if (name === 'RunService') return runService;
return null;
},
Workspace: stubService('Workspace'),
Players: stubService('Players'),
ReplicatedStorage: stubService('ReplicatedStorage'),
ServerStorage: stubService('ServerStorage'),
Lighting: stubService('Lighting'),
StarterGui: stubService('StarterGui'),
StarterPack: stubService('StarterPack'),
StarterPlayer: stubService('StarterPlayer'),
RunService: runService,
UserInputService: stubService('UserInputService'),
TweenService: stubService('TweenService'),
HttpService: stubService('HttpService'),
DataStoreService: stubService('DataStoreService'),
MarketplaceService: stubService('MarketplaceService'),
Chat: stubService('Chat'),
SoundService: stubService('SoundService'),
PathfindingService: stubService('PathfindingService'),
PhysicsService: stubService('PhysicsService'),
TeleportService: stubService('TeleportService'),
CollectionService: stubService('CollectionService'),
ContextActionService: stubService('ContextActionService'),
ContentProvider: stubService('ContentProvider'),
LocalizationService: stubService('LocalizationService'),
};
global.set('game', gameTable);
global.set('workspace', gameTable.Workspace);
global.set('Workspace', gameTable.Workspace);
// ------ Instance.new ------
// Возвращает «pseudo-instance» — на Этапе 2 это просто object с пропсами.
// На Этапе 3 будет полноценный класс с metatable и Parent setter.
global.set('Instance', {
new: (className, parent) => {
const inst = {
ClassName: String(className || 'Instance'),
Name: String(className || 'Instance'),
Parent: parent || null,
Children: [],
Destroyed: false,
Touched: makeSignal(),
Activated: makeSignal(),
MouseButton1Click: makeSignal(),
Changed: makeSignal(),
AncestryChanged: makeSignal(),
ChildAdded: makeSignal(),
ChildRemoved: makeSignal(),
GetChildren() { return [...this.Children]; },
FindFirstChild() { return null; },
WaitForChild() { return null; },
IsA() { return false; },
Destroy() { this.Destroyed = true; },
Clone() { return null; },
GetFullName() { return this.Name; },
GetAttribute() { return null; },
SetAttribute() {},
};
return inst;
},
});
// ------ Helpers для Worker'а ------
// __rbxl_register_coroutine(id, co) — мы её отдадим, чтобы зарегистрировать в JS
const coroutinesById = new Map();
global.set('__rbxl_register_coroutine', (id, co) => {
coroutinesById.set(String(id), co);
});
global.set('__rbxl_get_part_by_id', (_id) => {
// На Этапе 3 будет lookup в DataModel. Пока nil (script.Parent = nil)
return null;
});
global.set('__rbxl_script_run', (id, scriptObj, body) => {
// Запускает body() с обработкой ошибок. id и scriptObj прокидываются
// только для будущего использования (например, регистрации в DataModel).
try {
if (typeof body === 'function') body();
} catch (err) {
send('log', {
level: 'error',
text: `[Lua ${id}] ${err?.message || err}`,
});
}
});
// ------ Возвращаем api для Worker'а ------
return {
// обновление снапшотов (будет использовано на Этапе 3 для DataModel)
onSceneSnapshot() {},
onGuiSnapshot() {},
onDataSnapshot() {},
// tick scheduler — резюм ожидающих task.delay/defer
tickScheduler(_dt) {
const now = SCHEDULER.now();
if (SCHEDULER.sleeping.length === 0) return;
const ready = [];
const rest = [];
for (const t of SCHEDULER.sleeping) {
if (t.wakeAt <= now) ready.push(t);
else rest.push(t);
}
SCHEDULER.sleeping = rest;
for (const t of ready) {
try { t.run(); } catch (_) {}
}
},
fireHeartbeat(dt) {
try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {}
try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {}
},
fireTargetEvent(p) {
// На Этапе 3 — найти Part в DataModel и фейернуть его Touched
// Сейчас — no-op (но не падаем)
// Возможные kind: 'touch', 'untouch', 'click'
if (!p) return;
},
fireGlobalEvent(_p) {
// playerTouch / guiClick / keydown — также на Этапе 3
},
};
}
// --- Утилиты ---
function luaTostring(v) {
if (v == null) return 'nil';
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (v instanceof RbxVector3) return `${v.X}, ${v.Y}, ${v.Z}`;
if (v instanceof RbxColor3) return `${v.R}, ${v.G}, ${v.B}`;
if (typeof v === 'object') {
if (v.Name) return String(v.Name);
return '[object]';
}
try { return String(v); } catch (_) { return '?'; }
}