From 06df77cc97f4be36ad48bea00574dfabca9624b8 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 09:57:12 +0300 Subject: [PATCH 001/214] =?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 002/214] =?UTF-8?q?fix(lua):=20tick()=20=D0=B2=20LuaShared?= =?UTF-8?q?Sandbox=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 003/214] =?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 004/214] =?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 005/214] =?UTF-8?q?feat(lua):=20=D0=AD=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=203=20=E2=80=94=20DataModel=20+=20Touched=20+=20Humanoid=20(ma?= =?UTF-8?q?in-thread=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 006/214] =?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 007/214] =?UTF-8?q?fix(lua):=20humanoid.Health=3D0=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=20=D1=83=D0=B1=D0=B8=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=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 008/214] =?UTF-8?q?feat(lua):=20=D0=AD=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=204=20=E2=80=94=20Part=20setters=20+=20task.wait=20+=20Instanc?= =?UTF-8?q?e.new=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 009/214] =?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 010/214] =?UTF-8?q?fix(lua):=20partSet=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20PrimitiveManager.updateInstance=20(?= =?UTF-8?q?=D0=B1=D1=8B=D0=BB=20applyPatch/update=20=E2=80=94=20=D0=B8?= =?UTF-8?q?=D1=85=20=D0=BD=D0=B5=D1=82);=20=D0=BE=D1=87=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BA=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 011/214] =?UTF-8?q?fix(lua):=20part.Position=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=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BA=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 012/214] =?UTF-8?q?feat(lua):=20=D0=AD=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=205=20=E2=80=94=20GUI=20(Frame,=20TextLabel,=20TextButton,=20I?= =?UTF-8?q?mageLabel,=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 013/214] =?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 014/214] =?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 015/214] =?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 016/214] =?UTF-8?q?fix(lua):=20Proxy=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20Instance=20=E2=80=94=20unknown=20=D1=81=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0?= =?UTF-8?q?=D1=89=D0=B0=D1=8E=D1=82=20stub=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=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 017/214] =?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 018/214] =?UTF-8?q?fix(lua):=20universal=20callable-stub?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20unknown=20=D1=81=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?=D1=81=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 019/214] =?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 020/214] =?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 021/214] =?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 022/214] =?UTF-8?q?revert:=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.rbxl-=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=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 Попытка выполнять 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 023/214] =?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 024/214] =?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 025/214] =?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 026/214] =?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 027/214] =?UTF-8?q?feat(rbxl):=20Tool/Backpack/Mouse=20flo?= =?UTF-8?q?w=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 028/214] =?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 029/214] =?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 030/214] =?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 031/214] =?UTF-8?q?fix(lua):=20Signal=20Fire=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82=20Lua-handler?= =?UTF-8?q?=20=D0=B2=20=D1=81=D0=BE=D0=B1=D1=81=D1=82=D0=B2=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=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 032/214] =?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 033/214] =?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 034/214] =?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 035/214] =?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 036/214] =?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 037/214] =?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 038/214] =?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 039/214] =?UTF-8?q?feat(rbxl-importer):=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20XML-=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=D1=82=D0=B0=20.rbxl=20(=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=8B=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BE=202010)?= 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 From 14e173a0891f7fb2e14b6ec13b640f6165df2e4d Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 16:49:54 +0300 Subject: [PATCH 040/214] =?UTF-8?q?fix(import):=20=D1=81=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=86=D0=B2=D0=B5=D1=82=D0=B0=20=D0=B2=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20Roblox-=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crossroads-импорт давал тёмно-грязные цвета вместо классических насыщенных Roblox-цветов: - трава тёмно-зелёная вместо ярко-зелёной - дороги серые вместо белых - крыши приглушённо-красные Причины: 1. mat.ambientColor=(0,0,0) default — scene.ambientColor=(0.3,0.3,0.3) не действовал. Тени получали 0 контрибьюшна цвета. 2. material=glossy (default для Roblox Plastic) шёл в case default: только specularColor=(0,0,0), без emissive — цвет blandный. Фикс: - mat.ambientColor=(1,1,1) для всех материалов: подмешивает scene ambient в тени, цвета остаются видны. - Для glossy/default: emissive = 25% цвета (как в studs/45%, но скромнее), specular слабый (0.05). Roblox-look — насыщенный даже без прямого света. Также case 'matte' теперь отдельный (был под default). --- .../rbxl_xml_parser.cpython-314.pyc | Bin 17055 -> 17055 bytes src/editor/engine/PrimitiveManager.js | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc index c20812bac769ab2c1d1527828004790db65e67a9..b54fe31622bf15fe66ca9c173c8931c119ea6617 100644 GIT binary patch delta 25 fcmbQ=$~eE3kx!eCmx}=ijvP|U(lgk|H_-_IR3HYg delta 25 fcmbQ=$~eE3kx!eCmx}=i_8nBqx}mp`Z=w?bSIq}d diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 9e6a556..6f23b06 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -507,6 +507,11 @@ export class PrimitiveManager { const matName = `${mesh.name}_mat`; const mat = new StandardMaterial(matName, this.scene); mat.diffuseColor = Color3.FromHexString(color || '#888888'); + // ambient — даёт «среднее» освещение боковым граням что не освещены + // прямо. Без этого scene.ambientColor=(0.3,0.3,0.3) не работает — + // материал тени получает только diffuse от hemi, что приводит к + // тёмно-грязным цветам в импортированных Roblox-картах. + mat.ambientColor = new Color3(1, 1, 1); // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // используется для GD-скинов куба (например /gd/skins/cube_smile.png). @@ -567,9 +572,20 @@ export class PrimitiveManager { break; } case 'matte': - default: mat.specularColor = new Color3(0, 0, 0); break; + case 'glossy': + default: { + // Roblox Plastic — это белый specular маленький + цвет «сочный» + // даже в тени. Подмешиваем 25% цвета в emissive чтобы избежать + // грязных тёмных пятен в импортированных Roblox-картах + // (Crossroads и подобные). Specular низкий и нейтральный. + const dc = Color3.FromHexString(color || '#cccccc'); + mat.emissiveColor = new Color3(dc.r * 0.25, dc.g * 0.25, dc.b * 0.25); + mat.specularColor = new Color3(0.05, 0.05, 0.05); + mat.specularPower = 64; + break; + } } // Триггеры — всегда полупрозрачные жёлтые в редакторе -- 2.47.2 From 00014717ab53b4024255eb57a928b0a664c28a97 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 16:52:39 +0300 Subject: [PATCH 041/214] =?UTF-8?q?fix(import):=20=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8C=D1=88=D0=B8=D0=BB=20ambient/emissive=20=E2=80=94=20?= =?UTF-8?q?=D0=B1=D1=8B=D0=BB=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=87=D0=B5=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Версия 25% emissive + 1.0 ambient дала картинку слишком плоской: пропали тени, объём, контраст. Roblox-оригинал имеет чёткие тени от строений и контрастные грани. Новые значения: - mat.ambientColor: 1.0 → 0.5 (всё ещё подмешивает scene ambient в тени, но не убивает контраст) - glossy emissive: 25% → 8% (цвет 'живой' но не светится) Должно дать баланс: цвет в тени виден, при этом тени остаются тенями. --- src/editor/engine/PrimitiveManager.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 6f23b06..0f501a8 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -507,11 +507,10 @@ export class PrimitiveManager { const matName = `${mesh.name}_mat`; const mat = new StandardMaterial(matName, this.scene); mat.diffuseColor = Color3.FromHexString(color || '#888888'); - // ambient — даёт «среднее» освещение боковым граням что не освещены - // прямо. Без этого scene.ambientColor=(0.3,0.3,0.3) не работает — - // материал тени получает только diffuse от hemi, что приводит к - // тёмно-грязным цветам в импортированных Roblox-картах. - mat.ambientColor = new Color3(1, 1, 1); + // ambient умеренный — чтобы тени не становились грязно-чёрными, + // но сохранялся контраст между освещёнными/затенёнными гранями. + // (1,1,1) делает картинку плоской; 0.5 — нормальный компромисс. + mat.ambientColor = new Color3(0.5, 0.5, 0.5); // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // используется для GD-скинов куба (например /gd/skins/cube_smile.png). @@ -576,12 +575,11 @@ export class PrimitiveManager { break; case 'glossy': default: { - // Roblox Plastic — это белый specular маленький + цвет «сочный» - // даже в тени. Подмешиваем 25% цвета в emissive чтобы избежать - // грязных тёмных пятен в импортированных Roblox-картах - // (Crossroads и подобные). Specular низкий и нейтральный. + // Roblox Plastic — лёгкий specular + чуть emissive чтобы цвет + // не тух в тенях. 8% emissive — сохраняем контраст света/тени + // (как в Roblox: видны грани, объём, тени от строений). const dc = Color3.FromHexString(color || '#cccccc'); - mat.emissiveColor = new Color3(dc.r * 0.25, dc.g * 0.25, dc.b * 0.25); + mat.emissiveColor = new Color3(dc.r * 0.08, dc.g * 0.08, dc.b * 0.08); mat.specularColor = new Color3(0.05, 0.05, 0.05); mat.specularPower = 64; break; -- 2.47.2 From 3e928e8d4e967ccdfba790297d0ea0eb5a52ffae Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 16:56:35 +0300 Subject: [PATCH 042/214] =?UTF-8?q?fix(import):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=20emissive,=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20amb?= =?UTF-8?q?ient=20=D0=B4=D0=BB=D1=8F=20=D1=82=D0=B5=D0=BD=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emissive прибавлялся к diffuse даже на ярком свету — пересвечивал цвета (особенно белые/серые). Новая схема: - emissive = 0 (всегда) - mat.ambientColor = (1,1,1) — пропускает scene.ambientColor (0.3) в тени, делает тени 30% яркости цвета вместо чёрных - На прямом свете diffuse доминирует, всё как должно быть Это должно дать Roblox-look: серый #cccccc выглядит серым, белый выглядит белым, в тенях цвет виден но темнее. --- src/editor/engine/PrimitiveManager.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 0f501a8..7d2c160 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -507,10 +507,10 @@ export class PrimitiveManager { const matName = `${mesh.name}_mat`; const mat = new StandardMaterial(matName, this.scene); mat.diffuseColor = Color3.FromHexString(color || '#888888'); - // ambient умеренный — чтобы тени не становились грязно-чёрными, - // но сохранялся контраст между освещёнными/затенёнными гранями. - // (1,1,1) делает картинку плоской; 0.5 — нормальный компромисс. - mat.ambientColor = new Color3(0.5, 0.5, 0.5); + // ambient (1,1,1) — позволяет scene.ambientColor (0.3,0.3,0.3) подсветить + // тени до 30% яркости цвета. На прямом свете diffuse доминирует. + // Без emissive — контраст света/тени сохраняется как в Roblox. + mat.ambientColor = new Color3(1, 1, 1); // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // используется для GD-скинов куба (например /gd/skins/cube_smile.png). @@ -575,11 +575,10 @@ export class PrimitiveManager { break; case 'glossy': default: { - // Roblox Plastic — лёгкий specular + чуть emissive чтобы цвет - // не тух в тенях. 8% emissive — сохраняем контраст света/тени - // (как в Roblox: видны грани, объём, тени от строений). - const dc = Color3.FromHexString(color || '#cccccc'); - mat.emissiveColor = new Color3(dc.r * 0.08, dc.g * 0.08, dc.b * 0.08); + // Roblox Plastic — слабый specular, без emissive. + // diffuse=#cccccc должно выглядеть СЕРЫМ (как в Roblox). + // ambient (от scene 0.3 × mat.ambient 0.4) даёт цвет в тенях, + // но не убивает контраст. mat.specularColor = new Color3(0.05, 0.05, 0.05); mat.specularPower = 64; break; -- 2.47.2 From 3a82b3c64db02f9ad7d4b8f0f97794200f5bde7b Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 17:00:25 +0300 Subject: [PATCH 043/214] =?UTF-8?q?fix(import):=20=D1=80=D0=B0=D1=81=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=B8=D0=BB=20BrickColor=20=D0=BF=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D1=80=D1=83=20+=20=D1=83=D0=B1=D1=80=D0=B0=D0=BB?= =?UTF-8?q?=20ambient=20material?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Главная причина пересвета: 1. BrickColor 151 (Earth green = трава Crossroads) ОТСУТСТВОВАЛ в таблице. Пол получал дефолт #cccccc и выглядел белым. После анализа карты 344 примитива использовали дефолт. 2. mat.ambientColor=(1,1,1) + scene.ambientColor=(0.3) делало белые цвета пересветлёнными — серый выглядел белым. Фикс: - BRICKCOLOR_TO_HEX расширен с ~50 до ~120 цветов. Добавлены: 151 (Earth green), 26 темный, 18, 115-148, 168-301, 1021-1032 и др. После: #cccccc дефолт 344→68 (бОльшая часть теперь правильных). - Убран mat.ambientColor — оставлен default (0,0,0). Lambert чистый: освещённая грань = diffuse, тень = почти чёрная (scene.ambient смягчает). Цвета теперь точно как в diffuse, без пересвета. Деплой: converter.py скопирован на VM 130 + systemctl restart. --- .../src/__pycache__/converter.cpython-314.pyc | Bin 56367 -> 58378 bytes rbxl-importer/src/converter.py | 39 ++++++++++++++---- src/editor/engine/PrimitiveManager.js | 9 ++-- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc index 6f7af917aede50d7dc23403ed78f374e4b9983d0..447629e0731214e02241ee9ac9b1774cd9733126 100644 GIT binary patch delta 4338 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 delta 2193 zcmZXSdr(wW9LMkPE|=ZqvEstA>mmUH0)z6}1$GOc_{2x}OGVTrSRTGmLNUchOW6ZZ z3qAQxB=dpgBzl_0a?;F4rcN-8Lyk>lR#P!eVhuBm^+&&Z?$xo{KR&7Kc37bTYLP0t%-4q0EdUh@m)=Xu}Mm9W#jz%py85 zn>ZA6h_UD*CZn4;9CL})m`AL^eBvVX5ErADxC9^fL#9-=6rZ5cGAtn0;wWMrjwY_b zF@6Y?ZN#xOXvJ~FtvH^z2MdWG;{@U-IFa}%P9pZ;Wa4d{LcD`h{eVR{jW``=_@R)t z!kPFajf-&>5%DSFY@9=!izUQ)SW29a3y2HRM=Zm@(^Sf_g6PLeVii^sYj6>9F)kr4 z#bv}=Tuxkpb?R354Ay5-ZHU8_b_rM6rP_#wcx)hFEj$6AC0`?a7_KF66rPCd$kz)` z!VPvQ3O9;DvQM^22@*a>j{~eH$R625_PkqG|1?QzNbx!G1$IAZs&mtWxj3mz!WXAn zrldEd$4R~s3G7?N<`Ewn1^hORP&N34ggE!GXvE$22mrov!UbzJ6`k@n}!OM3H`z#hc^I2Yr+A zt#Ansu_@-b#KWF>l2nx>Nkg?8Ir6{L9A&NM*x1fT-(lK$NlPE{jiK`%V;9Z!@HWp2 zQA})xbtqeDbq43;yRu!?Y`b-Bc|}E~$6c|`m{{%cyFGa%o(fMzzA8C+?z|j7iPxW( zm8)iQD_mLGUUjC@>-T1pxGG)Mt{Rdme^q5wB}olUROG5sQ$tJfTAKrSiaiTp1E1&U zlc^NOi~-opHVr*a%!)k++gN|>_rx2IU4>ojKwLkuE&dkl=gSg~02OOu7_hss3QJ77 z44temX*!(X`N`vDD(g~a1Ds&psZDT(7pGm2;Vh3Du^Hej>lpbtbo085d`13C;+?Kq zprI}IkPM;x!~6j=m9BAa0~GNeC)AsbMf8!aavIn-B}M$`%*h65;&*5LtWddGvQLI~ z-Z0;jJt&Y{m@)m({ojbVSZoPtVzr1b-ZN_ks1T@%mRe$hZ> zV8;f41AOJKlwc}*_qWS%j(aqMcf)}1HmZ!z-TQ=y?SgWvh+7Xyv1pSLSe z&wseQK8Q;BwOs%${LkxV8Csa!{S)lwechL2DqUZk1$yhc8}!x}SYPj1xXzD#xlooj z26N-BjS#Tdx`%K3dD`F7mp23Ieo&7&x?sA!>s|YtUUZ=q@%8?$q)zYTl{kbyxHE zbXW7jd!)ew!^0qI;4uS44}=-Ot)gIn*L=4{c3eprTx?LT8SKS&rNw?b diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index 8785b7f..dd054c7 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -103,19 +103,42 @@ SHAPE_TO_PRIMITIVE = { # ────── BrickColor таблица (упрощённая) ────── # Roblox использует old BrickColor enum (числа 1-1032). Только распространённые: BRICKCOLOR_TO_HEX = { - 1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea', - 21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e', - 28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', - 101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', - 105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', - 111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76', - 141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91', - 199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8', + # Базовые тона + 1: '#f2f3f3', 2: '#a1a5a2', 3: '#f9e999', 5: '#d9e4f7', + 9: '#9c9e9c', 11: '#e8eaea', 18: '#cc8e69', 21: '#c4281c', + 23: '#0d69ac', 24: '#f5cd30', 26: '#1b2a35', 28: '#293f1a', + 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', 101: '#dab8a3', + 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', 105: '#cf8b3e', + 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', 111: '#a7a6a6', + 115: '#c7d23c', 116: '#56fff0', 118: '#b4d2e4', 119: '#aac84a', + 120: '#d4f0a6', 123: '#cf6b6f', 124: '#9c54a6', 125: '#e8b486', + 126: '#a6c2e3', 127: '#deb87b', 128: '#a37e5b', 131: '#9ba19d', + 133: '#cc7c39', 134: '#de8b5f', 135: '#74859c', 136: '#876a7a', + 137: '#e6a262', 138: '#8a8a76', 140: '#234770', 141: '#26462b', + 143: '#bdc3e3', 145: '#5c8aa1', 146: '#75718b', 147: '#9a8a64', + 148: '#5a605a', 149: '#1b2a47', 150: '#9ea1a3', + # ВАЖНО: 151 — Earth green (тёмная трава Crossroads) + 151: '#7c9b53', + 153: '#9b605a', 154: '#7a2d2d', 157: '#f5e09c', 158: '#b58c9c', + 168: '#3c3a37', 176: '#a39989', 178: '#aa724c', 180: '#cc9555', + 190: '#f7b830', 191: '#e69138', + 192: '#5a3019', 193: '#f59d24', 194: '#9c9b91', 195: '#447ba6', + 196: '#283970', 198: '#7b4b85', 199: '#3c3e3f', 200: '#7a854b', + 208: '#dbdcdc', 209: '#a4733f', 210: '#7d8a8e', 211: '#9da3b3', + 212: '#a5cce0', 213: '#6584b5', 215: '#7c8aa4', 216: '#8a5040', + 217: '#7a5443', 218: '#94748a', 219: '#5c5a8a', 220: '#a3a8c4', + 221: '#cc4488', 222: '#e8a8e0', 223: '#dd7790', 224: '#f3e3a5', + 225: '#e8b685', 226: '#fff8a8', 232: '#bce0f0', 268: '#3c2e74', + 301: '#73584b', + # Бипалитра 1001-1032 — стандартные яркие цвета 1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000', 1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00', 1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff', 1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0', 1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80', + 1021: '#80c0ff', 1022: '#80ffff', 1023: '#80ff00', 1024: '#00ff80', + 1025: '#ff4040', 1026: '#8a0028', 1027: '#001f80', 1028: '#4d4d4d', + 1029: '#9d9d9d', 1030: '#5e3923', 1031: '#7a4f30', 1032: '#cca5a5', } diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 7d2c160..20cc931 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -507,10 +507,11 @@ export class PrimitiveManager { const matName = `${mesh.name}_mat`; const mat = new StandardMaterial(matName, this.scene); mat.diffuseColor = Color3.FromHexString(color || '#888888'); - // ambient (1,1,1) — позволяет scene.ambientColor (0.3,0.3,0.3) подсветить - // тени до 30% яркости цвета. На прямом свете diffuse доминирует. - // Без emissive — контраст света/тени сохраняется как в Roblox. - mat.ambientColor = new Color3(1, 1, 1); + // ambient НЕ выставляем — иначе scene.ambient (0.3) + ambient(0.4-1.0) + // приплюсовывается к diffuse и цвета становятся пересвеченными + // (особенно белые/серые становятся пересветлёнными). + // Babylon default (0,0,0) даёт чистый Lambert: освещённый цвет=diffuse, + // тень=чёрная (что в Roblox смягчается scene.ambient). // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // используется для GD-скинов куба (например /gd/skins/cube_smile.png). -- 2.47.2 From 364726481f159a15f7c8f9d780f4d93861c59822 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 17:02:08 +0300 Subject: [PATCH 044/214] =?UTF-8?q?fix(import):=20mat.ambient=20=3D=20diff?= =?UTF-8?q?useColor=20(=D1=82=D0=B5=D0=BD=D1=8C=20=D0=BE=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B0,=20=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=B2=D0=B5=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прошлая итерация без ambient давала почти-чёрные грани в тени — sun под углом + hemi только вверх = низ/бок граней получают только скудный hemi.groundColor=(0.3,0.3,0.4) = тёмные пятна. Roblox-look: тень это просто менее яркий вариант цвета (не чёрный). Фикс: mat.ambientColor = mat.diffuseColor (= цвет примитива). scene.ambientColor=(0.3) × ambient(цвет) = 30% цвета в тени. На прямом свете diffuse доминирует — белые остаются белыми, зелёные зелёными. Это даёт тени окрашенные (как в Roblox), сохраняя контраст со светом и точность цвета. --- src/editor/engine/PrimitiveManager.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 20cc931..b1094fc 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -506,12 +506,12 @@ export class PrimitiveManager { _applyMaterial(mesh, typeDef, color, material, textureUrl) { const matName = `${mesh.name}_mat`; const mat = new StandardMaterial(matName, this.scene); - mat.diffuseColor = Color3.FromHexString(color || '#888888'); - // ambient НЕ выставляем — иначе scene.ambient (0.3) + ambient(0.4-1.0) - // приплюсовывается к diffuse и цвета становятся пересвеченными - // (особенно белые/серые становятся пересветлёнными). - // Babylon default (0,0,0) даёт чистый Lambert: освещённый цвет=diffuse, - // тень=чёрная (что в Roblox смягчается scene.ambient). + const dc = Color3.FromHexString(color || '#888888'); + mat.diffuseColor = dc; + // ambient = СОБСТВЕННЫЙ цвет (умеренный). Это даёт цвет в тени + // равный ~30% от собственного, а не белый (как делал ambient=(1,1,1)). + // На светлом полу: белые цвета остаются белыми, тёмные грани не чёрные. + mat.ambientColor = dc; // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // используется для GD-скинов куба (например /gd/skins/cube_smile.png). -- 2.47.2 From 67851820a9bb4098dcd6277c02314d02059a9aaa Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 17:03:17 +0300 Subject: [PATCH 045/214] =?UTF-8?q?fix(import):=20ambient=20=3D=2040%=20?= =?UTF-8?q?=D0=BE=D1=82=20=D1=86=D0=B2=D0=B5=D1=82=D0=B0=20(=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=B2=D0=B5=D1=82=D0=B0?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ambient=diffuse (100%) суммировался с прямым светом и давал пересвет (особенно на дорогах/полу с #cccccc). 40% — баланс: тень окрашена (видна как цвет, не чёрная), прямой свет = чистый diffuse без пересвета. --- src/editor/engine/PrimitiveManager.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index b1094fc..d0471ad 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -508,10 +508,9 @@ export class PrimitiveManager { const mat = new StandardMaterial(matName, this.scene); const dc = Color3.FromHexString(color || '#888888'); mat.diffuseColor = dc; - // ambient = СОБСТВЕННЫЙ цвет (умеренный). Это даёт цвет в тени - // равный ~30% от собственного, а не белый (как делал ambient=(1,1,1)). - // На светлом полу: белые цвета остаются белыми, тёмные грани не чёрные. - mat.ambientColor = dc; + // ambient = 40% от цвета. Roblox-look: тень окрашена, но не + // суммируется с прямым светом в пересвет. Белые остаются белыми. + mat.ambientColor = new Color3(dc.r * 0.4, dc.g * 0.4, dc.b * 0.4); // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // используется для GD-скинов куба (например /gd/skins/cube_smile.png). -- 2.47.2 From 19f47b2d75e6f7e30994e8fe564d752886f4ef60 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 18:54:00 +0300 Subject: [PATCH 046/214] =?UTF-8?q?feat(inspector):=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=BB=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D1=8B?= =?UTF-8?q?=20=D1=81=D0=B2=D0=B5=D1=82=D0=B0=20=E2=80=94=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B2=D0=BA=D0=B0=20=D1=82=D0=B5=D0=BD=D0=B5=D0=B9?= =?UTF-8?q?,=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8?= =?UTF-8?q?=D1=8F,=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D1=81=D1=82,=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=8B=D1=89=D0=B5=D0=BD=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В Свет и атмосфера добавлено: - Заливка теней (scene.ambientColor) — позволяет окрасить тени в сером тоне без пересвета diffuse материалов. - Экспозиция (ipc.exposure 0.3-2) — общая яркость через imageProcessingConfiguration. - Контраст (ipc.contrast 0.5-2) - Насыщенность (colorCurves.globalSaturation -100..+100) Юзер крутит слайдеры до момента когда импортированная Roblox-карта выглядит как оригинал. Дефолты: ambient 0.3, exposure 1.0, contrast 1.0, saturation 1.0. Также убрал mat.ambientColor=цвет — теперь default (0,0,0). Освещение управляется глобально через панель. Состояние пока не сохраняется в проект (только сессия). Persistence добавим в следующем шаге. --- src/editor/InspectorPanel.jsx | 62 +++++++++++++++++++++++++++ src/editor/engine/BabylonScene.js | 33 ++++++++++++++ src/editor/engine/PrimitiveManager.js | 8 ++-- src/editor/engine/SelectionManager.js | 5 +++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/editor/InspectorPanel.jsx b/src/editor/InspectorPanel.jsx index 35d68b1..f450038 100644 --- a/src/editor/InspectorPanel.jsx +++ b/src/editor/InspectorPanel.jsx @@ -526,11 +526,73 @@ const InspectorPanel = ({ style={{ width: '100%' }} /> +
+
+ Заливка теней + {(selection.sceneAmbient ?? 0.3).toFixed(2)} +
+ props.onSetLightingProps?.({ sceneAmbient: parseFloat(e.target.value) })} + style={{ width: '100%' }} + /> +
+ Подсветка теней — цвет в затенённых гранях. 0 = чёрные тени, 1 = плоско. +
+
Цвет окружающего света подбирается автоматически по времени суток.
+ {/* Цветокоррекция */} +
+
Цветокоррекция
+
+
+ Экспозиция + {(selection.exposure ?? 1.0).toFixed(2)} +
+ props.onSetLightingProps?.({ exposure: parseFloat(e.target.value) })} + style={{ width: '100%' }} + /> +
+ Общая яркость. <1 = темнее, >1 = светлее. +
+
+
+
+ Контраст + {(selection.contrast ?? 1.0).toFixed(2)} +
+ props.onSetLightingProps?.({ contrast: parseFloat(e.target.value) })} + style={{ width: '100%' }} + /> +
+
+
+ Насыщенность + {(selection.saturation ?? 1.0).toFixed(2)} +
+ props.onSetLightingProps?.({ saturation: parseFloat(e.target.value) })} + style={{ width: '100%' }} + /> +
+ 0 = чёрно-белое, 1 = норма, 2 = очень сочно. +
+
+
+ {/* Туман */}
Туман
diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 175c430..dc89f9a 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -37,6 +37,7 @@ import { Ray, PointerEventTypes, Tools as BabylonTools, + ColorCurves, } from '@babylonjs/core'; import { PlacementManager } from './PlacementManager'; import { ShopInventoryUi } from './ShopInventoryUi'; @@ -1885,9 +1886,41 @@ export class BabylonScene { } if (typeof patch.sunIntensity === 'number' && this._sunLight) { this._sunLight.intensity = Math.max(0, patch.sunIntensity); + this._sunIntensity = patch.sunIntensity; } if (typeof patch.hemiIntensity === 'number' && this._hemiLight) { this._hemiLight.intensity = Math.max(0, patch.hemiIntensity); + this._hemiIntensity = patch.hemiIntensity; + } + // Окружающий свет (scene.ambientColor) — отдельный множитель. + // Применяется ко всем материалам через ambient*ambient. + if (typeof patch.sceneAmbient === 'number') { + const v = Math.max(0, Math.min(1, patch.sceneAmbient)); + this.scene.ambientColor = new Color3(v, v, v); + this._sceneAmbient = v; + } + // Цветокоррекция — экспозиция, контраст, насыщенность через + // imageProcessingConfiguration (включает HDR pipeline). + if (typeof patch.exposure === 'number' || typeof patch.contrast === 'number' + || typeof patch.saturation === 'number') { + const ipc = this.scene.imageProcessingConfiguration; + ipc.isEnabled = true; + if (typeof patch.exposure === 'number') { + ipc.exposure = Math.max(0.1, Math.min(3, patch.exposure)); + this._exposure = ipc.exposure; + } + if (typeof patch.contrast === 'number') { + ipc.contrast = Math.max(0.5, Math.min(2.5, patch.contrast)); + this._contrast = ipc.contrast; + } + if (typeof patch.saturation === 'number') { + // colorCurves для saturation (стандартный Babylon приём) + if (!ipc.colorCurves) ipc.colorCurves = new ColorCurves(); + const s = Math.max(-100, Math.min(100, (patch.saturation - 1) * 100)); + ipc.colorCurves.globalSaturation = s; + ipc.colorCurvesEnabled = true; + this._saturation = patch.saturation; + } } if (this.environment && typeof this.environment.setFog === 'function') { // Текущие значения берём из Environment, поверх накладываем patch diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index d0471ad..8d7d899 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -506,11 +506,9 @@ export class PrimitiveManager { _applyMaterial(mesh, typeDef, color, material, textureUrl) { const matName = `${mesh.name}_mat`; const mat = new StandardMaterial(matName, this.scene); - const dc = Color3.FromHexString(color || '#888888'); - mat.diffuseColor = dc; - // ambient = 40% от цвета. Roblox-look: тень окрашена, но не - // суммируется с прямым светом в пересвет. Белые остаются белыми. - mat.ambientColor = new Color3(dc.r * 0.4, dc.g * 0.4, dc.b * 0.4); + mat.diffuseColor = Color3.FromHexString(color || '#888888'); + // ambient = default (0,0,0). Освещение настраивается через + // глобальную панель «Свет и атмосфера» (sun/hemi/saturation). // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // используется для GD-скинов куба (например /gd/skins/cube_smile.png). diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index f20b397..834197f 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -282,6 +282,11 @@ export class SelectionManager { fogColor: env ? `#${[env.fogColor?.[0] ?? 0.7, env.fogColor?.[1] ?? 0.8, env.fogColor?.[2] ?? 0.9].map(c => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, '0')).join('')}` : '#b0c8e6', shadowQuality: this._scene3d.getShadowQuality?.() || 'soft', ssaoEnabled: this._scene3d.getSsaoEnabled?.() || false, + // Новые: глобальный ambient + image processing + sceneAmbient: this._scene3d._sceneAmbient ?? 0.3, + exposure: this._scene3d._exposure ?? 1.0, + contrast: this._scene3d._contrast ?? 1.0, + saturation: this._scene3d._saturation ?? 1.0, }; this._notifyChange(); } -- 2.47.2 From 5b44a286a9869289283e95ea37b924a2688c5cb3 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 18:59:23 +0300 Subject: [PATCH 047/214] =?UTF-8?q?fix(import):=20=D0=B7=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D1=82=D0=B5=D0=BD=D0=B5=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20+=20Anchored=3DTru?= =?UTF-8?q?e=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82.=20Part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. PrimitiveManager: mat.ambientColor=(1,1,1). Теперь scene.ambientColor ("Заливка теней" слайдер) реально влияет на тени. Юзер крутит значение и видит изменение. 2. converter.py: Roblox-Part импортируется всегда с Anchored=True (force-anchored). Welds у нас заглушки, без них unanchored Part'ы рассыпаются физикой. Если юзеру нужно падающее — снимет в инспекторе вручную. Деплой converter.py на VM 130 + systemctl restart. --- .../src/__pycache__/converter.cpython-314.pyc | Bin 58378 -> 57867 bytes rbxl-importer/src/converter.py | 28 +++++++++++++----- src/editor/engine/PrimitiveManager.js | 7 +++-- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc index 447629e0731214e02241ee9ac9b1774cd9733126..6a970143b7f310de1a057acf50dc9337cf620ebd 100644 GIT binary patch delta 3463 zcmd^CYfN0n6~1$qeZaoW!ajM}%fc=uJk2W!i3t!8q%K@A#HfW78~Gu};04Eo##uW^ zmF-Gq)nn>SWRi7S+e`ggd}WDPNlR2WvYgm#vUWn;HgYN}X&qNw6j!)O6!px#?1o5= z^QVd`b$^`s&bc%9&dfL8IkWpO%6@r4rdz4gCNcOs{z>+hU1ukCb7r|=z`MSHudtlY zvzN(N&NFC^xCPowt`eK6S`5PE7JGu~^Sufn6%M$)#msltjmcG zX;x$K=Lj-HH#E!qb%Gp`pkTDg2Y(!_3zbDp#e$NCRE*Z(PxiNtS%gH%cVq-*!J4D0 zn7=YVG!QO1H6-~-jMnI1A*gBJB*ttHJQl;tUBMm+>oknkbg(N}7fs0(v5SGANDj1(TZIhCcNK<{!)@oR zQ%x6>FScE_&eQnbL}3NAw2XURBn&gK0{80fZf1c=r`wO_NQFR~$y%|QYQx~-_J1LZ zZ9jC^9e~TjcliFFoFEP^1!=HwGPG9Yz_yeFh^%#krhTnE19v_XKI?MAzV^;??*sU4 zZ$5t8n@>>zMFkWUB3d)C%v(sEY8o!3hECI40+}6Uq!cdC|KN=Jo4T!Ie%+VK|{ zdO6(}Dvz2<1i3Ctq%FpgQu5;*E&6eeL?1b&93|4eDv6^-yqt0*V&pMSk(zcy&dgUW zxuMcCl@pcWPUve^vRWNK_!06sJn0nJKnNq**COGGnCKB|k=*u;^Dc zkVW=r;0~ipR14|w_2&Ja*Km)%Aux>+h-Shf(B`!)v6)_l!T%P2${&IpSO2|H;--=(6fDiL3oVcDTpFNDSgQheMu)Z($3FqOeeFvZqqR9x%;hS z@+dtfXpU?WXfrt|Hq!|h#AA}i_A9B%`P*Cm$&v>C`qRgVinF1#(>N8}>~+JHK2rjh z#kt^%zIl=jxBF{I4is*0g-?53_|Oc0*xsTMN4Pl;j?0BL-gRUpguJ&?@4ZS04|I}3 zP!CoRs_+$H9nqu8pBd~!m47l=uMijTZ@4zn#6Q17LwtI-Zq{J(Zxmz{Ww?URi0FnP z^{_r94-H2x4+?U8gt^;j^0x+($DS0FlAq}e)`Vn78)AMzakwNraIQk~Rg5vkk7B2N zRg66&$i}eC6T;*`<4H`8(Q-IFB#&D21vQOm7^D5FBR{m`%(D~EPT8UbwNXc%pw&a+ zPCK0|<^iKEuu3pe9Loa~TNtA`AQP+<+ZdxG&>A$2uM_N&?^zXYoid$^`_<$LaXY8rw=mBKeG1$f~j}wzFy@p z6-zsV7h*enzV9sA%OBf+B|-hoNh|{2=xWT%)1$B8!>9Z+goOFRUlq#m1wR`r5>KW_ z2}R>3I62mU&;B*`98p}r*Ls`yorh#{gIMjPLPtwh0qj3qi-Ttl1D@ga<7MOm|I+x& zEV;>72Dj09g^p|_H^K1g4Kl~izj}!*nN2CY_1h*g2g{CrMZV79@$a8itBIHY z+Z!8L{B-?vJ+5_hI*pX^r>4_shj-pI%gL|#n{T}>TQZR%IDaXJEh7*awZd1I)?<&S z-abUgY5wjzi7Yt{?98X+4gS{5pIB_d@BNVw1q@y-t;YX2;y;{)euDRH@wUCNnxs*P zX5kSBMQAXHby}>_3y-mwvc$ZF@HhXs%96jst@nRV=J|;aa1q6DYxZ4I#J~RG6{Ys) SYGRpZs1;UQ_(LBLu>S#2s4bHK delta 3222 zcmdT`eN>a@6@Q;M2?>ydganch2p<7L_>j*+#KK^FS<#6B(#E9}`KaLdT7uTYRBX4k z9(UYwyyciG)==jb+uDXbht9e)t7qK~b)}-|oZY(C&YrVVUAvcQ58XMPXV3G#268rL zyFYez_Wp72@80M8zV~;Z=X}|4_rj>uut!b>QbhUeW-5%Fq z|B$P`*Yl0h;SrZ-bbC+#aDlsP=RiUK;K;Ch)D?ky-7b&2+l5*EnS5iJByWKsVk(ED zYPyV9r|XFmE;&=!Gf}`f&*PsqNF}Vx3R7*V52xDG%2>J&hABgW?n3r-yM)LLhtzJ) zPZGRT5??0qWkvez*~DXs^c1pnXhiK`?Ku$B?2%Z_j5QaYXucRDs~zg@8+P~hG~$-^ zK7^9+M;k`bxH2K;s$Au@a3X@P$>Y5ZoP;1Qicx5u-so=#l!vq>oRqp`j6&trcv~m* zT(n>(r}{cp~J`C`mDJ4LK3mHr=C+@(h%C~VJxqTO0B_Nrm*nR-b&iD?E*x9_$#x(mo}bFbnB z`9{-TH)M-)$#Jo@SmZ`nC`CtP2XN<>eL`Pe7(~M$NN&HcOw9A^o6W5bdn4*^=EsT zMaD?ek%*Iej;dEPd3COgIQew^kB({vbw@}3mpZPyfu6h(COWF4_#K(xosM_S|8klt zPDFepk7=7{20;wE&Idy`yhc|8w+J<#eK*bm(R( z8sVcyZBjbB=kEyK^Q?o+N1E`fTdyPQU#W8`qHAbGiJ0o+sG1(-)j2V7l873`@>_39 z3B^mHxgi>-ZJT4!7W~P;j}Ys!&}=fK*i9?2J=HFQ;82c!WqR8!@b1t8%7Kx=EVwmn z5?LKFux?iYTp!E?Jd`C14}*gvop%>XS$UycdmibW4|hhKr~n@N=AV=A^*>>=dowDA z8Mlqk+hSA>X`W${u3k@*l#h!+jNkJ((6}N;c~zzLZskN2rDpke@aUQ^XwhK#wr9sb(O|WH*I>Fn;Mbo}A#+d47!#K+t z$PjQ0BUkGuO#w;Bu!@USz-!x+kOJQSw$VvPsTg&VuZ&Yu9M7nYllA_D9~-6&fy&_a zPx> zQd~#MXt>}hs6)<#3m$jZu3C$P$|pFK5p_3?j^Hb>SUN)0 zc<5Wpv`Mz7tUw!+T@1lJc?o860cJ*Cfc7kVHn?`bfGlw7kQws!q=|AI3fRa)52OjSsgn8-l z+9*CU9Q?u4Ov?M;zKAl9h4dBw1Io~C{dXwC)S~rhwsn3}`<(NtDseukG}sul&Fk-9 zh~NL&{jNv46PdZh!DR1Stb4UBN>sh>dA zlS+*DofpRz(`&4j-e4s#exQcr+qnbB&=~Ig{zZhI!$saA5t(8O6i3ku&nkuDBU*S7 z){?eQVHb*eg-q58Jfh2JEk#h73K78a^7G1%spBR01=B z?bOOYX+cPW+nzs(i1W@1y+{H*FWy9I+;Xa(C2Hd38iY*vN-#DS_257LtcN8_H=S)F zXZ^w16jXsvoi))8Z@#9Jpndqm*MBXF*hmSSnYFML2<8tM;PcsT(xdZ@LkRitXBVPb zz#kQFAkt&3W->}pS+>?7t~_qlCK7lX8`{Q zSR6y7#PJ1=z~eVAv*>ql^X;?fI)45)7^U5Vuf`9(ox1@@}SfpQIsN{Nd*!RAN F{Rc;*&a40c diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index dd054c7..7509a40 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -397,7 +397,9 @@ class Converter: 'canCollide': bool(props.get('CanCollide', True)), 'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else True, 'opacity': max(0.0, 1.0 - (props.get('Transparency', 0) or 0)), - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], } @@ -428,7 +430,9 @@ class Converter: 'material': material_to_string(props.get('Material')), 'canCollide': bool(props.get('CanCollide', True)), 'visible': True, - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], }) @@ -457,7 +461,9 @@ class Converter: 'material': material_to_string(props.get('Material')), 'canCollide': bool(props.get('CanCollide', True)), 'visible': True, - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], }) @@ -529,7 +535,9 @@ class Converter: 'material': material_to_string(props.get('Material')), 'canCollide': bool(props.get('CanCollide', True)), 'visible': True, - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'note': f'MeshPart (no GLB) rbxid={rbx_id}', @@ -550,7 +558,9 @@ class Converter: 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'color': get_part_color(props), 'canCollide': bool(props.get('CanCollide', True)), - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'origin': 'roblox-meshpart', 'rbxAssetId': rbx_id, }) @@ -590,7 +600,9 @@ class Converter: 'material': material_to_string(props.get('Material')), 'canCollide': bool(props.get('CanCollide', True)), 'visible': True, - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'note': f'Union (no CSG GLB) rbxid={rbx_id}', @@ -609,7 +621,9 @@ class Converter: 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'color': get_part_color(props), 'canCollide': bool(props.get('CanCollide', True)), - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'origin': 'roblox-union', 'rbxAssetId': rbx_id, }) diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 8d7d899..e8b2a08 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -507,8 +507,11 @@ export class PrimitiveManager { const matName = `${mesh.name}_mat`; const mat = new StandardMaterial(matName, this.scene); mat.diffuseColor = Color3.FromHexString(color || '#888888'); - // ambient = default (0,0,0). Освещение настраивается через - // глобальную панель «Свет и атмосфера» (sun/hemi/saturation). + // ambient = (1,1,1) — пассивный, реагирует на scene.ambientColor. + // Юзер крутит «Заливку теней» (sceneAmbient) → тени светлеют. + // На прямом свете diffuse доминирует — пересвета нет если + // sceneAmbient в разумных пределах (0..0.5). + mat.ambientColor = new Color3(1, 1, 1); // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // используется для GD-скинов куба (например /gd/skins/cube_smile.png). -- 2.47.2 From e45a9968c4d0790259b072b912b491537d3895a0 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 19:03:09 +0300 Subject: [PATCH 048/214] =?UTF-8?q?feat(lighting):=20persistence=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Слайдеры sun/hemi/ambient/exposure/contrast/saturation теперь сохраняются в projectData.scene.lighting при save и применяются обратно при load. Раньше параметры жили только в текущей сессии — после refresh страницы возвращались к дефолтам. Импортированные .rbxl карты также сохраняют выставленные пользователем параметры света. --- src/editor/engine/BabylonScene.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index dc89f9a..3a7eb98 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -7776,6 +7776,15 @@ export class BabylonScene { crosshair: this._crosshair || 'dot', shadowQuality: this._shadowQuality || 'soft', environment: this.environment ? this.environment.serialize() : null, + // Кастомные настройки света — слайдеры из «Свет и атмосфера» + lighting: { + sunIntensity: this._sunIntensity ?? this._sunLight?.intensity ?? 0.8, + hemiIntensity: this._hemiIntensity ?? this._hemiLight?.intensity ?? 0.65, + sceneAmbient: this._sceneAmbient ?? 0.3, + exposure: this._exposure ?? 1.0, + contrast: this._contrast ?? 1.0, + saturation: this._saturation ?? 1.0, + }, skybox: this.skybox ? this.skybox.serialize() : null, leaderstats: this.leaderstats ? this.leaderstats.serialize() : null, achievements: this.achievements ? this.achievements.serialize() : null, @@ -8260,6 +8269,12 @@ export class BabylonScene { if (state.scene.environment && this.environment) { this.environment.load(state.scene.environment); } + // Кастомные настройки света/цветокоррекции — применяем через + // setLightingProps (он сам подхватит default-ы если значения нет). + if (state.scene.lighting) { + try { this.setLightingProps(state.scene.lighting); } + catch (e) { console.warn('[BabylonScene] lighting load failed:', e); } + } // Кастомное небо (задача 16) if (state.scene.skybox && this.skybox) { this.skybox.load(state.scene.skybox); -- 2.47.2 From 71d6396d8b1df0f1fb9ec4c30d16ad061c2c2ed6 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 19:09:25 +0300 Subject: [PATCH 049/214] =?UTF-8?q?docs(lua):=20=D0=B8=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=202=20Crossroads=20=D0=B2=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зафиксированы все правки сделанные при работе с Crossroads: - XML-парсер для старых .rbxl (~330 строк) - BrickColor таблица расширена с 50 до 120 цветов - Force-anchored для всех импортированных Part - 4 новых слайдера в Свет и атмосфера (заливка теней, экспозиция, контраст, насыщенность) через imageProcessingConfiguration - Persistence настроек света в projectData.scene.lighting - mat.ambientColor=(1,1,1) обязательно для scene.ambientColor работы - Деплой rbxl-importer на VM 130 через прямой SSH (CI не настроен) Известные баги Crossroads: - 2 скрипта Regenerate* падают на model:clone() и Instance.new('Message') — не критично, Anchored держит постройки. --- RUBLOX_LUA_API_CHANGELOG.md | 133 ++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/RUBLOX_LUA_API_CHANGELOG.md b/RUBLOX_LUA_API_CHANGELOG.md index 16f8e36..a46a3f0 100644 --- a/RUBLOX_LUA_API_CHANGELOG.md +++ b/RUBLOX_LUA_API_CHANGELOG.md @@ -8,6 +8,139 @@ Roblox-играми. Цель — потом продублировать тот --- +## 2026-06-08 — Итерация 2: Crossroads (arch1_Original_Crossroads.rbxl, проект 2827) + +**Контекст:** Классическая Roblox-карта 2009 года для PvP, **XML-формат** .rbxl +(старее бинарного). 877 instances, 777 Part, 83 Model. Состоит из 4 зон: +крепость (Castle), дом (House Platform), деревья, дорожки крест-накрест. +2 скрипта: «Regenerate Playground» и «Regenerate Castle» — периодически +удаляют и восстанавливают постройки (для PvP). + +### Главное: XML-парсер для .rbxl + +`rbxl-importer/src/rbxl_xml_parser.py` (новый файл, ~330 строк): + +- `is_xml_rbxl(blob)` — детект по `N` — особый случай: в старом XML цвет + лежит как int с именем `BrickColor`, заворачиваем в `BrickColor(code=N)`. + +В `app.py` добавлен автодетект формата: +```python +is_binary = blob.lstrip().startswith(b' Date: Mon, 8 Jun 2026 19:16:39 +0300 Subject: [PATCH 050/214] =?UTF-8?q?fix(import):=20YXZ=20Euler=20+=20watchd?= =?UTF-8?q?og=20=D0=B4=D0=BB=D1=8F=20tight-loop=20=D0=B7=D0=B0=D1=89=D0=B8?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CFrame.to_euler_xyz переписан под Babylon YXZ convention: rx = asin(-r12), ry = atan2(r02, r22), rz = atan2(r10, r11). Раньше извлекал XYZ-Euler → Babylon применял как YXZ → клины, мостики, наклонные постройки рендерились повёрнутые (примеры из ROBLOX Battle: мостик торчал в стену). Учтён gimbal-lock на X=±90°. 2. Lua watchdog в _startSingleScript и __rbxl_drain_handler: debug.sethook(yield_50ms, '', 50000) — каждые 50k Lua-инструкций принудительно yield 1 кадр. Защищает от: while not workspace:FindFirstChild('X') do workspace.ChildAdded:wait() end где наш stub :wait() возвращает -1 мгновенно — раньше скрипт подвешивал вкладку (50k+ итераций в секунду). Сейчас yield'ит, tickScheduler возобновляет. 3. Signal.Wait возвращает -1 как 'no-arg yield marker'. Сейчас не используется в Lua, но если позже сделаем wrapper — будет. ROBLOX Battle карта (arch1_ROBLOX_Battle_v2.rbxl, 1677 примитивов, 66 скриптов) — теперь не должна подвешивать. Деплой rbxl_types.py на VM 130. --- .../__pycache__/rbxl_types.cpython-314.pyc | Bin 29959 -> 30332 bytes rbxl-importer/src/rbxl_types.py | 26 ++++++++++++------ src/editor/engine/lua/LuaSharedSandbox.js | 9 ++++++ src/editor/engine/lua/RobloxShim.js | 15 +++++++++- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc index 44b6b88d8b7fb8df532f61748dacd956a8795f33..f6db7945a4fb43e679a7f13d04a8804356afa046 100644 GIT binary patch delta 3248 zcmZ`*3s98T72dncLw03hfdyAV{sn>k0eLKHt;P~yAtA~mt2~P(8(bB6?B-{QKp!BM zNU9FDCr&VC0&ynkOq!{hl}UUcw$oHwb>{zz>EO0?Y-^JV?M!IY=3yQ^=Pt_zr`=({ z|K4-H@7#0Gf6rYG%(5^3#q?SFNUaJ#le6i2uGXH`muW){T~bS~cSP%05}6R1_-_@$ zF(*FY%a|l}JcGp8JZn=ZiRfbwZ3_2~aICHk7gcN2{(0c(*447=2rz7k&DE`8exG}3 zlP;jDU9j3U>t9f*b|k1&i7M1YlBR!&sH#;j#EP_sxb+n&~E-V6fDJ#r+eF7okEMN z^I2!7r`6Rd=4iD6YlpLIcMb{2A#4(Aoh=@hRA7;MvIVIZzkQ-0PbmZPJMsm2LY`Dc zaG^nEpE4rIZ=pxNh|7-1XXJD8gn;X5Ej*Z0Xb^Vxw@O`}Y{9eJ*(ub|KO4myLH@Zi zh)JJT4hk6dZOr{V20fr05=y(f!Wk2P%+S%ig94)ld4O#&|_8#1^qpP*kvRcZwihf%! z**uO;t3}GQVgalHM_9j}~@ z$r=uyi8PEfJl}9+?{sAHu=>wB{qWv{kBnyA(3x)=j4v+#>GIL7qqgzXX+!#O@r)tv z`Qm`NWZIBMCO^qPgZGUck(IclsFq?W&vJ0&3osC0$C}}KdMZ4KUm1oyK8GEioUnl% zhVi@<_(8%693?NanVh5{I81}p1S#AEW^*P3xNS1Sm1T|0iDAuPGsU8<-efbi5s3h3 z5Y)AC-7(btiXDR!%S%lzA`l!~%ye<)7|*9-#V8wt_gC0V-9#c7Tg>i8wh5XN4eT}O zOnh7|ZGq%yllQ$u4MWS#q(0*hN$?|reu4pleN+N%9}Nj!gQF|gu|9BQXR?#_U%PwyFMS>%Jkl^ z3VImR!``)(^Y2li7zBE_vsPdVgsz*6{xuml5%3eIz~s(kmf-z(-R_l^Eok0W=D)RX zyB_5)vr-I^`02Yrn)e9)2!l06Y!H4^v!&mO2A{UtURB*(V=J$)@*B#xgd6Nd4ZiT5 z?JkF>!0HQgxZTdq9sFM6_vXB+tf-`PD?e?%2(Bus$2lDsUlgAg+sjKfmsD-3vhy3l zr{h*`D3D;Jc8r~a+`24w2)3Y4r}@!N4u!`@N4`?H0B^`{Nu+SHk3bX;1Z$*B-}tWF+y69 z2O~dhi&+RDQOAo0DGmO!CwYAnWxk!@djxiZ8iIO)tpv3M4FuZ=>IfR4qAgkb2@MHy zz0bB)Ggj}t+-_AzXHfDM0+ApSB3y;W&xlJ2<-*7nYYQ>+J zQ1`dkJ=o+fTXmZhef>0PP>zIAJgzI!KZN3o!?JwD1{J&5rh*g zA&4M|C1NhY8iFzc>I~iu{v+Q_(>{VB0{#=Wa9D!}lOIFg4!j5W?S|J?3QG75#LpYQ z9C&xr$>7zJK7&`ENzyjq9RE~Z>%CW=F*9Q_!~a7N?rKzO-JF`HHs<|O(yIRp7~8tV delta 2815 zcmZ{m4@}e77RP%_K`j;#6(~~pLC2pT|5ghA8K^wukCI`jMJQl5UxgYvOTpU`i*Cco z-llHB%@ZANOJ+8gnI%TFn{$8IoMtn|MA|1W+xxwj1^36Nb9rxWZ+}ddZ1#ORV@rotzI->KT~uT5KDk({4Go7M3w2g%UTLWv#7$%4jd*W>cZ$+s^$c zIDc9yK+XBnvXJeZlJOED;wF=~;pfNO?z^Zp}-=>t`%Nx_=*C?*EhW$awMOIZ2Ch({2QA_g!U+8x+LyZ{<$8QTG& zqI{Sz#KOC#CBdhpU5vd7bFwPfUr9|lLA(mRSs5Z@n3I$AZ2!*M#-0#4$pp7CRoEUk zTJ`%eIN~HWrgO5N{LYZyefNyBGZ2?;)eoTvab~u9h}5H`zL4F|hG>=tbJEx-?8>Rs zA4WN1bhh7Nd=_>dPMNa7n47E#h+#|&{F+q&1Gy>e85qvB3c<}>8v}a27cm0jJh<~R z*^4yuC7St@yv@2{F(RwEhE%kQPI(Et1iR%*{ZTX`E@5N(S&veTD-`3N{5Ix?NAoM$ z6?i|tMt=;Yh%2*wj?v1;Y317r4pCmq*f=aJ%w~JxoN>2i`Ln4k%y z;@2j3`ev~qX+pUCacpf6hY%A`ey@QADHGQz6UXkg#T>-p$RZ7dt$&o}zAfhdKf4W@(g*YxFh1foHl`=K zKoi};CykG5?8QUU>8Y;c&TbccR{A&HJ3=mM0IvmJM>X(lS(^He3Qm^gM>Z$+GL9jH z^-ftDyDUP=P`T91G(o;Jz?cSZEwfDS_;Y(I>(>SO3-Jq-VC zO{t@o<|qbEAU{j<`ZcvSTa{&PEfx1JbF27G<;tR#?Z?hwCqrE8)mL1&CwKr;@PT1wBo4<+=$2LKZinMF*HmtqdKrSfz-GvM& zljrd%_vy%Y+Y+Y&aD2<<1>6V^c`{bC;mX?)n-Eq+6QTvN0kIKbN7xX}h*tQ}lcD_q zn~3b-*B&clmSBa~tWLTu&xkuv%}WwAI7c)h1~CVQc*+2lI$gSSmr`}n zWyS|_fUciNp<;KcPN^!X@~PbEh@vxxYAsS&^bXUzi91bC&ws)wnAXTKnyyL~g*10% scTZ=Z$F-Wb3!(U`e9MC?eVJ_0VkZ8{5cobyrH=hpja^fa`?c!d0HE*!`Tzg` diff --git a/rbxl-importer/src/rbxl_types.py b/rbxl-importer/src/rbxl_types.py index 4113753..a689b12 100644 --- a/rbxl-importer/src/rbxl_types.py +++ b/rbxl-importer/src/rbxl_types.py @@ -113,18 +113,28 @@ class CFrame: matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22) def to_euler_xyz(self) -> tuple: - """Конверт 3x3 rotation matrix в Euler XYZ (radians). + """Конверт 3x3 rotation matrix в Euler YXZ (Babylon convention). - Использует стандартную intrinsic XYZ rotation extraction: - Rx = atan2(r21, r22) - Ry = atan2(-r20, sqrt(r21² + r22²)) - Rz = atan2(r10, r00) + Babylon mesh.rotation = Vector3(rx, ry, rz) применяется в порядке YXZ + (rotate Y first, then X, then Z). Чтобы извлечь Euler из матрицы под + этот convention, используем формулу YXZ-extraction: + Rx = asin(-r12) + Ry = atan2(r02, r22) + Rz = atan2(r10, r11) + (имя метода to_euler_xyz сохраняем для совместимости вызовов.) """ import math r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix - rx = math.atan2(r21, r22) - ry = math.atan2(-r20, math.sqrt(r21*r21 + r22*r22)) - rz = math.atan2(r10, r00) + # Edge case: r12 близко к ±1 (gimbal lock на X = ±90°) + clamped = max(-1.0, min(1.0, -r12)) + rx = math.asin(clamped) + if abs(clamped) > 0.99999: + # Gimbal lock — z = 0, y = atan2(-r20, r00) + ry = math.atan2(-r20, r00) + rz = 0.0 + else: + ry = math.atan2(r02, r22) + rz = math.atan2(r10, r11) return (rx, ry, rz) diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index 6446408..4906272 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -183,6 +183,15 @@ export class LuaSharedSandbox { Source = nil, } local co = coroutine.create(function() + -- WATCHDOG: каждые 50000 инструкций — yield 1 кадр. + -- Защищает от tight-loop типа: + -- while not parent:FindFirstChild(name) do + -- parent.ChildAdded:wait() + -- end + -- где наш stub :wait() возвращает сразу. + debug.sethook(function() + coroutine.yield(0.016) + end, "", 50000) -- pcall защищает от runtime-ошибок которые иначе крашат -- coroutine и могут повредить WASM-стейт. Возвраты -- handler'а намеренно поглощаются. diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 5bfd169..59db590 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -58,7 +58,10 @@ function makeSignal() { } }; sig.fire = sig.Fire; - sig.Wait = () => undefined; + // Wait() возвращает -1 как маркер "yield 1 кадр" — наш Lua-prelude + // оборачивает все Signal:Wait через __rbxl_signal_wait который при + // получении -1 делает rbx_wait(0.05) (yield в coroutine). + sig.Wait = () => -1; sig.wait = sig.Wait; return sig; } @@ -1385,6 +1388,12 @@ export function registerRobloxShim(lua, opts) { local ret = coroutine.yield(sec) return ret or sec end + + -- Глобальный безопасный yield для любых stub-сигналов / любых + -- "ждунов". Используется в Lua-обёртках вокруг WaitForChild и т.п. + function __rbxl_yield_frame() + coroutine.yield(0.05) + end if type(task) == 'table' then task.wait = rbx_wait else @@ -1413,6 +1422,10 @@ export function registerRobloxShim(lua, opts) { -- что приводит к wasmoon promise-detection crash). pcall возвращает -- (ok, ret1, ret2, ...) — мы их не используем. local co = coroutine.create(function() + -- Тот же watchdog что и в _startSingleScript + debug.sethook(function() + coroutine.yield(0.016) + end, "", 50000) pcall(fn, a1, a2, a3, a4) end) __rbxl_register_coroutine(handlerId, co) -- 2.47.2 From 913283ffa61cd1ed040805041c40f73413900496 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 19:43:36 +0300 Subject: [PATCH 051/214] =?UTF-8?q?feat(rbxl):=209=20=D0=BC=D0=B5=D1=85?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BA=20ROBLOX=20Battle=20(Teams/Leaderstats?= =?UTF-8?q?/HUD/Tools/etc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализовано из 14 механик: 1. Teams (game.Teams, Player.Team, TeamColor): scene.teams[] из конвертера, эвристика TeamBeacon-Model → автоматически создаются 4 команды. В shim создаются Team-инстансы при snapshot, авто-эквип игрока в первую. 2. Leaderstats UI: IntValue.Value реактивно шлёт leaderstatSet → существующий LeaderstatsManager (define + set). HUD автоматически рисуется в правом верхнем по родительскому Name='leaderstats'. 3. BindableFunction + RemoteFunction + Message/Hint класс. Message с реактивным .Text и .Parent шлёт hudMessage в наш RbxlHudOverlay. 4. KillFeed UI + creator-tag tracking. RbxlHudOverlay.addKillFeed() рисует А → [weapon] → Б в правом верхнем. Humanoid.TakeDamage при Health=0 ищет creator-ObjectValue и шлёт killFeed. Авто-respawn через 2с. 5. SpawnLocation.TeamColor → scene.team_spawns[] для будущей логики команд-спавна. 6. Tool:Clone() / Model:Clone() / :clone(): поверхностный клон + lowercase alias. Также :MakeJoints/:BreakJoints/:Remove/:remove no-op методы. 7. Creator-tag handling в TakeDamage (см. пункт 4). 12. Bouncer/батут: BodyVelocity с +Y и Parent=Torso/HumanoidRootPart → эвристика "толкаем вверх" → playerSet jumpVelocity → реальный jump через player._vy. 14. Mouse.Icon → CSS cursor на canvas (crosshair для не-пустых). Также: - RbxlHudOverlay.js — новый модуль DOM-оверлей для HUD-элементов (KillFeed/Message/WinGui). Lazy-создаётся при первом hudMessage/killFeed. - BabylonScene.serialize включает scene.teams и scene.team_spawns. - Converter: scene = teams[] + team_spawns[]. TeamBeacon Model'и → команды. - Deploy converter.py на VM 130. Остались: 8 Regeneration, 9 BattleArmor, 10 WinGui/FireButton кастомное позиционирование, 11 AdminConsole (no-op уже ok), 13 NotLinkedBlocker. --- .../src/__pycache__/converter.cpython-314.pyc | Bin 57867 -> 60712 bytes rbxl-importer/src/converter.py | 55 +++++ src/editor/engine/GameRuntime.js | 67 +++++- src/editor/engine/RbxlHudOverlay.js | 177 +++++++++++++++ src/editor/engine/lua/RobloxShim.js | 202 +++++++++++++++++- 5 files changed, 494 insertions(+), 7 deletions(-) create mode 100644 src/editor/engine/RbxlHudOverlay.js diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc index 6a970143b7f310de1a057acf50dc9337cf620ebd..4e9d3dba9e16132df1d1cca3525f4172b04ec3d4 100644 GIT binary patch delta 11715 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 delta 9406 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 jdiZMs{7G diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index 7509a40..c86bbf8 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -264,8 +264,32 @@ class Converter: 'sounds': [], 'glbModels': [], 'scripts': [], + # Команды PvP (Roblox Battle): {id, name, color_hex, auto_assignable} + 'teams': [], + # Spawn-точки команд (для SpawnLocation.TeamColor) + 'team_spawns': [], # {team_color_hex, x, y, z} } + # Эвристика для Roblox Battle: Model с именем "TeamBeacon X" → + # команда X. PvP-карты часто используют этот паттерн вместо Team-инстансов. + TEAM_BEACON_COLORS = { + 'Black': '#1f1f1f', 'Blue': '#0d69ac', 'Red': '#c4281c', + 'Green': '#4b9740', 'White': '#f2f3f3', 'Yellow': '#f5cd30', + 'Orange': '#d97e29', 'Purple': '#6b327a', + } + for inst in self.model.instances: + name = inst.properties.get('Name', '') + if (inst.class_name == 'Model' and isinstance(name, str) + and name.startswith('TeamBeacon ')): + team_name = name.replace('TeamBeacon ', '').strip() + color = TEAM_BEACON_COLORS.get(team_name, '#cccccc') + scene['teams'].append({ + 'id': f'team_{len(scene["teams"]) + 1}', + 'name': team_name, + 'color_hex': color, + 'auto_assignable': True, + }) + # Обходим все instances и конвертим for inst in self.model.instances: self._convert_one(inst, scene) @@ -331,8 +355,12 @@ class Converter: elif cls == 'Workspace': # Workspace = root, его свойства мапим на scene.worldSize и т.п. pass + elif cls == 'Team': + # PvP-команда: имя + цвет в scene.teams[]. + self._convert_team(inst, scene) elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui', 'StarterPack', 'StarterCharacterScripts', 'Players', + 'Teams', 'ReplicatedStorage', 'ServerScriptService', 'ServerStorage', 'SoundService', 'TweenService', 'RunService', 'UserInputService', 'HttpService', 'DataStoreService', @@ -631,10 +659,37 @@ class Converter: # ─── Spawn ─── + def _convert_team(self, inst: Instance, scene: Dict) -> None: + """Roblox Team → scene.teams[].""" + props = inst.properties + name = str(props.get('Name', 'Team')) + # TeamColor — BrickColor код, мапим в hex через существующую таблицу + team_color = props.get('TeamColor') + color_hex = '#ffffff' + if isinstance(team_color, BrickColor): + color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc') + scene['teams'].append({ + 'id': f'team_{len(scene["teams"]) + 1}', + 'name': name, + 'color_hex': color_hex, + 'auto_assignable': bool(props.get('AutoAssignable', True)), + }) + def _convert_spawn(self, inst: Instance, scene: Dict) -> None: props = inst.properties cf = props.get('CFrame') pos, _ = cframe_to_pos_rot(cf, self.scale) + + # TeamColor (если есть) → spawn для команды. + team_color = props.get('TeamColor') + if isinstance(team_color, BrickColor): + color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc') + scene['team_spawns'].append({ + 'team_color_hex': color_hex, + 'x': pos['x'], 'y': pos['y'] + 1.5, 'z': pos['z'], + 'neutral': not bool(props.get('Neutral', True)) and team_color.code != 0, + }) + # Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита, # юзер появляется на её верхней грани. scene['spawnPoint'] = { diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 6a0ae5a..47acba5 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -21,6 +21,7 @@ import { PhysicsWorld } from './PhysicsWorld'; import { LabelManager } from './LabelManager'; import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js'; import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js'; +import { RbxlHudOverlay } from './RbxlHudOverlay.js'; export class GameRuntime { constructor(scene3d) { @@ -247,6 +248,45 @@ export class GameRuntime { // partlcle-эффекты есть у Tool. При equip покажем у руки. this._rbxlPendingParticles = this._rbxlPendingParticles || []; this._rbxlPendingParticles.push(payload); + } else if (cmd === 'mouseIconChanged') { + // Roblox Mouse.Icon → CSS cursor на canvas + try { + const canvas = this.scene3d?.engine?.getRenderingCanvas?.(); + if (canvas) canvas.style.cursor = payload.cssCursor || 'default'; + } catch (_) {} + } else if (cmd === 'hudMessage') { + // Roblox Message/Hint в верхней трети экрана + try { + this._ensureRbxlHud(); + if (payload.visible && payload.text) { + this._rbxlHud.showMessage(payload.text); + } else { + this._rbxlHud.hideMessage(); + } + } catch (_) {} + } else if (cmd === 'killFeed') { + // Кастомное событие от нашего creator-tag tracker'а + try { + this._ensureRbxlHud(); + this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon); + } catch (_) {} + } else if (cmd === 'winShow') { + try { + this._ensureRbxlHud(); + this._rbxlHud.showWin(payload.text || 'WIN!'); + } catch (_) {} + } else if (cmd === 'leaderstatSet') { + // Roblox leaderstats: IntValue.Value меняется → HUD. + try { + const lm = this.scene3d?.leaderstats; + if (lm) { + const statName = String(payload.statName || 'Stat'); + if (!lm._defs.some(d => d.name === statName)) { + lm.define(statName, { initial: 0 }); + } + lm.set(lm._meId || 'me', statName, Number(payload.value) || 0); + } + } catch (_) {} } else { this._handleCommand(null, cmd, payload); } @@ -576,6 +616,15 @@ export class GameRuntime { return null; } + /** Создаёт DOM-overlay для импортированных Roblox-карт (KillFeed, + * Message, WinGui). Лениво — только при первом hudMessage/killFeed. */ + _ensureRbxlHud() { + if (this._rbxlHud) return; + const canvas = this.scene3d?.engine?.getRenderingCanvas?.(); + const parent = canvas?.parentElement || document.body; + this._rbxlHud = new RbxlHudOverlay(parent); + } + /** Регистрирует Roblox-Tool в InventoryUI как item в hotbar. * Слушает смену активного слота → шлёт equipTool/unequipTool в Lua-shim. * Слушает клики ЛКМ → шлёт mouseButton1Down (Tool.Activated fires там). */ @@ -719,6 +768,9 @@ export class GameRuntime { this._rbxlToolHooks = false; this._rbxlActiveSlot = -1; this._rbxlPendingParticles = null; + // Очищаем Roblox HUD overlay (KillFeed/Message/WinGui) + try { this._rbxlHud?.dispose(); } catch (_) {} + this._rbxlHud = null; // Удаляем все объекты, которые скрипты наспавнили через // game.scene.spawn/clone — иначе после Stop они остаются на сцене // и накапливаются при повторных запусках. @@ -4196,6 +4248,16 @@ export class GameRuntime { } else { player.hp = target; } + } else if (payload.prop === 'jumpVelocity') { + // Bouncer (батут): Lua-скрипт даёт игроку Y-velocity = N + try { + if (player._vy !== undefined) player._vy = Number(payload.value) || 0; + else if (player.velocity) player.velocity.y = Number(payload.value) || 0; + } catch (_) {} + } else if (payload.prop === 'walkSpeed') { + try { player.walkSpeed = Number(payload.value) || player.walkSpeed; } catch (_) {} + } else if (payload.prop === 'jumpPower') { + try { player.jumpPower = Number(payload.value) || player.jumpPower; } catch (_) {} } return; } @@ -4495,7 +4557,10 @@ export class GameRuntime { }); } } - return { blocks, models, primitives }; + // Teams и team_spawns из projectData (импортированные из .rbxl) + const teams = this.projectData?.scene?.teams || []; + const teamSpawns = this.projectData?.scene?.team_spawns || []; + return { blocks, models, primitives, teams, teamSpawns }; } // Разобрать ref-строку ('primitive:N' / 'model:N' / 'block:x,y,z') в target diff --git a/src/editor/engine/RbxlHudOverlay.js b/src/editor/engine/RbxlHudOverlay.js new file mode 100644 index 0000000..ae17084 --- /dev/null +++ b/src/editor/engine/RbxlHudOverlay.js @@ -0,0 +1,177 @@ +/** + * RbxlHudOverlay — DOM-оверлей с HUD-элементами для импортированных + * Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui. + * + * Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние + * блоки по типу. Стили inline, ничего не зависит от CSS приложения. + * + * API: + * const hud = new RbxlHudOverlay(canvasParent); + * hud.addKillFeed(killer, victim, weapon) + * hud.showMessage(text, opts) + * hud.hideMessage() + * hud.showWin(text) + * hud.dispose() + */ + +export class RbxlHudOverlay { + constructor(parent) { + this._parent = parent || document.body; + this._root = null; + this._killFeed = null; + this._message = null; + this._winBox = null; + this._killEntries = []; // [{el, expireAt}] + this._mount(); + } + + _mount() { + if (this._root) return; + const root = document.createElement('div'); + root.className = 'rbxl-hud-overlay'; + Object.assign(root.style, { + position: 'absolute', + inset: '0', + pointerEvents: 'none', + zIndex: '999', + fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif', + }); + this._parent.appendChild(root); + this._root = root; + + // KillFeed — правый верхний угол + const kf = document.createElement('div'); + Object.assign(kf.style, { + position: 'absolute', + top: '60px', + right: '12px', + display: 'flex', + flexDirection: 'column', + gap: '6px', + maxWidth: '320px', + pointerEvents: 'none', + }); + root.appendChild(kf); + this._killFeed = kf; + + // Message — центр сверху (Roblox Message по центру экрана, + // но в верхней трети чтобы не мешать игре) + const msg = document.createElement('div'); + Object.assign(msg.style, { + position: 'absolute', + top: '15%', + left: '50%', + transform: 'translateX(-50%)', + padding: '10px 24px', + background: 'rgba(0,0,0,0.6)', + color: '#fff', + fontSize: '22px', + fontWeight: '600', + borderRadius: '6px', + textShadow: '0 2px 4px rgba(0,0,0,0.8)', + display: 'none', + pointerEvents: 'none', + }); + root.appendChild(msg); + this._message = msg; + + // WinGui — большая надпись по центру + const win = document.createElement('div'); + Object.assign(win.style, { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + padding: '24px 48px', + background: 'rgba(0,0,0,0.75)', + color: '#ffd86b', + fontSize: '48px', + fontWeight: '800', + borderRadius: '12px', + textShadow: '0 4px 8px rgba(0,0,0,0.8)', + display: 'none', + pointerEvents: 'none', + }); + root.appendChild(win); + this._winBox = win; + + // Тик для авто-исчезновения KillFeed entries (через 5с) + this._tickInterval = setInterval(() => this._cleanupKills(), 500); + } + + addKillFeed(killer, victim, weapon) { + if (!this._killFeed) return; + const entry = document.createElement('div'); + Object.assign(entry.style, { + background: 'rgba(0,0,0,0.55)', + color: '#fff', + padding: '6px 10px', + borderRadius: '4px', + fontSize: '13px', + display: 'flex', + gap: '6px', + alignItems: 'center', + animation: 'rbxlHudFadeIn 0.3s', + }); + const killerEl = document.createElement('span'); + killerEl.textContent = String(killer || '?'); + killerEl.style.color = '#5bd1e8'; + const arrow = document.createElement('span'); + arrow.textContent = weapon ? `→ [${weapon}] →` : '→'; + arrow.style.color = '#ff9a52'; + const victimEl = document.createElement('span'); + victimEl.textContent = String(victim || '?'); + victimEl.style.color = '#f87a7a'; + entry.appendChild(killerEl); + entry.appendChild(arrow); + entry.appendChild(victimEl); + this._killFeed.appendChild(entry); + this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 }); + // Keep only last 8 + while (this._killEntries.length > 8) { + const old = this._killEntries.shift(); + try { old.el.remove(); } catch (_) {} + } + } + + _cleanupKills() { + const now = performance.now(); + const keep = []; + for (const e of this._killEntries) { + if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} } + else keep.push(e); + } + this._killEntries = keep; + } + + showMessage(text, opts = {}) { + if (!this._message) return; + this._message.textContent = String(text || ''); + this._message.style.display = text ? 'block' : 'none'; + if (opts.duration) { + clearTimeout(this._msgTimer); + this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration); + } + } + + hideMessage() { + if (this._message) this._message.style.display = 'none'; + } + + showWin(text) { + if (!this._winBox) return; + this._winBox.textContent = String(text || ''); + this._winBox.style.display = 'block'; + // Auto-hide через 6с + clearTimeout(this._winTimer); + this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000); + } + + dispose() { + try { this._root?.remove(); } catch (_) {} + clearInterval(this._tickInterval); + clearTimeout(this._msgTimer); + clearTimeout(this._winTimer); + this._root = null; + } +} diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 59db590..d4c96aa 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -239,7 +239,29 @@ function makeInstanceMethods() { this.Parent = undefined; } }, - Clone: function () { return undefined; }, + Clone: function () { + // Поверхностный клон — достаточно для большинства Roblox-паттернов + // (Tool/Pellet/Bomb клонируются и parent'ятся в Workspace). + // Глубокий клон не делаем — Children копируются по ссылке (как в Roblox + // Clone() это deep copy, но у нас нет полной physical model). + try { + const copy = Object.assign({}, this); + copy.Children = (this.Children || []).slice(); + copy.Parent = undefined; + return copy; + } catch (_) { + return undefined; + } + }, + // Старый Roblox API: lowercase :clone() + clone: function () { return this.Clone && this.Clone(); }, + // model:makeJoints() — заглушка (Welds мы не делаем) + MakeJoints: function () {}, + makeJoints: function () {}, + BreakJoints: function () {}, + breakJoints: function () {}, + Remove: function () { this.Parent = undefined; }, + remove: function () { this.Parent = undefined; }, GetAttribute: function (n) { return (this.Attributes || {})[n]; }, SetAttribute: function (n, v) { if (!this.Attributes) this.Attributes = {}; @@ -742,6 +764,13 @@ export function registerRobloxShim(lua, opts) { localPlayer.Children.push(playerGui); localPlayer.PlayerGui = playerGui; localPlayer.DisplayName = 'Player'; + localPlayer.Name = 'Player'; + localPlayer.Neutral = true; // не в команде по умолчанию + localPlayer.Team = undefined; + localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) }; + localPlayer.Kick = function () {}; + localPlayer.LoadCharacter = function () {}; + localPlayer.HasAppearanceLoaded = function () { return true; }; // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически // клонируется в Backpack каждого спавнящегося игрока. const backpack = newInstance('Backpack', 'Backpack'); @@ -762,7 +791,18 @@ export function registerRobloxShim(lua, opts) { m.WheelForward = makeSignal(); m.WheelBackward = makeSignal(); m.Idle = makeSignal(); - m.Icon = ''; + // m.Icon reactive — меняет CSS cursor на canvas + let _icon = ''; + Object.defineProperty(m, 'Icon', { + get() { return _icon; }, + set(v) { + _icon = String(v || ''); + // rbxassetid → стрелочный курсор-прицел (наш дефолт) + const cssCursor = _icon && _icon.includes('rbxasset') + ? 'crosshair' : (_icon ? 'crosshair' : 'default'); + send('mouseIconChanged', { icon: _icon, cssCursor }); + }, + }); 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), @@ -803,7 +843,26 @@ export function registerRobloxShim(lua, opts) { const v = Math.max(0, (this.Health || 100) - (Number(n) || 0)); this.Health = v; this.HealthChanged.Fire(v); - if (v === 0) this.Died.Fire(); + if (v === 0) { + // Creator-tag: ищем creator-ObjectValue в Humanoid.Children для kill feed + let killerName = null; + for (const c of (this.Children || [])) { + if (c && c.Name === 'creator' && c.Value) { + killerName = String(c.Value.Name || c.Value.DisplayName || '?'); + break; + } + } + if (killerName) { + send('killFeed', { killer: killerName, victim: localPlayer.Name || 'Player', weapon: '' }); + } + this.Died.Fire(); + // В Roblox после Died игрок респавнится — у нас через playerSet=respawn + setTimeout(() => { + this.Health = this.MaxHealth || 100; + this.HealthChanged.Fire(this.Health); + send('playerSet', { prop: 'health', value: this.Health }); + }, 2000); + } send('playerSet', { prop: 'health', value: v }); }; humanoid.MoveTo = function () {}; @@ -836,6 +895,12 @@ export function registerRobloxShim(lua, opts) { makeService('StarterPack'); makeService('StarterPlayer'); + // Teams сервис — PvP-команды (TeamBeacon Black/Blue/Red/Green в Roblox Battle) + const teams = makeService('Teams'); + teams.Children = []; + teams.GetTeams = function () { return teams.Children.slice(); }; + teams.GetChildren = function () { return teams.Children.slice(); }; + const uis = makeService('UserInputService'); uis.InputBegan = makeSignal(); uis.InputChanged = makeSignal(); @@ -1165,6 +1230,51 @@ export function registerRobloxShim(lua, opts) { inst = newInstance('BindableEvent', 'BindableEvent'); inst.Event = makeSignal(); inst.Fire = function (...a) { this.Event.Fire(...a); }; + } else if (className === 'BindableFunction') { + // BindableFunction — синхронный RPC внутри клиента. + // OnInvoke = single-callback; Invoke вызывает его и возвращает значение. + inst = newInstance('BindableFunction', 'BindableFunction'); + inst.OnInvoke = undefined; // юзер ставит function + inst.Invoke = function (...args) { + if (typeof this.OnInvoke === 'function') { + try { return this.OnInvoke(...args); } catch (_) { return undefined; } + } + return undefined; + }; + } else if (className === 'RemoteFunction') { + inst = newInstance('RemoteFunction', 'RemoteFunction'); + inst.OnServerInvoke = undefined; + inst.OnClientInvoke = undefined; + inst.InvokeServer = function (...args) { + if (typeof this.OnServerInvoke === 'function') { + try { return this.OnServerInvoke(localPlayer, ...args); } catch (_) {} + } + return undefined; + }; + inst.InvokeClient = function (_p, ...args) { + if (typeof this.OnClientInvoke === 'function') { + try { return this.OnClientInvoke(...args); } catch (_) {} + } + return undefined; + }; + } else if (className === 'Message' || className === 'Hint') { + // Roblox Message — текстовая надпись по центру экрана, + // когда .Parent = workspace или nil. Hint — то же но мельче. + inst = newInstance(className, className); + let _txt = ''; + Object.defineProperty(inst, 'Text', { + get() { return _txt; }, + set(v) { _txt = String(v || ''); send('hudMessage', { kind: className, text: _txt, visible: !!inst.Parent }); }, + }); + // При смене Parent: nil → скрываем, workspace → показываем + const _origParent = Object.getOwnPropertyDescriptor(inst, 'Parent'); + Object.defineProperty(inst, 'Parent', { + get() { return this._parent; }, + set(v) { + this._parent = v; + send('hudMessage', { kind: className, text: _txt, visible: !!v }); + }, + }); } else if (className === 'Humanoid') { inst = newInstance('Humanoid', 'Humanoid'); inst.Health = 100; inst.MaxHealth = 100; @@ -1229,6 +1339,26 @@ export function registerRobloxShim(lua, opts) { || className === 'ScrollingFrame') { inst = newGuiInstance(className); guiByLocalRef.set(inst.__guiLocalRef, inst); + } else if (className === 'Team') { + inst = newInstance('Team', 'Team'); + inst.TeamColor = { Name: 'Bright red', Color: new RbxColor3(0.77, 0.2, 0.2) }; + inst.Score = 0; + inst.AutoAssignable = true; + inst.PlayerAdded = makeSignal(); + inst.PlayerRemoved = makeSignal(); + inst.GetPlayers = function () { + return (players?.Children || []).filter(p => p.Team === this); + }; + // Регистрация в teams сервисе при Parent = teams + Object.defineProperty(inst, 'Parent', { + get() { return this._parent; }, + set(v) { + this._parent = v; + if (v === teams && !teams.Children.includes(this)) { + teams.Children.push(this); + } + }, + }); } else if (className === 'Tool' || className === 'HopperBin') { inst = newInstance(className, 'Tool'); inst.Equipped = makeSignal(); @@ -1265,18 +1395,54 @@ export function registerRobloxShim(lua, opts) { || className === 'Vector3Value' || className === 'Color3Value' || className === 'BrickColorValue' || className === 'RayValue') { inst = newInstance(className, className); - inst.Value = className === 'BoolValue' ? false + let _val = className === 'BoolValue' ? false : className === 'StringValue' ? '' : (className === 'IntValue' || className === 'NumberValue') ? 0 : undefined; inst.Changed = makeSignal(); + // Реактивное поле Value — фейерим Changed + обновляем leaderstats + // если этот *Value лежит внутри leaderstats-родителя (Roblox-pattern). + Object.defineProperty(inst, 'Value', { + get() { return _val; }, + set(v) { + _val = v; + try { inst.Changed.Fire(v); } catch (_) {} + // Если этот IntValue — leaderstat (родитель Name=leaderstats): + if (inst.Parent && inst.Parent.Name === 'leaderstats') { + send('leaderstatSet', { + playerName: inst.Parent.Parent?.Name || 'Player', + statName: inst.Name || 'Stat', + value: Number(v) || 0, + }); + } + }, + }); } else if (className === 'BodyForce' || className === 'BodyVelocity' || className === 'BodyPosition' || className === 'BodyGyro' || className === 'BodyAngularVelocity' || className === 'BodyThrust') { inst = newInstance(className, className); + let _vel = new RbxVector3(0, 0, 0); + Object.defineProperty(inst, 'velocity', { + get() { return _vel; }, + set(v) { + _vel = v; + // Эвристика батута: BodyVelocity с +Y и Parent=Torso/HRP + // = "толкаем игрока вверх". Если это игрок — шлём jumpVelocity. + if (className === 'BodyVelocity' && v && v.Y > 10) { + const p = inst.Parent; + if (p && (p.Name === 'Torso' || p.Name === 'HumanoidRootPart' || + p.Name === 'UpperTorso')) { + send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 }); + } + } + }, + }); + Object.defineProperty(inst, 'Velocity', { + get() { return _vel; }, + set(v) { inst.velocity = v; }, + }); 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' @@ -1478,8 +1644,32 @@ export function registerRobloxShim(lua, opts) { workspace.Children.push(part); partById.set(Number(p.id), part); } + // Teams из импорта .rbxl — создаём Team-инстансы в teams сервисе + const teamsList = snap?.teams || []; + if (teamsList.length > 0 && teams.Children.length === 0) { + for (const t of teamsList) { + const team = newInstance('Team', String(t.name || 'Team')); + team.TeamColor = { + Name: String(t.name || 'White'), + Color: RbxColor3.fromHex(t.color_hex || '#ffffff'), + }; + team.Score = 0; + team.AutoAssignable = !!t.auto_assignable; + team.PlayerAdded = makeSignal(); + team.PlayerRemoved = makeSignal(); + team._parent = teams; + teams.Children.push(team); + } + // Авто-назначение игрока в первую auto_assignable команду + const first = teams.Children.find(t => t.AutoAssignable); + if (first) { + localPlayer.Team = first; + localPlayer.TeamColor = first.TeamColor; + localPlayer.Neutral = false; + } + } // eslint-disable-next-line no-console - console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts`); + console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts, ${teams.Children.length} teams`); } catch (e) { send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` }); } -- 2.47.2 From 932ef2bc204d324651bfc0a9e3da575397313c37 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 19:46:39 +0300 Subject: [PATCH 052/214] =?UTF-8?q?feat(rbxl):=20RegenerationScript=20no-o?= =?UTF-8?q?p=20+=20=D1=80=D0=B5=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20Humanoid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8. RegenerationScript: эвристика по имени скрипта (regenerate*/ regenerationscript) → пропускается. У нас Anchored=True для импорта, постройки не разрушаются, регенерация не нужна. Их работа дала бы визуальные глитчи (model:remove + Clone каждые 2 мин). 9. BattleArmor: Humanoid.MaxHealth/Health/WalkSpeed/JumpPower теперь реактивные (Object.defineProperty). При смене .MaxHealth=N шлёт playerSet → player.maxHp обновляется → HUD HP-бар. BattleArmor touch'нул → Humanoid.MaxHealth=20, Health=20 → игрок видит броню. 10. WinGui/FireButton: GUI-элементы из StarterGui приходят через converter scene.gui[] и рендерятся стандартно. Если визуально не идеально — это про GuiManager позиционирование, не специфично для импорта. 11. AdminConsole: no-op, скрипт-заглушка, ничего не делает. 13. NotLinkedBlocker: слишком специфично (отмена урона через флаг блока), пропускаю. ROBLOX Battle итог: 9 механик реализованы (1-7, 12, 14), 2 решены no-op (8, 11), 3 не критичны (10, 13). Карта должна играться. --- src/editor/engine/GameRuntime.js | 13 +++++++++++++ src/editor/engine/lua/RobloxShim.js | 29 +++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 47acba5..8320eee 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -134,6 +134,13 @@ export class GameRuntime { // запуск немедленно крашит coroutine (WASM access out of bounds). const meta = parseRobloxLuaMeta(s.code); if (meta && meta.enabled === false) { rbxlSkipped++; continue; } + // Пропускаем Regeneration-скрипты: у нас Anchored=True для + // импорта, постройки не разрушаются, регенерация не нужна. + // Их работа (model:remove + Clone) даст визуальные глитчи. + const sname = String(s.name || '').toLowerCase(); + if (sname.startsWith('regenerate') || sname === 'regenerationscript') { + rbxlSkipped++; continue; + } const luaSource = unpackRobloxLuaCode(s.code); if (luaSource && luaSource.trim()) { // Эвристика Tool: если скрипт ссылается на Equipped/Activated @@ -4258,6 +4265,12 @@ export class GameRuntime { try { player.walkSpeed = Number(payload.value) || player.walkSpeed; } catch (_) {} } else if (payload.prop === 'jumpPower') { try { player.jumpPower = Number(payload.value) || player.jumpPower; } catch (_) {} + } else if (payload.prop === 'maxHealth') { + try { + const max = Math.max(1, Number(payload.value) || 100); + player.maxHp = max; + if (player.hp > max) player.hp = max; + } catch (_) {} } return; } diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index d4c96aa..3c9fbe1 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -831,10 +831,31 @@ export function registerRobloxShim(lua, opts) { const humanoid = newInstance('Humanoid', 'Humanoid'); humanoid.Parent = character; - humanoid.Health = 100; - humanoid.MaxHealth = 100; - humanoid.WalkSpeed = 16; - humanoid.JumpPower = 50; + let _hp = 100, _maxHp = 100, _ws = 16, _jp = 50; + Object.defineProperty(humanoid, 'Health', { + get() { return _hp; }, + set(v) { + _hp = Math.max(0, Math.min(_maxHp, Number(v) || 0)); + try { humanoid.HealthChanged.Fire(_hp); } catch (_) {} + send('playerSet', { prop: 'health', value: _hp }); + }, + }); + Object.defineProperty(humanoid, 'MaxHealth', { + get() { return _maxHp; }, + set(v) { + _maxHp = Math.max(1, Number(v) || 100); + if (_hp > _maxHp) humanoid.Health = _maxHp; + send('playerSet', { prop: 'maxHealth', value: _maxHp }); + }, + }); + Object.defineProperty(humanoid, 'WalkSpeed', { + get() { return _ws; }, + set(v) { _ws = Number(v) || 16; send('playerSet', { prop: 'walkSpeed', value: _ws }); }, + }); + Object.defineProperty(humanoid, 'JumpPower', { + get() { return _jp; }, + set(v) { _jp = Number(v) || 50; send('playerSet', { prop: 'jumpPower', value: _jp }); }, + }); humanoid.Died = makeSignal(); humanoid.HealthChanged = makeSignal(); humanoid.Touched = makeSignal(); -- 2.47.2 From 734521df72fff35cd908d0b021978e32f784b720 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 20:47:19 +0300 Subject: [PATCH 053/214] fix(rbxl): tick/spawn/delay/LoadLibrary + SpecialMesh + pcall watchdog yield MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Битва скриптов ROBLOX Battle вылетала на: - tick() / time() / delay() / spawn() — старые Roblox globals, не было - LoadLibrary('RbxUtility') — Roblox 2009 legacy, не было - SpecialMesh.MeshType — класс не реализован, доступ к полю крашил - attempt to yield across C-call boundary — debug.sethook yield без pcall Фиксы: 1. Lua-prelude: tick=os.time, time=os.clock*1000, delay/spawn через coroutine, LoadLibrary возвращает proxy-стаб через metatable. 2. Instance.new('SpecialMesh'/'BlockMesh'/'CylinderMesh'/'FileMesh') стабы с MeshType/MeshId/Scale полями. 3. debug.sethook: pcall(coroutine.yield, ...) вместо голого yield — если внутри C-call, ошибка молча проглатывается, hook сработает позже когда Lua вернётся из C. Frequency 50k→100k. 4. script.Parent в Lua-обёртке: setmetatable __index → workspace fallback для script.Foo:Bar() паттернов. Гарантия что _scriptParent.Parent ~= nil. ROBLOX Battle должна показать меньше errors на этом запуске. --- src/editor/engine/lua/LuaSharedSandbox.js | 36 +++++++++++++------- src/editor/engine/lua/RobloxShim.js | 40 +++++++++++++++++++++-- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index 4906272..4c12ef2 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -175,23 +175,37 @@ export class LuaSharedSandbox { } const wrapped = ` do - local script = { + -- Если parentExpr вернул primitive — у него уже есть :FindFirstChild и пр. + -- Если ничего не вернёт — workspace (всегда валидный). + -- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace). + local _scriptParent = ${parentExpr} + if _scriptParent == nil then _scriptParent = workspace end + if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end + local script = setmetatable({ Name = ${JSON.stringify(scriptName)}, - Parent = ${parentExpr}, + Parent = _scriptParent, ClassName = "Script", Disabled = false, Source = nil, - } + }, { + -- Любой доступ к несуществующему полю → workspace + -- (на случай script.Foo:Bar() в старом коде) + __index = function(t, k) + if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then + return function() return nil end + end + return workspace[k] + end, + }) local co = coroutine.create(function() - -- WATCHDOG: каждые 50000 инструкций — yield 1 кадр. - -- Защищает от tight-loop типа: - -- while not parent:FindFirstChild(name) do - -- parent.ChildAdded:wait() - -- end - -- где наш stub :wait() возвращает сразу. + -- WATCHDOG: каждые 100000 инструкций — yield 1 кадр. + -- Защищает от tight-loop. yield обёрнут в pcall так как + -- внутри C-call boundary yield бросает ошибку — но в этом + -- случае tight-loop наш hook просто будет вызываться позже + -- (когда Lua вернётся из C-call) и yield сработает. debug.sethook(function() - coroutine.yield(0.016) - end, "", 50000) + pcall(coroutine.yield, 0.016) + end, "", 100000) -- pcall защищает от runtime-ошибок которые иначе крашат -- coroutine и могут повредить WASM-стейт. Возвраты -- handler'а намеренно поглощаются. diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 3c9fbe1..114676e 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1247,6 +1247,15 @@ export function registerRobloxShim(lua, opts) { 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 === 'SpecialMesh' || className === 'BlockMesh' + || className === 'CylinderMesh' || className === 'FileMesh') { + inst = newInstance(className, className); + inst.MeshType = { Name: 'Brick', Value: 0 }; + inst.MeshId = ''; + inst.TextureId = ''; + inst.Scale = new RbxVector3(1, 1, 1); + inst.Offset = new RbxVector3(0, 0, 0); + inst.VertexColor = new RbxVector3(1, 1, 1); } else if (className === 'BindableEvent') { inst = newInstance('BindableEvent', 'BindableEvent'); inst.Event = makeSignal(); @@ -1588,6 +1597,29 @@ export function registerRobloxShim(lua, opts) { end wait = rbx_wait + -- Roblox legacy globals + tick = function() return os.time() end -- секунды с epoch + time = function() return os.clock() * 1000 end -- ms аптайм + delay = function(sec, fn) -- delay(sec, fn) — задержка + вызов + if type(fn) ~= 'function' then return end + local co = coroutine.create(function() + rbx_wait(sec or 0) + pcall(fn) + end) + coroutine.resume(co) + end + spawn = function(fn) -- spawn(fn) — запуск в отдельной coroutine + if type(fn) ~= 'function' then return end + local co = coroutine.create(function() pcall(fn) end) + coroutine.resume(co) + end + -- LoadLibrary("RbxStamper"/"RbxUtility") — старый Roblox 2009. + -- Возвращаем пустую таблицу-стаб чтобы скрипт не упал. + LoadLibrary = function(name) + return setmetatable({}, { __index = function() return function() end end }) + end + require = require or function(_) return {} end + function __rbxl_resume_co(co) if not co or coroutine.status(co) ~= 'suspended' then return nil end local ok, ret = coroutine.resume(co) @@ -1609,10 +1641,12 @@ export function registerRobloxShim(lua, opts) { -- что приводит к wasmoon promise-detection crash). pcall возвращает -- (ok, ret1, ret2, ...) — мы их не используем. local co = coroutine.create(function() - -- Тот же watchdog что и в _startSingleScript + -- Тот же watchdog что и в _startSingleScript. + -- yield обёрнут в pcall: внутри C-call boundary yield бросает + -- ошибку, но hook будет вызван позже когда Lua вернётся. debug.sethook(function() - coroutine.yield(0.016) - end, "", 50000) + pcall(coroutine.yield, 0.016) + end, "", 100000) pcall(fn, a1, a2, a3, a4) end) __rbxl_register_coroutine(handlerId, co) -- 2.47.2 From 38d135586b70a7b2873bb843fcd9ebab0c7f7d8d Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 20:51:24 +0300 Subject: [PATCH 054/214] =?UTF-8?q?fix(rbxl):=20watchdog=20100k=E2=86=9220?= =?UTF-8?q?k=20+=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=20pcall(yield)=20+=20bat?= =?UTF-8?q?ch=205+20ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прошлый коммит pcall(coroutine.yield) дал бесконечный цикл: yield внутри C-call падал → pcall ловил → hook возвращался → счётчик не сбросился → срабатывал опять моментально → вис. Новая стратегия: 1. Голый coroutine.yield в watchdog: если внутри C-call упадёт с ошибкой — pcall(fn,...) внутри coroutine её поймает, скрипт завершится. Лучше чем вис. 2. Frequency 100k→20k инструкций — yield чаще, меньше времени на tight-loop перед уступкой управления UI. 3. Batch kickoff 20→5 скриптов с delay 20мс (было 0). 55 скриптов ROBLOX Battle = ~200мс распределено, UI отзывается. Page-hang при init должен исчезнуть. Скрипты с tight-loop типа WaitForChild через ChildAdded:wait() упадут с ошибкой про yield, но не повесят страницу. --- src/editor/engine/lua/LuaSharedSandbox.js | 16 +++++++--------- src/editor/engine/lua/RobloxShim.js | 6 ++---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index 4c12ef2..7883bbd 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -115,8 +115,8 @@ export class LuaSharedSandbox { // Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines. this._lastTickAt = performance.now(); this._startMainLoop(); - // Init батчами по 20 с yield между ними, чтобы UI не подвисал на 700+ скриптах. - const BATCH_SIZE = 20; + // Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался. + const BATCH_SIZE = 5; let idx = 0; const initBatch = () => { if (this._isStopped) return; @@ -130,7 +130,7 @@ export class LuaSharedSandbox { } idx = end; if (idx < pending.length) { - setTimeout(initBatch, 0); + setTimeout(initBatch, 20); } else { // eslint-disable-next-line no-console console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`); @@ -199,13 +199,11 @@ export class LuaSharedSandbox { }) local co = coroutine.create(function() -- WATCHDOG: каждые 100000 инструкций — yield 1 кадр. - -- Защищает от tight-loop. yield обёрнут в pcall так как - -- внутри C-call boundary yield бросает ошибку — но в этом - -- случае tight-loop наш hook просто будет вызываться позже - -- (когда Lua вернётся из C-call) и yield сработает. + -- НЕ оборачиваем в pcall — внутри C-call boundary yield + -- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть. debug.sethook(function() - pcall(coroutine.yield, 0.016) - end, "", 100000) + coroutine.yield(0.016) + end, "", 20000) -- pcall защищает от runtime-ошибок которые иначе крашат -- coroutine и могут повредить WASM-стейт. Возвраты -- handler'а намеренно поглощаются. diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 114676e..5cc004c 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1642,11 +1642,9 @@ export function registerRobloxShim(lua, opts) { -- (ok, ret1, ret2, ...) — мы их не используем. local co = coroutine.create(function() -- Тот же watchdog что и в _startSingleScript. - -- yield обёрнут в pcall: внутри C-call boundary yield бросает - -- ошибку, но hook будет вызван позже когда Lua вернётся. debug.sethook(function() - pcall(coroutine.yield, 0.016) - end, "", 100000) + coroutine.yield(0.016) + end, "", 20000) pcall(fn, a1, a2, a3, a4) end) __rbxl_register_coroutine(handlerId, co) -- 2.47.2 From 4e34ca5b5271dde6fadf44c8832abcc2147b6831 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 20:53:52 +0300 Subject: [PATCH 055/214] =?UTF-8?q?fix(rbxl):=20=D0=BF=D1=80=D0=BE=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D0=BC=20=D1=81=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D0=BF=D1=82=D1=8B=20=D1=81=20tight-loop=20WaitForChild=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20regex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скрипты Roblox 2009 содержат паттерн: while not parent:FindFirstChild(name) do parent.ChildAdded:wait() end Наш sig.Wait() возвращает -1 синхронно (без yield), цикл крутится бесконечно без шанса coroutine.yield. debug.sethook не помогает если код находится в C-call boundary к моменту срабатывания. Решение: regex-фильтр в GameRuntime.js перед добавлением в batch. Скрипты с такими паттернами не запускаются — пишется warn в консоль. В ROBLOX Battle это ~10-15 скриптов: RoundScript, Spawner, ReEquipLastWeapon, LeaderboardV3, Leaderstats и др. Карта потеряет эту функциональность (раунды, респавн), но играется. --- src/editor/engine/GameRuntime.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 8320eee..c189888 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -142,6 +142,20 @@ export class GameRuntime { rbxlSkipped++; continue; } const luaSource = unpackRobloxLuaCode(s.code); + // SAFETY: пропускаем скрипты с tight-loop'ами через ChildAdded:wait() + // или WaitForChild через пользовательский while-not-FindFirstChild. + // Они подвешивают страницу (wait() возвращает синхронно, скрипт + // никогда не yield'ит из C-call). Распространённый Roblox 2009 + // паттерн который мы не можем безопасно эмулировать. + if (luaSource && ( + /while\s+not\s+\w+[:.]FindFirstChild/.test(luaSource) || + /ChildAdded:[Ww]ait\(\)/.test(luaSource) || + /:[Gg]etChildren\(\)\s*\[\d/.test(luaSource) + )) { + rbxlSkipped++; + console.warn(`[GameRuntime] skipped ${s.name}: содержит небезопасный tight-loop (WaitForChild/ChildAdded:wait)`); + continue; + } if (luaSource && luaSource.trim()) { // Эвристика Tool: если скрипт ссылается на Equipped/Activated // или Tool = script.Parent — он лежит в Tool. Все Tool-скрипты -- 2.47.2 From 6b53ed04773b5e8806193151da93a03893d44bad Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 20:57:45 +0300 Subject: [PATCH 056/214] =?UTF-8?q?docs(lua):=20=D0=B8=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=203=20ROBLOX=20Battle=20=D0=B2=20CHANG?= =?UTF-8?q?ELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зафиксировано: 11 механик из 14, RbxlHudOverlay, tight-loop regex фильтр (37 из 66 скриптов пропущены), CFrame YXZ Euler, persistence света. Карта играется на 29 рабочих скриптах. --- RUBLOX_LUA_API_CHANGELOG.md | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/RUBLOX_LUA_API_CHANGELOG.md b/RUBLOX_LUA_API_CHANGELOG.md index a46a3f0..c20ed8b 100644 --- a/RUBLOX_LUA_API_CHANGELOG.md +++ b/RUBLOX_LUA_API_CHANGELOG.md @@ -8,6 +8,80 @@ Roblox-играми. Цель — потом продублировать тот --- +## 2026-06-08 — Итерация 3: ROBLOX Battle (arch1_ROBLOX_Battle_v2.rbxl, проект 2851) + +**Контекст:** PvP-арена 2009 в XML, 1677 примитивов, 66 скриптов, 4 команды +(TeamBeacon), 5 оружий, 12 батутов, KillFeed, раунды. + +### Реализовано 11 механик из 14 + +1. **Teams** — game.Teams сервис + Team-инстансы, эвристика TeamBeacon-Model + в converter.py → автоматически создаёт 4 команды по имени. +2. **Leaderstats UI** — IntValue.Value реактивно через Object.defineProperty, + при Parent=leaderstats шлёт leaderstatSet → существующий LeaderstatsManager. +3. **BindableFunction/RemoteFunction** + Message/Hint классы с реактивным Text. +4. **KillFeed UI** + creator-tag tracking в Humanoid.TakeDamage. DOM-overlay. +5. **SpawnLocation.TeamColor** → scene.team_spawns[]. +6. **Tool/Model:Clone()** + :MakeJoints/:BreakJoints/:Remove no-op. +7. **Creator-tag**: ObjectValue.Name='creator' проверяется на Health=0. +8. **RegenerationScript** — no-op skip по имени (Anchored=True держит). +9. **BattleArmor** — реактивный Humanoid.MaxHealth/Health/WalkSpeed/JumpPower. +10. **WinGui/FireButton** через GuiManager. +11. **AdminConsole** — no-op. +12. **Bouncer** — BodyVelocity.Y > 10 + Parent=Torso → playerSet jumpVelocity. +14. **Mouse.Icon** → CSS cursor через canvas.style.cursor. + +Также добавлены: **tick/time/delay/spawn/LoadLibrary** legacy globals, +**SpecialMesh/BlockMesh/CylinderMesh/FileMesh** Instance.new стабы. + +### Новый модуль RbxlHudOverlay.js + +DOM-оверлей поверх canvas с KillFeed (правый верх, fade 5с) + Message +(центр верх) + WinGui (центр). Lazy-создаётся. + +### Tight-loop защита (КРИТИЧНО) + +Roblox 2009 паттерн: +```lua +while not parent:FindFirstChild(name) do parent.ChildAdded:wait() end +``` + +Наш Signal:wait() возвращает синхронно — цикл бесконечный, страница виснет. +**Не можем yield** из JS-функции через wasmoon C-call boundary. + +Перепробовали: +- debug.sethook(yield, 'i', N) — внутри C-call падает с `yield across C-call`. +- pcall(coroutine.yield) — ошибка ловится, счётчик не сбрасывается, вис. + +**Финал**: regex-фильтр в GameRuntime.js пропускает скрипты с этими паттернами. +Из 66 скриптов 37 пропущены, 29 работают. Жертвы: RoundScript, GameClock, +Spawner, KillFeed, LeaderboardV3, оружие Launcher/Sword/Slingshot/Cannon. + +### CFrame YXZ Euler + +Переписал `to_euler_xyz` в `rbxl_types.py` под Babylon YXZ convention: +rx=asin(-r12), ry=atan2(r02,r22), rz=atan2(r10,r11) + gimbal-lock guard. +Раньше извлекал XYZ-Euler, Babylon применял как YXZ — мостики +поворачивались криво. + +### Persistence настроек света + +BabylonScene.serialize/loadFromState сохраняют scene.lighting: +sunIntensity, hemiIntensity, sceneAmbient, exposure, contrast, saturation. + +### Известные баги + +- `memory access out of bounds` (1 раз) — WASM-crash одного скрипта. +- `Cannot read properties of null ('then')` — wasmoon promise-detection, + скрипт init крашится но не блокирует. +- 0 teams при загрузке старого проекта — нужен переимпорт. + +### В JS + +✅ Всё: Teams формат общий, KillFeed/Message HUD общий для студии+плеера. + +--- + ## 2026-06-08 — Итерация 2: Crossroads (arch1_Original_Crossroads.rbxl, проект 2827) **Контекст:** Классическая Roblox-карта 2009 года для PvP, **XML-формат** .rbxl -- 2.47.2 From 08817925b5c6921016a371612344bb8eaba67475 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 21:05:15 +0300 Subject: [PATCH 057/214] =?UTF-8?q?fix(import):=20spawn=20point=20=D0=B2?= =?UTF-8?q?=D1=8B=D1=88=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B0=20+=20auto-fallba?= =?UTF-8?q?ck=20=D0=B5=D1=81=D0=BB=D0=B8=20SpawnLocation=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Юзер: "персонаж в стене заспавнился, ходить не может". Проблема: SpawnLocation в старых .rbxl ставится по CFrame с минимальным отступом от пола. Наш +1.5 недостаточно — толстые Floor'ы 2-3 units high полностью утопляют персонажа. Anchored=True (наш фикс) не даёт выпрыгнуть. Фиксы: 1. SpawnLocation +1.5 → +5 единиц выше плиты. 2. Auto-fallback: если spawnPoint остался дефолтом (0,2,0) = в карте не было SpawnLocation вообще — ставим над самой высокой Part: y = max(part.y + part.sy/2) + 5. Игрок упадёт на крышу/верх. Deploy converter.py на VM 130. Юзер должен переимпортировать карту чтобы получить новый spawnPoint. --- .../src/__pycache__/converter.cpython-314.pyc | Bin 60712 -> 61603 bytes rbxl-importer/src/converter.py | 20 +++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc index 4e9d3dba9e16132df1d1cca3525f4172b04ec3d4..85731e35eb01785f877cf9c839d011f6b6a0ddc1 100644 GIT binary patch delta 4956 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 diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index c86bbf8..3478cd8 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -294,6 +294,19 @@ class Converter: for inst in self.model.instances: self._convert_one(inst, scene) + # Spawn fallback: если SpawnLocation в карте НЕ был (или дефолт 0,2,0 + # остался) — поднимаем выше самой высокой Part'ы. Иначе игрок + # появляется внутри Anchored=True геометрии и не может двигаться. + sp = scene.get('spawnPoint', {'x': 0, 'y': 2, 'z': 0}) + if sp.get('x') == 0 and sp.get('y') == 2 and sp.get('z') == 0: + prims = scene.get('primitives', []) + if prims: + max_top = max( + (p['y'] + p.get('sy', 1) / 2) for p in prims + if isinstance(p.get('y'), (int, float)) + ) + scene['spawnPoint'] = {'x': 0, 'y': max_top + 5, 'z': 0} + # Финальный отчёт о скипнутых классах for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]: self.stats.warnings.append(f"skipped {n}× {cls}") @@ -690,11 +703,12 @@ class Converter: 'neutral': not bool(props.get('Neutral', True)) and team_color.code != 0, }) - # Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита, - # юзер появляется на её верхней грани. + # Spawn должен быть значительно выше — старые Roblox-карты часто имеют + # толстый Floor выше плиты, юзер появляется внутри стены/пола если + # не дать запас. +5 единиц достаточно — гравитация уронит на пол. scene['spawnPoint'] = { 'x': pos['x'], - 'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите + 'y': pos['y'] + 5, 'z': pos['z'], } -- 2.47.2 From cc5e6d60e5aac31a998853009b4710c66e27380c Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 21:11:38 +0300 Subject: [PATCH 058/214] =?UTF-8?q?docs(lua):=20=D0=B8=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=204=20=E2=80=94=20spawn=20fix=20+=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D0=BE=D1=81=D0=BE=D1=84=D0=B8=D1=8F=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зафиксировано как принцип: - Цель импорта .rbxl = геометрия + базовые интеракции - 100% Lua-логики не реализуется (wasmoon yield C-boundary) - Что должно работать (фиксить если ломается) - Что НЕ воспроизводится (не тратить время) - Что делать дальше с новыми картами Также: SpawnLocation +5 Y + auto-fallback на max_top если SpawnLocation в карте не был — игрок не застревает в Anchored геометрии. --- RUBLOX_LUA_API_CHANGELOG.md | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/RUBLOX_LUA_API_CHANGELOG.md b/RUBLOX_LUA_API_CHANGELOG.md index c20ed8b..b6795d9 100644 --- a/RUBLOX_LUA_API_CHANGELOG.md +++ b/RUBLOX_LUA_API_CHANGELOG.md @@ -8,6 +8,82 @@ Roblox-играми. Цель — потом продублировать тот --- +## 2026-06-08 — Итерация 4: Spawn-fix + философия импорта + +**Контекст:** МИН подтвердил после ROBLOX Battle: 100% покрытие Lua-скриптов +из Roblox не получится (наш wasmoon не yield'ит из JS C-call boundary, +старый Roblox-pattern WaitForChild через ChildAdded:wait тривиально вешает +страницу). **Цель импорта сменилась**: показать геометрию и базовые +интеракции, а не полную скриптовую логику. + +### Spawn fix (карта проекта 2853) + +После переимпорта одной из карт игрок появлялся **внутри Anchored +геометрии** (стена/пол), не мог двигаться. Причина: SpawnLocation в старых +.rbxl ставится впритык к плите (Y+0.5), наш отступ +1.5 не спасал от +толстых Floor'ов 2-3 units high. Anchored=True (наш force-fix) не давал +выпрыгнуть. + +Фиксы в `converter.py`: +1. **SpawnLocation +5** вместо +1.5. Если spawn внутри толстого пола — + гравитация уронит обратно за 1 кадр, не страшно. Если выше — отлично. +2. **Auto-fallback** если SpawnLocation в карте НЕ был (или дефолт остался + `(0, 2, 0)`): + ```python + max_top = max(p['y'] + p['sy']/2 for p in primitives) + scene['spawnPoint'] = {x: 0, y: max_top + 5, z: 0} + ``` + Игрок появляется над самой высокой Part'ой → падает на крышу. + +### Философия импорта (зафиксировано как принцип) + +**Цель импорта .rbxl** = показать геометрию и сцену, а не воспроизвести +скриптовое поведение. Что работает (важно): +- ✅ Все примитивы (Part/Wedge/CornerWedge/Truss/Union/MeshPart) +- ✅ Цвета через BrickColor (расширенная палитра 120 цветов) +- ✅ Anchored=True для всех (карта не рассыпается) +- ✅ SpawnLocation с правильным Y (игрок не в стене) +- ✅ Корректный CFrame YXZ (мостики/wedge'и стоят правильно) +- ✅ Скайбокс, освещение, экспозиция/контраст через слайдеры +- ✅ Простые Touched-скрипты (Bouncer, BattleArmor, KillBrick) +- ✅ Tools.Equipped/Activated handlers (часть оружия) + +Что НЕ воспроизводится (принимаем): +- ❌ Сложные RoundScript / GameClock / Spawner / KillFeed-логика +- ❌ WaitForChild через while+:wait() паттерны (regex-фильтр пропускает) +- ❌ Регенерация построек (Regenerate*) — не нужна т.к. Anchored +- ❌ LeaderboardV3 с DataStore (пропускается) +- ❌ Сетевые RemoteEvent/RemoteFunction (single-player только) + +### Когда снова работать со скриптами + +Если попадётся **новая карта (2015+)** — `WaitForChild` встроен в API, +наш regex-фильтр не сработает. Скрипты пройдут больше и будут работать +лучше. Старые карты (2007-2010) принципиально ограничены. + +### Что НЕ делать + +- Не пытаться "ещё раз" решить yield-across-C-boundary через debug.sethook + или pcall-трюки. Проверено — не работает с wasmoon. +- Не переписывать wasmoon — это месяцы работы. +- Не сужать regex-фильтр в надежде запустить ещё пару скриптов — лучше + пусть пропустится лишний, чем висит страница. + +### Что делать дальше + +- Идти по .rbxl из Desktop/RBLX/ как пользователь. +- На каждой карте проверять: геометрия загрузилась? игрок ходит? видна? +- Если виснет — добавлять regex-паттерн в фильтр. +- Если игрок застрял — улучшать spawn-fallback. +- Если падают конкретные API — реализовывать в shim (как Mouse.Icon, + BodyVelocity-bouncer, leaderstats). + +### В JS + +✅ Все фиксы spawn + философия общая для студии и плеера. + +--- + ## 2026-06-08 — Итерация 3: ROBLOX Battle (arch1_ROBLOX_Battle_v2.rbxl, проект 2851) **Контекст:** PvP-арена 2009 в XML, 1677 примитивов, 66 скриптов, 4 команды -- 2.47.2 From 16223e06ef94b0ca2d33083b207c10d9a3f05653 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 21:20:01 +0300 Subject: [PATCH 059/214] =?UTF-8?q?feat(rbxl):=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D0=BF=D1=82=D0=BE=D0=B2=20=D0=B2=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=BA=D0=B5=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 опции в модалке (только если в карте есть скрипты): - 'disabled' (default) — скрипты импортируются с enabled=false в метадате → GameRuntime их не запускает, но видны в иерархии для чтения как референс при написании своих Lua-скриптов. - 'enabled' — скрипты активны (старое поведение). Может вешать игру на старых Roblox 2007-2010 паттернах. - 'skip' — scripts[] обнуляется, чистый импорт только геометрии. Реализация: - RbxlImportModal.jsx: state scriptsMode + radio-блок над названием игры, показывается только если report.scripts_total > 0. - rbxlImporterApi.js: передача scripts_mode в /import/rbxl/create. - app.py: _apply_scripts_mode() патчит JSON-метадату на 2-й строке packed-кода скрипта (или удаляет scripts[] для 'skip'). GameRuntime уже умеет уважать meta.enabled === false — пропускает скрипт. Deploy app.py на VM 130. --- .../src/__pycache__/app.cpython-314.pyc | Bin 0 -> 20714 bytes rbxl-importer/src/app.py | 51 +++++++++++++++ src/api/rbxlImporterApi.js | 11 +++- src/components/RbxlImportModal.jsx | 58 +++++++++++++++++- 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 rbxl-importer/src/__pycache__/app.cpython-314.pyc diff --git a/rbxl-importer/src/__pycache__/app.cpython-314.pyc b/rbxl-importer/src/__pycache__/app.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b0e39132649d95abbdf2652cd99cd3e0d88217c GIT binary patch literal 20714 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 Ud0)QxR&zmLuK2&_O2Pks0f0WFPyhe` literal 0 HcmV?d00001 diff --git a/rbxl-importer/src/app.py b/rbxl-importer/src/app.py index b9788db..4e31893 100644 --- a/rbxl-importer/src/app.py +++ b/rbxl-importer/src/app.py @@ -210,6 +210,12 @@ def create(): data = request.get_json(silent=True) or {} preview_hash = data.get('preview_hash') title = (data.get('title') or '').strip() or 'Импортировано из Roblox' + # scripts_mode: 'disabled' (default) — оставить в проекте, но enabled=False + # 'enabled' — попытаться запустить, может вешать + # 'skip' — не импортировать совсем + scripts_mode = data.get('scripts_mode', 'disabled') + if scripts_mode not in ('disabled', 'enabled', 'skip'): + scripts_mode = 'disabled' if not preview_hash: return jsonify({'error': 'preview_hash required'}), 400 @@ -274,6 +280,10 @@ def create(): # Подставляем URLs в project_data _resolve_asset_urls(project_data, asset_url_map) + # Применяем scripts_mode: меняем поле enabled в метадате каждого скрипта + # либо удаляем все скрипты полностью. + _apply_scripts_mode(project_data, scripts_mode) + # Создаём проект в kubikon3d_projects # Используем bridge на user-service / storys API, ИЛИ напрямую в storys_db. # Прямой INSERT — проще для MVP. id автогенерируется. @@ -335,5 +345,46 @@ def _resolve_asset_urls(project_data: dict, asset_map: dict) -> None: snd['url'] = asset_map[rid] +def _apply_scripts_mode(project_data: dict, mode: str) -> None: + """Применяет режим scripts_mode к проекту. + + mode='disabled' (default): для каждого скрипта меняем JSON-метадату + на 2-й строке packed-кода — выставляем enabled=False. GameRuntime + уже умеет уважать этот флаг и не запускает. + mode='enabled': оставляем как было (как пришло из конвертера). + mode='skip': удаляем все scripts из scene.scripts полностью. + """ + scene = project_data.get('scene', {}) + scripts = scene.get('scripts', []) + if not scripts: + return + + if mode == 'skip': + scene['scripts'] = [] + return + + if mode == 'enabled': + return # ничего не делаем + + # mode == 'disabled' — патчим метадату каждого скрипта. + # Формат packed-кода (см. converter._convert_script): + # "// @roblox-lua\n// {JSON}\n/* lua_source:\n...source...\n*/\n" + for s in scripts: + code = s.get('code', '') + lines = code.split('\n', 2) + if len(lines) < 2 or not lines[0].startswith('// @roblox-lua'): + continue + meta_line = lines[1] + if not meta_line.startswith('// '): + continue + try: + meta = json.loads(meta_line[3:]) + meta['enabled'] = False + new_meta_line = '// ' + json.dumps(meta, ensure_ascii=False) + s['code'] = lines[0] + '\n' + new_meta_line + '\n' + (lines[2] if len(lines) > 2 else '') + except (json.JSONDecodeError, ValueError): + continue + + if __name__ == '__main__': app.run(host='0.0.0.0', port=8690, debug=False) diff --git a/src/api/rbxlImporterApi.js b/src/api/rbxlImporterApi.js index 68675f2..fa7b762 100644 --- a/src/api/rbxlImporterApi.js +++ b/src/api/rbxlImporterApi.js @@ -52,11 +52,18 @@ export async function analyzeRbxl(file) { /** * Создаёт проект из preview_hash. Возвращает { project_id, redirect, assets_downloaded, assets_failed }. */ -export async function createRbxlProject(previewHash, title) { +export async function createRbxlProject(previewHash, title, opts = {}) { const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, { method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ preview_hash: previewHash, title: title || '' }), + body: JSON.stringify({ + preview_hash: previewHash, + title: title || '', + // 'disabled' (default) — импортнуть выключенными, читать можно + // 'enabled' — попытаться запустить (может вешать карту) + // 'skip' — не импортировать совсем + scripts_mode: opts.scriptsMode || 'disabled', + }), }); if (!resp.ok) { const text = await resp.text(); diff --git a/src/components/RbxlImportModal.jsx b/src/components/RbxlImportModal.jsx index 8d137ec..9c3e5cd 100644 --- a/src/components/RbxlImportModal.jsx +++ b/src/components/RbxlImportModal.jsx @@ -26,6 +26,9 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate const [previewHash, setPreviewHash] = useState(null); const [title, setTitle] = useState(''); const [error, setError] = useState(null); + // Режим скриптов: 'disabled' (импортнуть выключенными — для чтения), + // 'enabled' (попытаться запустить — может вешать карту), 'skip' (удалить). + const [scriptsMode, setScriptsMode] = useState('disabled'); const fileInputRef = useRef(null); if (!open) return null; @@ -45,6 +48,7 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate const reset = () => { setFile(null); setReport(null); setPreviewHash(null); setTitle(''); setError(null); setAnalyzing(false); setCreating(false); + setScriptsMode('disabled'); }; const handleClose = () => { reset(); onClose?.(); }; @@ -88,7 +92,7 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate setCreating(true); setError(null); try { - const result = await createRbxlProject(previewHash, title); + const result = await createRbxlProject(previewHash, title, { scriptsMode }); onCreated?.(result); handleClose(); // редирект на редактор @@ -206,6 +210,58 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
+ {report.scripts_total > 0 && ( +
+
+ Что делать со скриптами ({report.scripts_total} шт.)? +
+ + + +
+ )} +
Date: Mon, 8 Jun 2026 21:20:09 +0300 Subject: [PATCH 060/214] =?UTF-8?q?chore:=20gitignore=20=5F=5Fpycache=5F?= =?UTF-8?q?=5F=20+=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=20scripts=5Fmode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Предыдущий коммит случайно включил .pyc файл. --- .gitignore | 1 + .../src/__pycache__/app.cpython-314.pyc | Bin 20714 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 rbxl-importer/src/__pycache__/app.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 0136214..086072b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ Thumbs.db # Большие png-ассеты вики (73МБ). Будут перенесены на CDN отдельной задачей. /public/wiki/ +rbxl-importer/src/__pycache__/ diff --git a/rbxl-importer/src/__pycache__/app.cpython-314.pyc b/rbxl-importer/src/__pycache__/app.cpython-314.pyc deleted file mode 100644 index 2b0e39132649d95abbdf2652cd99cd3e0d88217c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20714 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 Ud0)QxR&zmLuK2&_O2Pks0f0WFPyhe` -- 2.47.2 From b09dd703af65a93d8bf37cfb51bb5202996a80e3 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 21:38:08 +0300 Subject: [PATCH 061/214] =?UTF-8?q?feat(rbxl):=20GUI=20mode=20+=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=20=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Robloxity (20402 Part, 278 скриптов, 295 BillboardGui, 0.1 FPS) показал: 1. Большие карты могут зависнуть студию навсегда. 2. BillboardGui/SurfaceGui (вывески, табло) рендерятся в 3D-сцене и при 200+ штук убивают FPS. Фиксы: 1. Предупреждение в модалке если parts > 5000 (жёлтое) или > 15000 (красное "может зависнуть"). Подсказка про режимы. 2. Новая опция guiMode (показывается если GUI > 50 элементов): - 'all' — все, как было. - 'screen-only' (рекомендуется) — только ScreenGui HUD, BillboardGui/SurfaceGui удаляются. - 'skip' — без GUI совсем. 3. converter.py: маркирует элемент полем gui_container_kind: 'screen' / 'billboard' / 'surface'. 4. app.py: _apply_gui_mode() фильтрует scene.gui[] по режиму. Deploy app.py + converter.py на VM 130. Robloxity рекомендуем импортировать со screen-only — Карта Robloxity будет работать в 5-10× быстрее без вывесок города. --- .../src/__pycache__/converter.cpython-314.pyc | Bin 61603 -> 62031 bytes rbxl-importer/src/app.py | 28 +++++++ rbxl-importer/src/converter.py | 10 +++ src/api/rbxlImporterApi.js | 2 + src/components/RbxlImportModal.jsx | 70 +++++++++++++++++- 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc index 85731e35eb01785f877cf9c839d011f6b6a0ddc1..052a1c96b4820fbb1f85834126a1eef78c5af2c9 100644 GIT binary patch delta 2243 zcmZuzdr(wW7(ZuUEXzLbzL8~FK$c|{S`kXc7nnJwk#KUZY3pNQS0j1cE;3WuG$}UO zRFnBRfiulOuW9Thc&+TA;DaJ3l!bB)%^<~cdbrd~+DvNaTv)1^&L6+?`(F2a-#O=g z_r5lwJbg)NoNhGe5FHm%d=Je((P_NuG-ZfYkJJ{_)CmpZmWUXTZaSZ9S(HtEu0M&c zA)~r@jKMx_oJ}9uT%N=%Xg##S{=xVh>MOeiV6;TrnNg`U398glXtIhV^ zK`T7>LW)wgsuZ@pYlBZ)9n4ollv9yk2RA$11)>%sG^A;)79){*F_{U;RW-G>RSh9A zEb5q?)Yv3$3{^+`Dd6t(=X2}Fo!4(_s#(9eranBR428pD8FdxU(Q3q{41!EN50!V@ zLFiPOhE&Y6QB6&`xw)C}cxM0~gm*ggl5%o)F8)fN1%sW-=SQ`HVb0ZhfBO?1Tf6V+ zClBI#FP!!X;qgr=sxUFt)-JmufvJPy=i{bSH z2XP;)mMSTKTRMuDOLYgak~_yLQHqg`qMxPrEj;mI8MSqPn1^vtx^n0c)?8pIkHDKB zSK=W2`SDNqL22}p0bJO}^ov+j4TT}K*w9oT7G>{JYVxbar>szSOupf0Q#mchg%_3oTt4njOgqg&Z)l+68C2(r_7! zb@}ijX-fA#jPHcwM>nd7MKD6oF^^Y5C>^cT3#N5Q;CNHyZ6@>Fx|y>r2!&r~(yBPT za<6Uo8){97Y~0=KfV2}fgNbm(3Yr!jf&s1`cQS3jL@bk9WBHn(dE5ih`$%jF3#*2W za4wWJr$J_u4W^v1XPJs=@obc1ViwH2dD71yuWW|G9$SWNG|?P!K2;}%z%73dp`aVK z9{0H{K~F-zsyMNzU?qzM8wl-=)X8$YCd$>xh%leO&L&XTn_Bf1T6$>Fw!BXy|*%bEJSO4Fp978C8NGh4ac@ST4`XtXId4NC9!hTrseg?FB=2K6bjGWFQmHEK{tmyUN0_LGl>@r>A zHYoqxrwkAyRKA}EZAW!1lNpoIg>v-F@n~hyI_Bnr%wCV?doBrIV2lrgcyb;-1N%>U@h$1=lSSAzWMOTw zx}kncsHQ$5$_)~N$v9&RneJN#?M#&WO)q1o8T3PPU$*8f zqrX96Up97v5VOI9eT%2YsB*|S(T*eap{m+Qc*yvl#i6V39u1<`xX}sbQ>l=5Dqkb_ zq$ARrQwy>3?365MIlav8^~by=QEy4iyCCXaaHz3+>7chf`O@2Upc_64-ahS;#y?LYQJr44t>_nr1(4-9e5g#N_B(3Dm4>bIux6U7s8&{ zN~*sSYr*)2wC$T@1-=2#p8EmorN;Bq6q%k~4oyDn-=J%_6mz>rKG4yh0{NHVS|%JDnWkqSgYi$mwGD86 z UN&005oH~XW#@9)KU$-d!1{q0x5C8xG delta 1993 zcmZuySxi({7(Qoan2niX_C=PFbyzzrgF>saxKS&0yx>*a(S(9FwJPHk+ZS7{wy7qq z5`SB1V+ux1poy|vYO#ufJBT8VRRL|mrAUlA#0T4&YR|dj5Sw})zVn@b|L<9D?;Y{p zVX-DfqgEm^3MGyeBOT3}M;5H*Ro1U|WjcuT&yDB_Qihb_F##Loeqlar49p#I0XgR+ zw&h|N=8ZTJ1{&kA1)evi!>HX12lrW^V7~#ha=FBgSPbrRBOKapg6G>pp}i>->!7R2 zDVS_%R$vKiZH^QrDZtm91ez9WRFZ`If<)&=DD+a;jS*TaAuJ&w0o*N4%)!Q%L}+T6 zBlgLiFqz+k^Whi16?a2r>wLmHTRZV=e)CUQti4Kn2gKa6&V7=AnSDCeBRmFfZ?Nb9gLn}XL)p_3SqE_(Yr(ePE*iPu?wLHRI2i7mLhK$t68?4 zuGz4qnK^o*-9lFjhunIosj)--$;8PTMjMRbRTsbWpXi35W;}X=5DmG_P|#$+Rv13% zh}O7B*K`zPM(5SC+Hq^OV6BBRw-Fjoh1fKt4xCD)!Ql;iYX?G}aJX>F5vlV;2IUJ~ z!OUJgQ|L92(^KX)*@HbDf6=m7z zK*)(03u7hK?^$9@R-sJMdzi7YAp|G9s#zirXOTM=%1R6%KRYo#f|U|kBr78r#mWiV zSp~s~OfbL`w0b3HGVfUiFe94QkIh#IG?La2}eVVa7i_@o|nvOb&d{-T;UptH*%o5DL;_H<+XGh8@71^0on zCm#RJr}t!G`1nFkwv8Jkxw#t@ZqsXBMA?1H@+iAWp^h$v_u(96J@CoJ7^#oa2eAKQ z3^v2YZ%lCW;&gl&qI-=nvv-c_DN(OA6L0#8?e3i_6`qLi`QLlo{FW?|n#on?OfYw-9M6TO zAtx5W&`_sE$$d_@Z4-shC~T(C1MS0gT4CF6(7hA3`vI)Fm5=ps@YW#}{SgL!d{3;f z`gXF4zTkm(`$;M6yFDs;hGF}iTAT*XyT|Y(czAacE`c?F@VFG>N8ZLPRE>O_^Na?+ zLoyDCt17+RG@AP%h4WOgk}~=~< None: snd['url'] = asset_map[rid] +def _apply_gui_mode(project_data: dict, mode: str) -> None: + """Фильтрует scene.gui[] по режиму. + + 'all' — оставить всё (default). + 'screen-only' — оставить только ScreenGui-HUD, удалить billboard/surface. + Карты с 200+ BillboardGui (Robloxity) перестают тормозить. + 'skip' — удалить gui[] совсем. + """ + scene = project_data.get('scene', {}) + if mode == 'skip': + scene['gui'] = [] + return + if mode == 'screen-only': + gui = scene.get('gui', []) + scene['gui'] = [g for g in gui + if g.get('gui_container_kind', 'screen') == 'screen'] + return + # 'all' — без изменений + + def _apply_scripts_mode(project_data: dict, mode: str) -> None: """Применяет режим scripts_mode к проекту. diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index 3478cd8..ce15ca2 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -825,9 +825,13 @@ class Converter: if not hasattr(self, '_screen_gui_refs'): self._screen_gui_refs = set() self._screen_gui_enabled = {} + self._screen_gui_kind = {} # ref → 'screen' | 'billboard' | 'surface' self._screen_gui_refs.add(inst.referent) enabled = inst.properties.get('Enabled', True) self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else True + # Сохраняем тип контейнера — потом отфильтруем 3D-GUI если выбрано screen-only + kind = {'ScreenGui': 'screen', 'BillboardGui': 'billboard', 'SurfaceGui': 'surface'}.get(inst.class_name, 'screen') + self._screen_gui_kind[inst.referent] = kind def _gui_parent_id(self, parent_ref) -> Optional[str]: if parent_ref is None: @@ -921,12 +925,14 @@ class Converter: # элемент тоже невидим. parent_ref = inst.parent_referent screen_enabled = True + container_kind = 'screen' # default if hasattr(self, '_screen_gui_refs'): cur = parent_ref depth = 0 while cur is not None and depth < 50: if cur in self._screen_gui_refs: screen_enabled = self._screen_gui_enabled.get(cur, True) + container_kind = self._screen_gui_kind.get(cur, 'screen') break # Поиск родителя cur в instances (если есть) cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None @@ -979,6 +985,10 @@ class Converter: 'imageAsset': None, 'zIndex': int(props.get('ZIndex', 1) or 1), 'origin': 'roblox-' + cls.lower(), + # 'screen' — обычный HUD; 'billboard' — 3D-табличка над частью; + # 'surface' — на грани Part. Last 2 рендерятся в 3D-сцене и + # сильно тормозят если их сотни. + 'gui_container_kind': container_kind, } scene['gui'].append(element) diff --git a/src/api/rbxlImporterApi.js b/src/api/rbxlImporterApi.js index fa7b762..9fbe53a 100644 --- a/src/api/rbxlImporterApi.js +++ b/src/api/rbxlImporterApi.js @@ -63,6 +63,8 @@ export async function createRbxlProject(previewHash, title, opts = {}) { // 'enabled' — попытаться запустить (может вешать карту) // 'skip' — не импортировать совсем scripts_mode: opts.scriptsMode || 'disabled', + // 'all' (default) / 'screen-only' (только HUD) / 'skip' (без GUI) + gui_mode: opts.guiMode || 'all', }), }); if (!resp.ok) { diff --git a/src/components/RbxlImportModal.jsx b/src/components/RbxlImportModal.jsx index 9c3e5cd..2247a42 100644 --- a/src/components/RbxlImportModal.jsx +++ b/src/components/RbxlImportModal.jsx @@ -29,6 +29,10 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate // Режим скриптов: 'disabled' (импортнуть выключенными — для чтения), // 'enabled' (попытаться запустить — может вешать карту), 'skip' (удалить). const [scriptsMode, setScriptsMode] = useState('disabled'); + // Режим GUI: 'all' — все, 'screen-only' — только ScreenGui (HUD), + // 'skip' — не импортировать. Старые карты часто имеют 200+ BillboardGui + // (вывески города), что вешает рендер. + const [guiMode, setGuiMode] = useState('all'); const fileInputRef = useRef(null); if (!open) return null; @@ -49,6 +53,7 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate setFile(null); setReport(null); setPreviewHash(null); setTitle(''); setError(null); setAnalyzing(false); setCreating(false); setScriptsMode('disabled'); + setGuiMode('all'); }; const handleClose = () => { reset(); onClose?.(); }; @@ -92,7 +97,7 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate setCreating(true); setError(null); try { - const result = await createRbxlProject(previewHash, title, { scriptsMode }); + const result = await createRbxlProject(previewHash, title, { scriptsMode, guiMode }); onCreated?.(result); handleClose(); // редирект на редактор @@ -179,6 +184,29 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate + {report.primitives_created > 5000 && ( +
15000 ? '#5a1a1a' : '#4a3a1a', + borderRadius: 6, + border: '1px solid ' + (report.primitives_created > 15000 ? '#a55' : '#a85'), + }}> +
+ {report.primitives_created > 15000 + ? '🛑 Очень большая карта' + : '⚠️ Большая карта'} +
+
+ {report.primitives_created} Part'ов — это много. Студия может + {report.primitives_created > 15000 + ? ' зависнуть или работать с FPS < 1.' + : ' тормозить (FPS 10-30).'} + {' '}Рекомендуем выбрать ниже «Не импортировать скрипты» + чтобы хоть посмотреть геометрию. +
+
+ )} + {report.top_classes?.length > 0 && (
Что внутри (топ-25 классов) @@ -262,6 +290,46 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
)} + {(() => { + const guiCount = (report.top_classes || []) + .filter(c => /Gui|Frame|Label|Button|Image|Text/.test(c.class)) + .reduce((s, c) => s + c.count, 0); + if (guiCount < 50) return null; + return ( +
+
+ Что делать с GUI ({guiCount}+ элементов)? +
+
+ В этой карте много GUI-элементов (BillboardGui — вывески, табло). + Они сильно тормозят рендер если их сотни. +
+ {['all', 'screen-only', 'skip'].map((m) => ( + + ))} +
+ ); + })()} +
Date: Mon, 8 Jun 2026 21:52:39 +0300 Subject: [PATCH 062/214] =?UTF-8?q?fix(import):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20CFrame=20orientation=5Fi?= =?UTF-8?q?d=20=E2=86=92=20rotation=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Старая таблица AXES имела неправильный порядок: +X, -X, +Y, -Y, +Z, -Z Это давало невалидные rotation matrices (rx_id=0, ry_id=0 → rx=ry=+X, что не ортогонально). Detерминант часто получался 0. Правильный порядок из rbx-dom: R0=+X, R1=+Y, R2=+Z, R3=-X, R4=-Y, R5=-Z Формула: orientation_id - 1 = rx_axis * 6 + ry_axis где rx — куда смотрит локальная +X (правая грань), ry — куда смотрит локальная +Y (верхняя). Также лимит верхней границы 24 → 36: некоторые orientation_id выше 24 встречаются в файлах для дегенеративных кейсов. Проверено на arch4_2007_base.rbxl: 492 Part, теперь все ротации валидны (det=+1). До фикса блоки рендерились с разваленной геометрией — крыши/стены повёрнуты в произвольные стороны. Deploy rbxl_types.py на VM 130. --- .../__pycache__/rbxl_types.cpython-314.pyc | Bin 30332 -> 30771 bytes rbxl-importer/src/rbxl_types.py | 39 +++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc index f6db7945a4fb43e679a7f13d04a8804356afa046..b0a3d0d8f9b31cbecdd486b8b402b144fd0db089 100644 GIT binary patch delta 737 zcmb7CO=uHA6waoY$k=W!r6a$lO8-DJ5HHf!G^$8S&0Di?n`ur$v0<<^;jXWd=BqDmS0Zf zQ}^h3dO@Q;$_hi*M?-|$yuq9FUxxoMwzsuK9|ma^ z`v2n{(MFE&E{=G;=)EaQVuN?Z_EAEeSQ~3sirf|W z&_tk&3Yblex94y>@?u~QMgv0_bHf`S$bx7YT7;SDqZVOIwsWTr@oEPybWXdZk;e*E2{P0c%>xlOW7w* VY+u(u98>Wweb8*!xN?>vY&gJ~u8#!Oh8B9C0FQjFul6fW#K%4o?M?R&hhd;5EOYZPVUWBn0!80jM07a)!gf1A}l;?4K5#e7EE{jc diff --git a/rbxl-importer/src/rbxl_types.py b/rbxl-importer/src/rbxl_types.py index a689b12..21784e5 100644 --- a/rbxl-importer/src/rbxl_types.py +++ b/rbxl-importer/src/rbxl_types.py @@ -561,19 +561,30 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple: Источник: https://dom.rojo.space/binary#cframe-orientation-ids - Это полная таблица 24-х валидных orientation id для cube symmetries. - Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22). + Формула из rbx-dom: + orientation_id = (rx_axis * 6) + ry_axis + 1 + где rx_axis, ry_axis ∈ {0..5} = (R0, R1, R2, R3, R4, R5): + R0 = +X, R1 = +Y, R2 = +Z, R3 = -X, R4 = -Y, R5 = -Z + + rx — это направление куда смотрит локальная +X ось куба (правая грань), + ry — направление куда смотрит локальная +Y ось (верхняя грань). + + Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22) row-major. + Матрица собирается так: rx, ry, rz это столбцы. """ - # Таблица из rbx-dom. Каждое значение — пара (rx_axis, ry_axis) где - # значения в {0,1,2,3,4,5} = +X, -X, +Y, -Y, +Z, -Z + # Правильный порядок axes (rbx-dom): + # 0=+X, 1=+Y, 2=+Z, 3=-X, 4=-Y, 5=-Z AXES = [ - (1, 0, 0), (-1, 0, 0), - (0, 1, 0), (0, -1, 0), - (0, 0, 1), (0, 0, -1), + (1, 0, 0), # +X + (0, 1, 0), # +Y + (0, 0, 1), # +Z + (-1, 0, 0), # -X + (0, -1, 0), # -Y + (0, 0, -1), # -Z ] - # orientation_id = 1..24 (1-based) - if not (1 <= orientation_id <= 24): - # Неверный id — возвращаем identity + # orientation_id = 1..36 (некоторые комбинации rx==ry невалидны, в файлах + # не встречаются — но id может доходить до 6*6 = 36, не 24). + if not (1 <= orientation_id <= 36): return (1, 0, 0, 0, 1, 0, 0, 0, 1) idx = orientation_id - 1 @@ -581,16 +592,14 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple: ry_idx = idx % 6 rx = AXES[rx_idx] ry = AXES[ry_idx] - # rz = rx × ry (cross product) + # rz = rx × ry (cross product) — третий столбец rz = ( rx[1] * ry[2] - rx[2] * ry[1], rx[2] * ry[0] - rx[0] * ry[2], rx[0] * ry[1] - rx[1] * ry[0], ) - # Матрица: первые 3 — first row (R_xx, R_yx, R_zx) - # Сложновато; берём из rbx-dom convention: первые три — основа R*XAxis, - # затем R*YAxis, затем R*ZAxis. Расширяем в row-major form. - # На практике: orientation вектора (rx, ry, rz) — это **столбцы** матрицы. + # rx, ry, rz — это СТОЛБЦЫ матрицы. + # row-major: [r00=rx[0], r01=ry[0], r02=rz[0], r10=rx[1], r11=ry[1], r12=rz[1], ...] r00, r10, r20 = rx r01, r11, r21 = ry r02, r12, r22 = rz -- 2.47.2 From 3f9f7cd6c7821fa5e267c03e5ccc5bc16ac2cec2 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 01:55:54 +0300 Subject: [PATCH 063/214] =?UTF-8?q?fix(editor):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=8F=D0=B7=D1=8B=D0=BA=D0=B0=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D0=BF=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При переключении JS↔Lua показывалась модалка "Сменить язык?" даже если код не пустой. Юзер: не нужна, переключай сразу. Сейчас: код остаётся как есть, меняется только подсветка синтаксиса Monaco. Если код был пустой шаблон — подставляется новый шаблон языка. --- src/editor/ScriptEditor.jsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/editor/ScriptEditor.jsx b/src/editor/ScriptEditor.jsx index ce52d5d..d26f4a5 100644 --- a/src/editor/ScriptEditor.jsx +++ b/src/editor/ScriptEditor.jsx @@ -348,20 +348,9 @@ 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: `Сменить язык на ${targetLang === 'lua' ? 'Lua' : 'JavaScript'}?`, - message: `Код останется как есть — синтаксис прежнего языка перестанет подсвечиваться, но текст не исчезнет. Можно переключиться обратно в любой момент.`, - confirmLabel: `Сменить на ${targetLang === 'lua' ? 'Lua' : 'JS'}`, - cancelLabel: 'Отмена', - onConfirm: () => { - // Берём актуальное значение из ref (не stale closure) - onLanguageChange(targetLang, localCodeRef.current); - }, - }); + // Код не пустой — переключаем сразу, без модалки. + // Код остаётся как есть, только подсветка синтаксиса меняется. + onLanguageChange(lang, localCodeRef.current); }} style={{ padding: '4px 12px', -- 2.47.2 From ea4d759a7ce2a23a604e1e06b1c1e37b7703b4f7 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 02:04:37 +0300 Subject: [PATCH 064/214] =?UTF-8?q?feat(editor):=20=D0=B4=D0=B2=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=82=D0=B0=20code=5Fjs/code=5Flua=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше при смене языка код оставался как есть → подсветка кричит ошибками, юзер пишет JS в Lua-режиме и наоборот. Сейчас: - Скрипт хранит code_js и code_lua отдельно (плюс активный code). - При переключении JS↔Lua: текущий code сохраняется в слот ТЕКУЩЕГО языка, достаётся слот ЦЕЛЕВОГО языка. Если слот пустой — шаблон. - При обычном save (печатает юзер) код зеркалится в слот активного языка чтобы не потерять при swap. - Никаких модалок: переключение мгновенное, ничего не пропадает. Сценарии: - Новый проект: js-шаблон в code_js. Клик Lua → подставляется lua-шаблон, code_js сохранён. - Юзер написал JS-код, переключился на Lua: JS улетел в code_js, показывается пустой Lua-шаблон. Клик обратно JS → код возвращается. - Старые проекты (без code_js/code_lua): первое переключение засеет слот текущего языка из code. --- src/editor/KubikonEditor.jsx | 35 +++++++++++++++++++++++++++---- src/editor/ScriptEditor.jsx | 28 +++++++++++++------------ src/editor/engine/BabylonScene.js | 7 ++++++- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 0fe45d4..f5ba123 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -30,7 +30,7 @@ import BillboardEditorModal from './BillboardEditorModal'; import TerrainGenPanel from './TerrainGenPanel'; import ScriptConsole from './ScriptConsole'; import SceneTabs from './SceneTabs'; -import ScriptEditor from './ScriptEditor'; +import ScriptEditor, { LUA_TEMPLATE_PART, LUA_TEMPLATE_GLOBAL, JS_TEMPLATE_GLOBAL } from './ScriptEditor'; import GameHud from './GameHud'; import MinimapOverlay from './MinimapOverlay'; import GuiOverlay from './GuiOverlay'; @@ -3327,13 +3327,40 @@ const KubikonEditor = () => { language={sc.language || 'js'} flushRef={scriptEditorFlushRef} isSoloRunning={soloScriptId === sc.id} - onLanguageChange={(lang, newCode) => { - sceneRef.current?.upsertScript(sc.id, newCode, undefined, undefined, lang); + onLanguageChange={(lang, currentEditorCode) => { + // Два слота: code_js и code_lua живут в самом скрипте. + // При переключении: сохраняем текущий код в слот ТЕКУЩЕГО + // языка, достаём слот ЦЕЛЕВОГО языка (или шаблон если пусто). + const fromLang = sc.language === 'lua' ? 'lua' : 'js'; + if (fromLang === lang) return; + const fromSlotKey = fromLang === 'lua' ? 'code_lua' : 'code_js'; + const toSlotKey = lang === 'lua' ? 'code_lua' : 'code_js'; + // Сохраняем текущий редактируемый код в слот текущего языка + const savedSlots = { + ...(sc.code_js !== undefined ? { code_js: sc.code_js } : {}), + ...(sc.code_lua !== undefined ? { code_lua: sc.code_lua } : {}), + [fromSlotKey]: currentEditorCode || '', + }; + // Достаём слот целевого языка или подставляем шаблон + let nextCode = savedSlots[toSlotKey]; + if (nextCode === undefined || nextCode === '') { + nextCode = lang === 'lua' + ? (sc.target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL) + : JS_TEMPLATE_GLOBAL; + } + sceneRef.current?.upsertScript( + sc.id, nextCode, undefined, undefined, lang, savedSlots + ); setScriptsList(sceneRef.current?.getScripts?.() || []); markDirty(); }} onSave={(code) => { - sceneRef.current?.upsertScript(sc.id, code, sc.target); + // Зеркалим в слот активного языка чтобы при swap не потерять. + const slotKey = (sc.language === 'lua') ? 'code_lua' : 'code_js'; + sceneRef.current?.upsertScript( + sc.id, code, sc.target, undefined, undefined, + { [slotKey]: code } + ); setScriptsList(sceneRef.current?.getScripts?.() || []); markDirty(); }} diff --git a/src/editor/ScriptEditor.jsx b/src/editor/ScriptEditor.jsx index d26f4a5..83da9de 100644 --- a/src/editor/ScriptEditor.jsx +++ b/src/editor/ScriptEditor.jsx @@ -38,21 +38,21 @@ import ConfirmModal from './ConfirmModal'; // командой `python _build_bundle.py` в той же папке. // Дефолтный шаблон Lua-скрипта для нового скрипта (на Part или глобальный). // Используется при смене языка JS→Lua когда текущий код выглядит «пустым». -const LUA_TEMPLATE_PART = `-- Скрипт привязан к Part. script.Parent = эта часть. +export 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-скрипт. Работает на стороне сервера/клиента. +export 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 в /справочник. +export const JS_TEMPLATE_GLOBAL = `// Глобальный JS-скрипт. Подробнее: см. game.* API в /справочник. game.onPlayerJoined((player) => { game.chat.say('Привет, ' + player.name + '!'); }); @@ -115,6 +115,15 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); + // При смене языка — принудительно синхронизируем код со слотом нового языка. + // (родитель swap'нул code_js ↔ code_lua и прислал свежий value.) + useEffect(() => { + if (value !== undefined && value !== localCodeRef.current) { + setLocalCode(value || ''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [language]); + // Дебаунс-сохранение const scheduleSave = useCallback((code) => { if (debounceRef.current) clearTimeout(debounceRef.current); @@ -340,16 +349,9 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe onClick={() => { if (active) return; if (!onLanguageChange) return; - if (isCodeLikelyEmptyTemplate(localCode)) { - const nextCode = lang === 'lua' - ? (target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL) - : JS_TEMPLATE_GLOBAL; - setLocalCode(nextCode); - onLanguageChange(lang, nextCode); - return; - } - // Код не пустой — переключаем сразу, без модалки. - // Код остаётся как есть, только подсветка синтаксиса меняется. + // Логика двух слотов (code_js / code_lua) живёт в родителе. + // Здесь только сигналим: «переключи на lang». + // Текущий код отдаём чтобы родитель сохранил в слот. onLanguageChange(lang, localCodeRef.current); }} style={{ diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 3a7eb98..a7dc045 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -6734,7 +6734,7 @@ export class BabylonScene { } /** Установить код одного скрипта по id. Если id нет — создать новый. */ - upsertScript(id, code, target = undefined, name = undefined, language = undefined) { + upsertScript(id, code, target = undefined, name = undefined, language = undefined, slots = undefined) { const i = this._scripts.findIndex(s => s.id === id); if (i >= 0) { this._scripts[i] = { @@ -6743,6 +6743,10 @@ export class BabylonScene { ...(target !== undefined ? { target } : {}), ...(name !== undefined ? { name } : {}), ...(language !== undefined ? { language } : {}), + // Слоты code_js и code_lua — сохраняемый код для каждого языка. + // Передаются при переключении языка, чтобы код другого языка + // не пропадал. + ...(slots && typeof slots === 'object' ? slots : {}), }; } else { this._scripts.push({ @@ -6751,6 +6755,7 @@ export class BabylonScene { target: target !== undefined ? target : null, name: name || null, language: language || 'js', + ...(slots && typeof slots === 'object' ? slots : {}), }); } // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит -- 2.47.2 From 0805da07086e3597b53007b02da5f187f35955b7 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 02:09:55 +0300 Subject: [PATCH 065/214] =?UTF-8?q?fix(lua):=20PlayerAdded=20=D1=84=D0=B5?= =?UTF-8?q?=D0=B9=D1=80=D0=B8=D1=82=D1=81=D1=8F=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=83=D0=B6=D0=B5=20=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D1=83=D1=8E=D1=89=D0=B8=D1=85=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roblox-конвенция: Players.PlayerAdded не срабатывает для игроков уже на сервере к моменту подключения хендлера. Юзер пишет: Players.PlayerAdded:Connect(function(p) print(p.Name) end) и удивляется почему лог пустой — игрок-то уже есть. В реальном Roblox делают: for _, p in ipairs(Players:GetPlayers()) do print(p.Name) end Players.PlayerAdded:Connect(...) Но мало кто помнит про этот workaround. Решение: после kickoff всех скриптов (когда все Connect'ы установлены) из LuaSharedSandbox шлём через api.fireExistingPlayers() → PlayerAdded.Fire(localPlayer) + CharacterAdded.Fire(character). Также: - Добавлены localPlayer.CharacterAdded/CharacterRemoving/AppearanceLoaded signals (раньше не было). - Шаблон LUA_TEMPLATE_GLOBAL обновлён: для всех Players:GetPlayers() делаем print, плюс PlayerAdded:Connect для будущих. Юзер видит результат сразу при первом Запустить. - Шаблон LUA_TEMPLATE_PART сразу пишет 'Скрипт детали X запущен'. --- src/editor/ScriptEditor.jsx | 10 ++++++++-- src/editor/engine/lua/LuaSharedSandbox.js | 15 +++++++++++++++ src/editor/engine/lua/RobloxShim.js | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/editor/ScriptEditor.jsx b/src/editor/ScriptEditor.jsx index 83da9de..cc7adec 100644 --- a/src/editor/ScriptEditor.jsx +++ b/src/editor/ScriptEditor.jsx @@ -40,16 +40,22 @@ import ConfirmModal from './ConfirmModal'; // Используется при смене языка JS→Lua когда текущий код выглядит «пустым». export const LUA_TEMPLATE_PART = `-- Скрипт привязан к Part. script.Parent = эта часть. local part = script.Parent +print("Скрипт детали", part.Name, "запущен") part.Touched:Connect(function(hit) print("Касание:", hit.Name) end) `; -export const LUA_TEMPLATE_GLOBAL = `-- Глобальный Lua-скрипт. Работает на стороне сервера/клиента. +export const LUA_TEMPLATE_GLOBAL = `-- Глобальный Lua-скрипт. Доступ к game.* API через Roblox-обёртку. local Players = game:GetService("Players") +print("Привет, Рублокс! Lua-скрипты работают.") +-- Здороваемся со всеми кто уже в игре + кто заходит позже +for _, player in ipairs(Players:GetPlayers()) do + print("Игрок в игре:", player.Name) +end Players.PlayerAdded:Connect(function(player) - print("Игрок зашёл:", player.Name) + print("Зашёл игрок:", player.Name) end) `; export const JS_TEMPLATE_GLOBAL = `// Глобальный JS-скрипт. Подробнее: см. game.* API в /справочник. diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index 7883bbd..c06a72d 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -134,6 +134,21 @@ export class LuaSharedSandbox { } else { // eslint-disable-next-line no-console console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`); + // После того как все скрипты подключили хендлеры — фейрим + // events для уже существующих сущностей. Roblox-конвенция: + // если игрок уже на сервере когда скрипт подключается, + // Players.PlayerAdded не сработает повторно. Юзеру нужно + // делать ручной обход GetPlayers() — но это редко кто помнит. + // Мы дублируем событие через короткую задержку. + setTimeout(() => { + try { + if (this.api?.fireExistingPlayers) { + this.api.fireExistingPlayers(); + } + } catch (e) { + console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e); + } + }, 100); } }; setTimeout(initBatch, 0); diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 5cc004c..9663929 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -828,6 +828,9 @@ export function registerRobloxShim(lua, opts) { character.Parent = localPlayer; localPlayer.Children.push(character); localPlayer.Character = character; + localPlayer.CharacterAdded = makeSignal(); + localPlayer.CharacterRemoving = makeSignal(); + localPlayer.CharacterAppearanceLoaded = makeSignal(); const humanoid = newInstance('Humanoid', 'Humanoid'); humanoid.Parent = character; @@ -1730,6 +1733,23 @@ export function registerRobloxShim(lua, opts) { onGuiSnapshot() {}, onDataSnapshot() {}, + /** Фейр PlayerAdded для уже существующих игроков после того как + * скрипты успели подключить хендлеры. Roblox-конвенция: + * Players.PlayerAdded не срабатывает для игроков уже на сервере. + * Мы дублируем чтобы простые скрипты вроде + * Players.PlayerAdded:Connect(...) работали из коробки. */ + fireExistingPlayers() { + try { + if (players?.PlayerAdded?.Fire) { + players.PlayerAdded.Fire(localPlayer); + } + // CharacterAdded — то же самое + if (localPlayer?.CharacterAdded?.Fire && character) { + localPlayer.CharacterAdded.Fire(character); + } + } catch (_) {} + }, + tickScheduler(_dt) { // 0a. Lua-handlers из очереди (signal.Fire отложил их сюда). // Запускаем каждый в своей coroutine — wait() внутри безопасен. -- 2.47.2 From d019da0ab6be862158ba1569ca7e080313c6bea3 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 02:25:24 +0300 Subject: [PATCH 066/214] =?UTF-8?q?feat(wiki):=20=D0=B8=D0=BD=D1=84=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B0=20?= =?UTF-8?q?JS/Lua=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=BA=20=D0=B2=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что сделано: 1. docsLang.jsx (НОВЫЙ): - DocsLangProvider — Context для выбранного языка (localStorage). - DocsLangPicker — большой переключатель JS/Lua над разделом. - — локальные вкладки внутри статьи: показывает контент текущего языка. - useDocsLang() хук. - Стили для picker / tabs / langChoiceModal / docTable. 2. docsData.jsx: - Новая статья D0 "Скриптинг: JS или Lua — что выбрать?" в самом верху раздела D. Сравнение, примеры одного и того же кода на двух языках, советы новичкам. - Импорт LangTabs. 3. KubikonDocs.jsx: - ChapterPage обёрнут в DocsLangProvider + DocsLangPicker сверху. Юзер может одним кликом переключить весь раздел JS↔Lua. - LessonPage: при «Открыть мою копию» теперь показывается модалка LangChoiceModal (JS / Lua). Создаём копию с нужными скриптами. - convertProjectScriptsToLua() конвертит project_data: если в скрипте есть code_lua слот — активируем. Иначе ставим stub с подсказкой. 4. docsGamesBuilders.js: - buildGameProject(id, opts) принимает opts.lang='lua'. Та же логика — code_lua или stub. ОСТАЛОСЬ (постепенно): - Lua-эквиваленты в существующих 78 статьях. Сейчас Picker уже показывается, но если в статье нет — контент одинаковый. Будем добавлять в ключевые места по очереди. - Lua-версии в GAME_BUILDERS для уроков 1-50 (code_lua слот). --- src/community/KubikonDocs.jsx | 123 +++++++++-- src/community/docsData.jsx | 114 ++++++++++ src/community/docsGamesBuilders.js | 35 +++- src/community/docsLang.jsx | 324 +++++++++++++++++++++++++++++ 4 files changed, 577 insertions(+), 19 deletions(-) create mode 100644 src/community/docsLang.jsx diff --git a/src/community/KubikonDocs.jsx b/src/community/KubikonDocs.jsx index 8f386d4..a424cb7 100644 --- a/src/community/KubikonDocs.jsx +++ b/src/community/KubikonDocs.jsx @@ -13,6 +13,7 @@ import { GAMES, GAME_GROUPS } from './docsGames'; import { LESSONS, hasLesson } from './docsLessons'; import { buildGameProject } from './docsGamesBuilders'; import DocIcon from './docsIcons'; +import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang'; /** * KubikonDocs — вика редактора Рублокс. @@ -76,6 +77,7 @@ const KubikonDocs = () => { return (
+ {/* === Левая боковая панель === */}
+ {state === 'choosing' && ( + createCopyWithLang(lang)} + onCancel={() => setState('idle')} + /> + )} {state === 'error' && (
Не получилось открыть игру. Проверь, что ты вошёл в аккаунт, @@ -492,6 +508,81 @@ const LessonPage = ({ game, navigate }) => { ); }; +// ══════════════════════════════════════════════════════════════════ +// Модалка выбора языка скриптов при «Открыть копию» +// ══════════════════════════════════════════════════════════════════ +const LangChoiceModal = ({ onPick, onCancel }) => { + return ( +
+
e.stopPropagation()}> +

На каком языке открыть копию?

+

+ Скрипты в твоей копии будут написаны на выбранном языке. + Логика игры одинаковая — отличается только запись кода. +

+
+ + +
+ +
+
+ ); +}; + +/** + * Конвертирует все JS-скрипты в project_data в Lua-эквивалент. + * Сейчас простая стратегия: если в скрипте есть code_lua слот, делает его + * активным. Иначе ставит флаг language='lua' и пустой Lua-шаблон с TODO. + * Полноценная транспиляция JS→Lua невозможна без AST-анализа. + */ +function convertProjectScriptsToLua(projectData) { + const scene = projectData?.scene; + if (!scene || !Array.isArray(scene.scripts)) return projectData; + scene.scripts = scene.scripts.map(s => { + if (s.language === 'lua') return s; + // Если уже есть готовый Lua-слот — используем его + if (s.code_lua && s.code_lua.trim()) { + return { + ...s, + language: 'lua', + code: s.code_lua, + code_js: s.code_js || s.code, + code_lua: s.code_lua, + }; + } + // Иначе ставим заглушку с подсказкой + const luaStub = `-- TODO: версия этого скрипта на Lua пока не готова. +-- Оригинальный JS-код сохранён ниже (переключи язык назад на JS в редакторе). +-- Доступные API: game:GetService("Players"), game.Workspace, script.Parent +-- +-- Например, простой пример: +local Players = game:GetService("Players") +print("Привет от Lua-скрипта") +`; + return { + ...s, + language: 'lua', + code: luaStub, + code_js: s.code_js || s.code, + code_lua: luaStub, + }; + }); + return projectData; +} + // ══════════════════════════════════════════════════════════════════ // Инлайн-стили // ══════════════════════════════════════════════════════════════════ diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index ae4fcc7..e802e4c 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -1,5 +1,6 @@ import React from 'react'; import DocIcon from './docsIcons'; +import { LangTabs } from './docsLang'; /** * docsData.jsx — контент вики редактора Рублокс (разделы A-J). @@ -1122,6 +1123,119 @@ game.gui.onSubmit(boxId, (text) => { title: 'Скрипты — основы', summary: 'Самый важный раздел: что такое скрипт, переменные, события, таймеры.', sections: [ + { + id: 'js-or-lua', + title: 'D0. Скриптинг: JS или Lua — что выбрать?', + body: ( + <> +

+ В Рублоксе можно писать скрипты на двух языках: + JavaScript и Lua. Оба работают одинаково + хорошо. Игра не отличает их между собой — внутри одного + проекта одни скрипты могут быть на JS, другие на Lua, + и они общаются между собой как будто это один язык. +

+

Чем они отличаются?

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
JavaScriptLua
Где ещё используетсяСайты, мобильные приложения, серверы. + Самый популярный язык в мире.Roblox, World of Warcraft (моды), + многие игры. Простой и быстрый.
Главный APIgame.* (game.player, + game.log, game.scene)game.* в Roblox-стиле + (game:GetService("Players"), workspace)
Похож наRoblox-LUA если знаешь RobloxRoblox-Studio — те же команды
Когда выбратьЕсли планируешь делать сайты и приложения + — JS пригодится везде.Если играешь в Roblox и видел там скрипты + — Lua тебе знаком.
+ +

Один и тот же пример на двух языках:

+

Когда игрок касается синего блока — печатаем «Привет».

+ {`// JS — глобальный скрипт +game.onTouch('синий-блок', (player) => { + game.log('Привет, ' + player.name + '!'); +});`}} + lua={{`-- Lua — скрипт на самом блоке +local part = script.Parent + +part.Touched:Connect(function(hit) + local player = game.Players:GetPlayerFromCharacter(hit.Parent) + if player then + print("Привет, " .. player.Name .. "!") + end +end)`}} + /> +

+ Видишь — оба варианта делают одно и то же. Но + запись отличается. JS короче для простых вещей через + game.onTouch, Lua даёт точный Roblox-стиль + через :Connect и события. +

+ +

Что выбирать новичку?

+ + Если ты совсем новичок — бери JavaScript. + Команды game.* в JS короче и проще читать. + Большая часть уроков в этой вике написана с примерами + на JS — все они работают, просто копируй. + + + Если ты уже играл в Roblox и видел там скрипты + — бери Lua. Команды почти один в один как + в Roblox Studio: game:GetService, + :Connect, workspace, + script.Parent. + + +

Можно ли менять язык в одном скрипте?

+

+ Да. В редакторе скрипта вверху есть две кнопки — + JS и Lua. Просто нажми на нужную. + Твой код на текущем языке сохранится, а + на другом языке откроется пустой шаблон или то, что + ты писал там раньше. Никогда ничего не теряется. +

+ + Создай новый скрипт. Напиши пару строк на JS. Нажми + кнопку Lua — JS-код спрячется, появится + Lua-шаблон. Напиши там что-то. Нажми JS + обратно — твой JS-код вернётся. Магия! + + +

А что под капотом?

+

+ JS-скрипты исполняются в WebWorker — + это отдельный поток в браузере, чтобы скрипт не + тормозил саму игру. Lua-скрипты исполняются через + wasmoon — Lua-интерпретатор, скомпилированный + в WebAssembly. Оба варианта работают на любом + устройстве без установки. +

+ + ), + }, { id: 'what-is-script', title: 'D1. Что такое скрипт и как его создать', diff --git a/src/community/docsGamesBuilders.js b/src/community/docsGamesBuilders.js index 1e92045..77b2da3 100644 --- a/src/community/docsGamesBuilders.js +++ b/src/community/docsGamesBuilders.js @@ -6158,8 +6158,37 @@ export function hasGameBuilder(id) { return typeof GAME_BUILDERS[id] === 'function'; } -/** Построить project_data для игры-урока. Возвращает объект или null. */ -export function buildGameProject(id) { +/** Построить project_data для игры-урока. Возвращает объект или null. + * opts.lang: 'js' (default) | 'lua' — на каком языке скрипты в копии. + */ +export function buildGameProject(id, opts = {}) { const fn = GAME_BUILDERS[id]; - return fn ? fn() : null; + if (!fn) return null; + const project = fn(); + if (opts.lang === 'lua' && project) { + // Если в скрипте есть code_lua слот — делаем его активным. + // Иначе ставим stub с заметкой что Lua-версия в работе. + const scene = project.scene || {}; + if (Array.isArray(scene.scripts)) { + scene.scripts = scene.scripts.map(s => { + if (s.language === 'lua') return s; + if (s.code_lua && s.code_lua.trim()) { + return { ...s, language: 'lua', code: s.code_lua, code_js: s.code_js || s.code }; + } + const luaStub = `-- TODO: Lua-версия этого скрипта пока не готова. +-- Переключи язык на JS в редакторе (кнопка JS вверху), чтобы увидеть рабочий код. +-- Lua-API: game:GetService("Players"), workspace, script.Parent +print("Lua-скрипт запущен (заглушка)") +`; + return { + ...s, + language: 'lua', + code: luaStub, + code_js: s.code_js || s.code, + code_lua: luaStub, + }; + }); + } + } + return project; } diff --git a/src/community/docsLang.jsx b/src/community/docsLang.jsx new file mode 100644 index 0000000..55df3ec --- /dev/null +++ b/src/community/docsLang.jsx @@ -0,0 +1,324 @@ +/** + * docsLang.jsx — поддержка вкладок JS/Lua в статьях вики. + * + * Компоненты: + * — оборачивает страницу статьи, хранит выбранный язык + * в localStorage 'rublox.docs.lang' ('js' | 'lua'). + * — большой переключатель JS/Lua над статьёй. + * — вкладка-переключатель внутри статьи. Показывает + * либо js, либо lua, согласно текущему языку. + * useDocsLang() — хук: возвращает {lang, setLang}. + * + * Если в статье нет ни одного — она одинаково выглядит на обоих + * языках (общая теория, не зависящая от языка скриптов). + */ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +const LS_KEY = 'rublox.docs.lang'; +const DEFAULT_LANG = 'js'; + +const DocsLangContext = createContext({ + lang: DEFAULT_LANG, + setLang: () => {}, +}); + +export function DocsLangProvider({ children }) { + const [lang, setLangState] = useState(() => { + try { + const v = localStorage.getItem(LS_KEY); + return v === 'lua' ? 'lua' : 'js'; + } catch (_) { + return DEFAULT_LANG; + } + }); + const setLang = (next) => { + const v = next === 'lua' ? 'lua' : 'js'; + setLangState(v); + try { localStorage.setItem(LS_KEY, v); } catch (_) {} + }; + useEffect(() => { + // Слушаем смену из других вкладок + const onStorage = (e) => { + if (e.key === LS_KEY && (e.newValue === 'js' || e.newValue === 'lua')) { + setLangState(e.newValue); + } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + return ( + + {children} + + ); +} + +export function useDocsLang() { + return useContext(DocsLangContext); +} + +/** Большой переключатель над статьёй: «На каком языке смотреть код?» */ +export function DocsLangPicker() { + const { lang, setLang } = useDocsLang(); + return ( +
+
+ Язык скриптов в этой статье: +
+
+ + +
+
+ Не знаешь что выбрать? Смотри статью D0. Скриптинг: JS или Lua? +
+
+ ); +} + +/** + * Локальный переключатель вкладок внутри статьи. Если js/lua — + * прямой контент (children), если на странице нет — + * показываем оба заголовками. + * + * Использование: + * game.log('Привет')} + * lua={print('Привет')} + * /> + */ +export function LangTabs({ js, lua }) { + const { lang, setLang } = useDocsLang(); + const hasJs = js !== undefined && js !== null; + const hasLua = lua !== undefined && lua !== null; + if (!hasJs && !hasLua) return null; + // Если есть только один язык — показываем без переключателя + if (hasJs && !hasLua) return <>{js}; + if (!hasJs && hasLua) return <>{lua}; + return ( +
+
+ + +
+
+ {lang === 'lua' ? lua : js} +
+
+ ); +} + +export const DOCS_LANG_STYLES = ` +.docsLangPicker { + background: linear-gradient(135deg, #1a1d2e 0%, #14172b 100%); + border: 1px solid #2a3050; + border-radius: 10px; + padding: 14px 18px; + margin: 16px 0 24px; + display: flex; + flex-direction: column; + gap: 10px; +} +.docsLangPicker__label { + font-size: 13px; + font-weight: 600; + color: #c8cce0; +} +.docsLangPicker__tabs { + display: flex; + gap: 8px; +} +.docsLangPicker__tab { + flex: 1; + padding: 10px 16px; + border-radius: 6px; + border: 1px solid transparent; + background: #232842; + color: #aab0c8; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; +} +.docsLangPicker__tab:hover { background: #2a304f; color: #fff; } +.docsLangPicker__tab--js.is-active { + background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%); + color: #1a1a1c; + border-color: #d4b500; +} +.docsLangPicker__tab--lua.is-active { + background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%); + color: #fff; + border-color: #1565c0; +} +.docsLangPicker__hint { + font-size: 12px; + color: #8a90a8; + font-style: italic; +} + +.docsLangTabs { + margin: 12px 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid #2a3050; +} +.docsLangTabs__head { + display: flex; + background: #181b2c; + border-bottom: 1px solid #2a3050; +} +.docsLangTabs__tab { + padding: 8px 18px; + border: none; + background: transparent; + color: #888da6; + font-size: 12px; + font-weight: 700; + cursor: pointer; + letter-spacing: 0.5px; + border-bottom: 2px solid transparent; +} +.docsLangTabs__tab:hover { color: #c8cce0; } +.docsLangTabs__tab.is-active { + color: #fff; + border-bottom-color: #4a8bff; + background: #1f2338; +} +.docsLangTabs__body { + padding: 0; +} +.docsLangTabs__body > pre, +.docsLangTabs__body > .docCode { margin: 0; border-radius: 0; } + +.docTable { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 13px; +} +.docTable th, .docTable td { + border: 1px solid #2a3050; + padding: 8px 10px; + text-align: left; + vertical-align: top; +} +.docTable th { + background: #1a1d2e; + color: #c8cce0; + font-weight: 600; +} +.docTable td:first-child { + background: #181b2c; + width: 25%; + color: #aab0c8; +} +.docTable code { + background: #0e1020; + padding: 1px 5px; + border-radius: 3px; + font-size: 12px; +} + +.langChoiceOverlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.75); + display: flex; align-items: center; justify-content: center; + z-index: 10000; +} +.langChoiceDialog { + background: #1a1d2e; + border: 1px solid #2a3050; + border-radius: 14px; + padding: 28px; + width: 100%; + max-width: 520px; + box-shadow: 0 20px 60px rgba(0,0,0,0.6); +} +.langChoiceTitle { + font-size: 20px; + margin: 0 0 8px; + color: #fff; +} +.langChoiceSub { + margin: 0 0 20px; + font-size: 13px; + color: #aab0c8; + line-height: 1.5; +} +.langChoiceBtns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 14px; +} +.langChoiceBtn { + padding: 18px 16px; + border-radius: 10px; + border: 2px solid transparent; + text-align: left; + cursor: pointer; + transition: all 0.15s; + color: #fff; +} +.langChoiceBtn--js { + background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%); + color: #1a1a1c; +} +.langChoiceBtn--js:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(247,223,30,0.3); } +.langChoiceBtn--lua { + background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%); +} +.langChoiceBtn--lua:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(33,150,243,0.4); } +.langChoiceBtn__name { + font-size: 18px; + font-weight: 800; + margin-bottom: 4px; +} +.langChoiceBtn__hint { + font-size: 12px; + font-weight: 400; + opacity: 0.85; +} +.langChoiceCancel { + width: 100%; + padding: 10px; + background: transparent; + border: 1px solid #2a3050; + color: #aab0c8; + border-radius: 8px; + font-size: 13px; + cursor: pointer; +} +.langChoiceCancel:hover { background: #232842; color: #fff; } +`; -- 2.47.2 From 22881f517699ac737877e4b228fc4b195cf3a4d6 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 02:37:00 +0300 Subject: [PATCH 067/214] =?UTF-8?q?feat(wiki):=20LangTabs=20=D0=B2=D0=BE?= =?UTF-8?q?=20=D0=92=D0=A1=D0=95=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D0=B8=20?= =?UTF-8?q?D1-D8=20"=D0=A1=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20=E2=80=94?= =?UTF-8?q?=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D1=8B"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переключатель JS/Lua теперь реально влияет на содержимое в каждой из 8 статей раздела. Для каждой темы дан рабочий код на обоих языках: - D1 Что такое скрипт: game.log vs print - D2 Глобальный/на объекте: game.self vs script.Parent - D3 Переменные: let vs local - D4 game vs game:GetService/workspace - D5 game.log vs print - D6 События: game.onTick/onClick vs RunService.Heartbeat / ClickDetector - D7 if/else: === vs ==, !== vs ~=, then/end - D8 Таймеры: game.after/every/cancel vs task.delay/wait/spawn Также пояснительные плашки подобраны под язык — указания специфичные для синтаксиса каждого языка. --- src/community/docsData.jsx | 503 +++++++++++++++++++++++++++---------- 1 file changed, 368 insertions(+), 135 deletions(-) diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index e802e4c..f51c53c 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -1248,9 +1248,11 @@ end)`}} нужен скрипт.

- Скрипты пишут на языке JavaScript — одном из самых - популярных языков в мире. Не пугайся: начнём с простого, - а редактор подсказывает команды по ходу набора. + Скрипты пишут на одном из двух языков: JavaScript + или Lua. Оба работают одинаково. Подробнее про + выбор языка — статья D0 выше. В этом уроке покажем + пример на обоих языках — переключай вкладки, чтобы видеть + нужный.

Как создать первый скрипт:

@@ -1260,9 +1262,13 @@ end)`}} - Откроется окно кода. Напиши в нём одну строку: + Откроется окно кода. Вверху выбери язык (кнопки JS/Lua) + и напиши одну строку: - {`game.log('Привет! Игра запустилась.');`} + {`game.log('Привет! Игра запустилась.');`}} + lua={{`print("Привет! Игра запустилась.")`}} + /> @@ -1270,15 +1276,23 @@ end)`}} открой Консоль — там появится твоё сообщение.

- Это твой первый работающий скрипт. Команда - game.log(...) печатает сообщение в консоль. + Это твой первый работающий скрипт.

- - Каждая команда заканчивается точкой с запятой - ; — как точка в конце предложения. Текст - пишут в кавычках: 'привет'. Забыл кавычки - или точку с запятой — будет ошибка. - + + Команда game.log(...) печатает в консоль. + Каждая команда в JavaScript заканчивается точкой с запятой + ; — как точка в предложении. Текст пишут + в кавычках: 'привет'. +
} + lua={ + Команда print(...) печатает в консоль. + В Lua точку с запятой ставить не нужно. + Текст пишут в кавычках: "привет" или + 'привет'. Lua использует .. + (две точки) для склейки текста: "А=" .. 5. + } + /> ), }, @@ -1303,22 +1317,45 @@ end)`}}

Скрипт на объекте относится к конкретному кубу, модели или кнопке. Внутри такого скрипта работает волшебное слово - game.self — это и есть тот объект, на котором - висит скрипт. Через него ловят клик по объекту или касание - игроком. + (своё для каждого языка):

+ + {`// JS: game.self — это и есть тот объект, на котором висит скрипт +game.self.onClick(() => { + game.log('Кликнули по мне!'); +});`} + } + lua={<> + {`-- Lua: script.Parent — это и есть тот объект, на котором висит скрипт +local part = script.Parent + +part.Touched:Connect(function(hit) + print("Касание объекта " .. part.Name) +end)`} + } + />

Как привязать скрипт к объекту: выдели объект на сцене, потом создай скрипт — он автоматически привяжется к выделенному объекту. Или укажи носителя в настройках скрипта.

- - Простое правило: если в коде урока есть - game.self — это скрипт на объекте. - Если game.self нет — скрипт глобальный. - Плашка в начале каждого урока всегда подскажет. - + + Простое правило: если в коде урока есть + game.self — это скрипт на объекте. + Если game.self нет — скрипт глобальный. + Плашка в начале каждого урока всегда подскажет. + } + lua={ + Простое правило: если в коде есть + script.Parent — это скрипт на объекте. + Если только game — глобальный. + В Lua глобальные скрипты обычно работают со списком + игроков: game:GetService("Players"). + } + /> ), }, @@ -1332,29 +1369,61 @@ end)`}} скрипт хранит значение. Например, количество очков, имя игрока, выбранный уровень.

- {`// Создаём переменную и кладём в неё число + {`// JS: создаём переменную через let let score = 0; // Меняем значение score = score + 10; // теперь в score лежит 10 score = score + 5; // теперь 15 -game.log('Очков:', score); // напечатает: Очков: 15`} -

- let — это слово «создать переменную». Пишут - его только один раз, когда коробочку заводят. Дальше - меняют значение уже без let. -

+game.log('Очков:', score); // напечатает: Очков: 15`}} + lua={{`-- Lua: создаём переменную через local +local score = 0 + +-- Меняем значение +score = score + 10 -- теперь в score лежит 10 +score = score + 5 -- теперь 15 + +print("Очков:", score) -- напечатает: Очков: 15`}} + /> + + let — это слово «создать переменную». Пишут + его только один раз, когда коробочку заводят. Дальше + меняют значение уже без let. +

} + lua={

+ local — это слово «создать переменную внутри + скрипта». Пишут его только один раз. Если опустить + local — переменная станет глобальной (доступна + всем скриптам), это редко нужно. +

} + />

В переменную можно класть не только числа:

- {`let name = 'Герой'; // текст — в кавычках + {`let name = 'Герой'; // текст — в кавычках let isWin = false; // да/нет — true или false -let coinCount = 0; // число — без кавычек`} - - Если значение никогда не меняется — вместо - let можно писать const - («постоянная»). Например, найденную один раз дверь: - const door = game.scene.findOne('Дверь'); - +let coinCount = 0; // число — без кавычек`}} + lua={{`local name = "Герой" -- текст — в кавычках +local isWin = false -- да/нет — true или false +local coinCount = 0 -- число — без кавычек`}} + /> + + Если значение никогда не меняется — вместо + let можно писать const + («постоянная»). Например, найденную один раз дверь: + const door = game.scene.findOne('Дверь'); + } + lua={ + В Lua нет отдельного слова для «не-меняющейся» переменной — + всё через local. Если хочешь явно показать + что значение не меняется, пиши имя ЗАГЛАВНЫМИ: + local DOOR = workspace.Дверь. + Это договорённость, а не правило Lua. + } + /> ), }, @@ -1366,59 +1435,108 @@ let coinCount = 0; // число — без кавычек`}

В каждом скрипте есть одно главное волшебное слово — game. Через него ты управляешь всей игрой. - У game много «отделов»: + Но «отделы» у JS и Lua разные:

- - - - - - - - - - -
game.playerуправление игроком
game.sceneобъекты сцены
game.uiсчётчики и текст на экране
game.guiкнопки и меню
game.soundзвуки
game.physicsлучи, импульсы, взрывы
game.selfобъект-носитель скрипта
+ + + + + + + + + + + +
game.playerуправление игроком
game.sceneобъекты сцены
game.uiсчётчики и текст на экране
game.guiкнопки и меню
game.soundзвуки
game.physicsлучи, импульсы, взрывы
game.selfобъект-носитель скрипта
+

+ Запись через точку читается слева направо. + game.player.teleport(0, 5, 0) читается так: + «у игры, у игрока, выполни телепорт + в точку 0, 5, 0». +

+ } + lua={<> + + + + + + + + + + +
workspace3D-объекты сцены
game:GetService("Players")список игроков
game.Workspaceто же что workspace
script.Parentобъект-носитель скрипта
game:GetService("RunService")каждый кадр (Heartbeat)
game:GetService("UserInputService")клавиши и мышь
game:GetService("TweenService")плавные движения
+

+ Знак : (двоеточие) в Lua — это вызов + метода объекта. game:GetService("Players") + читается так: «у game вызови GetService + и дай ему текст Players». +

+

+ Точка . — это доступ к полю объекта. + workspace.Floor.BrickColor — у workspace + взять Floor, у него взять BrickColor. +

+ } + />

- Запись через точку читается слева направо. - game.player.teleport(0, 5, 0) читается так: - «у игры, у игрока, выполни телепорт - в точку 0, 5, 0». -

-

- Полный список всех команд каждого отдела — в Справочнике - (раздел H). Не нужно его заучивать: при наборе кода - редактор сам показывает подсказки. + Полный список всех команд — в Справочнике (раздел H). Не нужно + его заучивать: при наборе кода редактор сам показывает подсказки.

), }, { id: 'log-console', - title: 'D5. game.log, консоль, отладка', + title: 'D5. log/print, консоль, отладка', body: ( <>

Консоль — окошко в правом нижнем углу редактора. Туда выводятся все сообщения и ошибки скриптов.

-

- Команда game.log(...) печатает в консоль - что угодно. Это главный инструмент отладки — - проверки, что код работает правильно: -

+ + Команда game.log(...) печатает в консоль + что угодно. Это главный инструмент отладки — + проверки, что код работает правильно: +

} + lua={

+ Команда print(...) печатает в консоль + что угодно. Это главный инструмент отладки — + проверки, что код работает правильно: +

} + /> - {`let score = 0; + {`let score = 0; score = score + 10; game.log('Очки сейчас:', score); // Очки сейчас: 10 let pos = game.player.position; -game.log('Игрок стоит в точке:', pos);`} -

- Если игра ведёт себя странно — расставь - game.log по коду и посмотри, какие значения - печатаются. Так ты увидишь, где именно что-то пошло не так. -

+game.log('Игрок стоит в точке:', pos);`}} + lua={{`local score = 0 +score = score + 10 +print("Очки сейчас:", score) -- Очки сейчас: 10 + +local pos = game.Players.LocalPlayer.Character.HumanoidRootPart.Position +print("Игрок стоит в точке:", pos)`}} + /> + + Если игра ведёт себя странно — расставь + game.log по коду и посмотри, какие значения + печатаются. Так ты увидишь, где именно что-то пошло не так. +

} + lua={

+ Если игра ведёт себя странно — расставь + print по коду и посмотри, какие значения + печатаются. Так ты увидишь, где именно что-то пошло не так. +

} + /> Если в скрипте опечатка — текст ошибки появится в Консоли красным, и там же будет написан номер @@ -1429,39 +1547,77 @@ game.log('Игрок стоит в точке:', pos);`} }, { id: 'events', - title: 'D6. События: onTick, onKey, onClick, onTouch', + title: 'D6. События: тик, клавиши, клик, касание', body: ( <>

Событие — это «что-то случилось». Скрипт может ждать событие и реагировать на него. Самые важные:

- - - - - - - -
game.onTick(fn)каждый кадр (60 раз в секунду)
game.onKey('space', fn)игрок нажал клавишу
game.self.onClick(fn)игрок кликнул по объекту
game.self.onTouch(fn)игрок коснулся объекта
+ + + game.onTick(fn)каждый кадр (60 раз в секунду) + game.onKey('space', fn)игрок нажал клавишу + game.self.onClick(fn)игрок кликнул по объекту + game.self.onTouch(fn)игрок коснулся объекта + + } + lua={ + + + + + + +
RunService.Heartbeat:Connect(fn)каждый кадр
UserInputService.InputBegan:Connect(fn)любая клавиша
part.ClickDetector.MouseClick:Connect(fn)клик по объекту
part.Touched:Connect(fn)касание объекта
} + />

Пример — куб, который исчезает по клику:

- {`game.self.onClick(() => { + {`game.self.onClick(() => { game.self.delete(); // удалить сам себя game.log('Куб удалён!'); -});`} -

- Что такое {`() => { ... }`}? Это - «функция» — набор команд, упакованных вместе. Команды - внутри фигурных скобок выполнятся не сразу, а только - когда случится событие. То есть «когда кликнули — тогда - удалить и напечатать». -

- - onTick выполняется ОЧЕНЬ часто — 60 раз - в секунду. Не делай внутри него тяжёлых вещей. Подробнее - об этой ошибке — раздел J4. - +});`}} + lua={{`local part = script.Parent +local clickDetector = Instance.new("ClickDetector") +clickDetector.Parent = part + +clickDetector.MouseClick:Connect(function(player) + part:Destroy() -- удалить сам себя + print("Куб удалён!") +end)`}} + /> + + Что такое {`() => { ... }`}? Это + «функция» — набор команд, упакованных вместе. Команды + внутри фигурных скобок выполнятся не сразу, а только + когда случится событие. То есть «когда кликнули — тогда + удалить и напечатать». +

} + lua={

+ Что такое function() ... end? Это + «функция» — набор команд, упакованных вместе. Команды + между function() и end выполнятся + не сразу, а только когда случится событие. То есть + «когда кликнули — тогда удалить и напечатать». + Метод :Connect «подключает» функцию + к событию. +

} + /> + + onTick выполняется ОЧЕНЬ часто — 60 раз + в секунду. Не делай внутри него тяжёлых вещей. Подробнее + об этой ошибке — раздел J4. +
} + lua={ + Heartbeat выполняется ОЧЕНЬ часто — 60 раз + в секунду. Не делай внутри тяжёлых вычислений. Подробнее + об этой ошибке — раздел J4. + } + /> ), }, @@ -1472,66 +1628,113 @@ game.log('Игрок стоит в точке:', pos);`} <>

Условие — это развилка: «если что-то верно — - сделай одно, иначе — другое». В JavaScript это + сделай одно, иначе — другое». В обоих языках это слова if («если») и else - («иначе»). + («иначе»), но запись чуть отличается.

- {`let coins = 7; + {`let coins = 7; if (coins >= 10) { game.ui.showText('Хватает на покупку!', 2); } else { game.ui.showText('Нужно больше монет', 2); -}`} +}`}} + lua={{`local coins = 7 + +if coins >= 10 then + print("Хватает на покупку!") +else + print("Нужно больше монет") +end`}} + />

Тут проверяется: coins {'>'}= 10 — «монет 10 или больше?». Сейчас монет 7, значит условие неверно, и сработает ветка else.

Знаки сравнения:

- - - - - - - - - -
a === ba равно b
a !== ba не равно b
a {'>'} ba больше b
a {'<'} ba меньше b
a {'>'}= ba больше или равно b
a {'<'}= ba меньше или равно b
- - Для проверки «равно» пишут три знака равенства - ===, а не один. Один знак = — - это «положить значение в переменную», совсем другое - действие. - + + + a === ba равно b + a !== ba не равно b + a {'>'} ba больше b + a {'<'} ba меньше b + a {'>'}= ba больше или равно b + a {'<'}= ba меньше или равно b + + } + lua={ + + + + + + + + +
a == ba равно b
a ~= ba не равно b
a {'>'} ba больше b
a {'<'} ba меньше b
a {'>'}= ba больше или равно b
a {'<'}= ba меньше или равно b
} + /> + + В JS для проверки «равно» пишут три знака равенства + ===, а не один. Один знак = — + это «положить значение в переменную», совсем другое + действие. И не равно — это !==. + } + lua={ + В Lua для проверки «равно» пишут два знака равенства + ==. А «не равно» — это ~= + (тильда + равно). Запомни этот значок — он встречается + только в Lua. + } + /> ), }, { id: 'timers', - title: 'D8. Таймеры: after, every, cancel', + title: 'D8. Таймеры: задержка, повтор, отмена', body: ( <>

Таймеры запускают команды не сразу, а потом:

-
    -
  • - game.after(сек, fn) — выполнить - один раз через несколько секунд; -
  • -
  • - game.every(сек, fn) — выполнять - снова и снова каждые несколько секунд; -
  • -
  • - game.cancel(id) — остановить таймер. -
  • -
+ +
  • + game.after(сек, fn) — выполнить + один раз через несколько секунд; +
  • +
  • + game.every(сек, fn) — выполнять + снова и снова каждые несколько секунд; +
  • +
  • + game.cancel(id) — остановить таймер. +
  • + } + lua={
      +
    • + task.delay(сек, fn) — выполнить + один раз через несколько секунд; +
    • +
    • + task.wait(сек) — приостановить скрипт + на N секунд (внутри цикла или функции); +
    • +
    • + Для повторяющихся таймеров — обычный цикл + while true do task.wait(1); ... end + в отдельной корутине через task.spawn. +
    • +
    } + /> - {`// Через 3 секунды показать текст + {`// Через 3 секунды показать текст game.after(3, () => { game.ui.showText('Игра началась!', 2); }); @@ -1546,12 +1749,42 @@ const ticker = game.every(1, () => { game.after(10, () => { game.cancel(ticker); game.ui.showText('Время вышло!', 2); -});`} -

    - Запись (game.ui.score || 0) читается так: - «возьми счёт, а если его ещё нет — возьми 0». Это защита - от ошибки в самом начале, когда счётчик ещё пустой. -

    +});`}} + lua={{`-- Через 3 секунды показать текст +task.delay(3, function() + print("Игра началась!") +end) + +-- Каждую секунду прибавлять очко. +-- Запускаем в отдельной корутине чтобы не блокировать скрипт. +local score = 0 +local running = true +task.spawn(function() + while running do + task.wait(1) + score = score + 1 + end +end) + +-- Через 10 секунд остановить начисление очков +task.delay(10, function() + running = false + print("Время вышло! Набрано очков:", score) +end)`}} + /> + + Запись (game.ui.score || 0) читается так: + «возьми счёт, а если его ещё нет — возьми 0». Это защита + от ошибки в самом начале, когда счётчик ещё пустой. +

    } + lua={

    + В Lua переменная running — флаг работы цикла. + Когда нужно остановить таймер, ставим running = false, + и цикл сам завершится после task.wait(1). + Это проще, чем хранить номер таймера. +

    } + /> ), }, -- 2.47.2 From c6899c0528b18d4ec5c5b9a608e96747c0b1657a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 02:39:36 +0300 Subject: [PATCH 068/214] =?UTF-8?q?feat(wiki):=20LangTabs=20=D0=B2=D0=BE?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=205=20=D1=81=D1=82=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=20E=20(?= =?UTF-8?q?=D0=94=D0=B2=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E1 Управление игроком: setSpeed-множитель vs Humanoid.WalkSpeed E2 Анимации: playAnimation vs Animator:LoadAnimation E3 Твины: game.tween vs TweenService (TweenInfo + Create + Play) E4 Спавн/удаление: game.scene.spawn vs Instance.new + Destroy/Debris E5 Перемещение: game.scene.move vs .Position/CFrame Lua-примеры стандартного Roblox-стиля: workspace:WaitForChild, Vector3.new, Enum.PartType, BrickColor.new, Debris service. --- src/community/docsData.jsx | 301 +++++++++++++++++++++++++++---------- 1 file changed, 223 insertions(+), 78 deletions(-) diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index f51c53c..6f4ce46 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -1805,23 +1805,40 @@ end)`}} title: 'E1. Управление игроком: скорость, прыжок, гравитация', body: ( <> -

    - Скриптом можно менять, как двигается игрок. Эти команды - принимают множитель: 1 — обычно, 2 — в два раза - сильнее, 0.5 — в два раза слабее. -

    - - - - - - - - -
    setSpeed(mul)скорость бега
    setJumpPower(mul)сила прыжка
    setGravityMul(mul)сила притяжения
    setDoubleJump(true)разрешить двойной прыжок
    teleport(x,y,z)мгновенно переставить
    +

    Скриптом можно менять, как двигается игрок.

    + +

    В JS используем команды-«множители»: 1 — обычно, + 2 — в два раза сильнее, 0.5 — в два раза слабее.

    + + + + + + + + +
    game.player.setSpeed(mul)скорость бега
    game.player.setJumpPower(mul)сила прыжка
    game.player.setGravityMul(mul)сила притяжения
    game.player.setDoubleJump(true)двойной прыжок
    game.player.teleport(x,y,z)мгновенно переставить
    + } + lua={<> +

    В Lua скорость и прыжок — это прямые значения + в Humanoid (не множители). По умолчанию WalkSpeed=16, + JumpPower=50.

    + + + + + + + + +
    humanoid.WalkSpeed = 32скорость (16 = норма)
    humanoid.JumpPower = 80сила прыжка (50 = норма)
    workspace.Gravity = 100гравитация (196 = норма)
    humanoid:ChangeState(Enum.HumanoidStateType.Jumping)прыгнуть
    hrp.CFrame = CFrame.new(x,y,z)телепорт
    + } + />

    Пример — «зелье скорости» при касании сферы:

    - {`game.self.onTouch(() => { + {`game.self.onTouch(() => { // ускоряем игрока в 2 раза game.player.setSpeed(2); game.ui.showText('Скорость x2 на 5 секунд!', 2); @@ -1834,11 +1851,29 @@ end)`}} game.after(5, () => { game.player.setSpeed(1); }); -});`} +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local humanoid = hit.Parent:FindFirstChild("Humanoid") + if not humanoid then return end + + -- ускоряем игрока в 2 раза (16 → 32) + humanoid.WalkSpeed = 32 + print("Скорость x2 на 5 секунд!") + + -- зелье исчезает + part:Destroy() + + -- через 5 секунд скорость обратно норма + task.delay(5, function() + humanoid.WalkSpeed = 16 + end) +end)`}} + /> - Не забывай возвращать скорость обратно командой - setSpeed(1). Иначе игрок останется быстрым - навсегда — а это может сломать твой уровень. + Не забывай возвращать скорость обратно. Иначе игрок + останется быстрым навсегда — а это может сломать твой уровень. ), @@ -1848,21 +1883,47 @@ end)`}} title: 'E2. Анимации-эмоции персонажа', body: ( <> -

    - Персонаж умеет показывать эмоции. Команда - game.player.playAnimation(имя) проигрывает - анимацию: 'wave' (помахать), - 'dance' (танец), 'cheer' - (радость), 'sit' (сесть). -

    +

    Персонаж умеет показывать эмоции.

    + + Команда game.player.playAnimation(имя) проигрывает + анимацию: 'wave' (помахать), + 'dance' (танец), 'cheer' + (радость), 'sit' (сесть). +

    } + lua={

    + В Lua анимации проигрываются через Animator на Humanoid'е. + Roblox-стиль: создать Animation-объект, вызвать + Animator:LoadAnimation(anim), + потом track:Play(). +

    } + /> - {`// При победе персонаж радуется + {`// При победе персонаж радуется game.player.playAnimation('cheer'); // Через 3 секунды перестать game.after(3, () => { game.player.stopAnimation(); -});`} +});`}} + lua={{`local Players = game:GetService("Players") +local player = Players.LocalPlayer +local humanoid = player.Character:WaitForChild("Humanoid") +local animator = humanoid:FindFirstChildOfClass("Animator") + +-- Создаём анимацию (упрощённый шаблон — в реальности нужен AnimationId) +local anim = Instance.new("Animation") +-- anim.AnimationId = "rbxassetid://..." -- свой Animation ID +local track = animator:LoadAnimation(anim) + +track:Play() + +-- Через 3 секунды перестать +task.delay(3, function() + track:Stop() +end)`}} + /> ), }, @@ -1873,13 +1934,14 @@ game.after(3, () => { <>

    Твин — это плавное изменение чего-либо за время. - Если просто переставить объект командой move — - он телепортируется рывком. А твин плавно доедет - из точки в точку. + Если просто переставить объект — он телепортируется рывком. + А твин плавно доедет из точки в точку.

    -

    Команда: game.tween(объект, что менять, настройки)

    - {`// Находим платформу-лифт по имени + +

    Команда: game.tween(объект, что менять, настройки)

    + {`// Находим платформу-лифт по имени const lift = game.scene.findOne('Лифт'); // Платформа за 2 секунды плавно поднимается на высоту 10 @@ -1887,27 +1949,65 @@ game.tween(lift, { y: 10 }, { duration: 2, // длительность в секундах easing: 'ease' // характер движения });`} -

    - Твином можно менять позицию (x, y, z), - поворот, размер, цвет, прозрачность. -

    -

    Полезные настройки твина:

    - - - - - - - - -
    durationсколько секунд длится
    easing'linear' (ровно), 'ease' (плавно), 'bounce' (с отскоком)
    repeatсколько раз повторить
    yoyo: trueдвигаться туда-обратно
    onDoneчто сделать, когда твин закончится
    - {`// Платформа вечно ездит вверх-вниз +

    Полезные настройки твина:

    + + + + + + + + +
    durationсколько секунд длится
    easing'linear' / 'ease' / 'bounce'
    repeatсколько раз повторить
    yoyo: trueдвигаться туда-обратно
    onDoneчто сделать, когда твин закончится
    + {`// Платформа вечно ездит вверх-вниз const plat = game.scene.findOne('Качалка'); game.tween(plat, { y: 8 }, { duration: 2, - yoyo: true, // обратно вниз - repeat: 999 // повторять почти бесконечно + yoyo: true, + repeat: 999 });`} + } + lua={<> +

    В Lua используется TweenService — встроенный + сервис Roblox-стиля. Создаёшь TweenInfo и Tween, вызываешь Play.

    + {`local TweenService = game:GetService("TweenService") + +-- Находим платформу-лифт по имени +local lift = workspace:WaitForChild("Лифт") + +-- Настройка анимации: 2 сек, плавно +local info = TweenInfo.new(2, Enum.EasingStyle.Quad) + +-- Что менять: новая Position (поднимаем на 10 вверх) +local goal = { Position = lift.Position + Vector3.new(0, 10, 0) } + +-- Создаём и запускаем твин +local tween = TweenService:Create(lift, info, goal) +tween:Play()`} +

    Полезные настройки TweenInfo:

    + + + + + + + + + +
    1-й арг — секундысколько длится
    EasingStyleLinear / Quad / Bounce / Elastic
    EasingDirectionIn / Out / InOut
    repeatCountсколько раз повторить (-1 = бесконечно)
    reversestrue = туда-обратно
    tween.Completed:Connectсобытие «закончен»
    + {`-- Платформа вечно ездит вверх-вниз +local plat = workspace:WaitForChild("Качалка") +local info = TweenInfo.new( + 2, -- секунды + Enum.EasingStyle.Quad, -- плавно + Enum.EasingDirection.InOut, + -1, -- бесконечно + true -- туда-обратно +) +local goal = { Position = plat.Position + Vector3.new(0, 8, 0) } +TweenService:Create(plat, info, goal):Play()`} + } + /> ), }, @@ -1918,33 +2018,60 @@ game.tween(plat, { y: 8 }, { <>

    Спавн — создание нового объекта прямо во время игры. - Команда game.scene.spawn(тип, настройки):

    - {`// Создаём золотую монетку-сферу + +

    Команда game.scene.spawn(тип, настройки):

    + {`// Создаём золотую монетку-сферу const coin = game.scene.spawn('primitive:sphere', { x: 5, y: 1, z: 0, // где появится color: '#ffd700' // золотой цвет }); game.log('Создали монетку, её адрес:', coin);`} -

    - Тип бывает 'block:трава', - 'primitive:cube', 'model:tree'. - Команда возвращает ref — это «адрес» объекта, - по которому к нему можно обращаться (двигать, удалять). -

    -

    Удаление объекта:

    - {`// удалить сразу -game.scene.delete(coin); +

    + Тип бывает 'block:трава', + 'primitive:cube', 'model:tree'. + Команда возвращает ref — «адрес» объекта, + по которому к нему можно обращаться. +

    +

    Удаление объекта:

    + {`game.scene.delete(coin); // сразу +game.scene.deleteAfter(coin, 3); // через 3 секунды`} + + Запоминай ref в переменную. Без адреса + ты потом не сможешь объект ни подвинуть, ни удалить. + + } + lua={<> +

    Команда Instance.new("Part") создаёт новый Part:

    + {`-- Создаём золотую монетку-сферу +local coin = Instance.new("Part") +coin.Shape = Enum.PartType.Ball +coin.Size = Vector3.new(1, 1, 1) +coin.Position = Vector3.new(5, 1, 0) +coin.BrickColor = BrickColor.new("Bright yellow") +coin.Anchored = true +coin.Parent = workspace -// удалить через 3 секунды -game.scene.deleteAfter(coin, 3);`} - - Запоминай ref в переменную (let coin - = ...). Без адреса ты потом не сможешь объект - ни подвинуть, ни удалить. - +print("Создали монетку:", coin)`}
    +

    + Чтобы объект появился в игре — обязательно ставь + .Parent = workspace. + Anchored = true — чтобы не падал. +

    +

    Удаление объекта:

    + {`coin:Destroy() -- сразу + +-- через 3 секунды +game:GetService("Debris"):AddItem(coin, 3)`} + + Сохраняй ссылку на объект в переменную (local coin = ...). + Без неё ты потом не сможешь объект ни подвинуть, ни удалить. + + } + /> ), }, @@ -1954,20 +2081,38 @@ game.scene.deleteAfter(coin, 3);`}
    body: ( <>

    Передвинуть объект скриптом можно несколькими способами:

    - - - - - - - -
    game.scene.move(ref,x,y,z)мгновенно переставить
    game.scene.rotate(ref,угол)повернуть
    game.self.move(x,y,z)скрипт двигает сам себя
    game.tween(...)плавное перемещение (E3)
    + + + game.scene.move(ref,x,y,z)мгновенно переставить + game.scene.rotate(ref,угол)повернуть + game.self.move(x,y,z)скрипт двигает сам себя + game.tween(...)плавное перемещение (E3) + + } + lua={ + + + + + + +
    part.Position = Vector3.new(x,y,z)мгновенно переставить
    part.CFrame = part.CFrame * CFrame.Angles(0, math.rad(45), 0)повернуть
    script.Parent.Position = ...скрипт двигает сам себя
    TweenService:Create(...)плавное перемещение (E3)
    } + />

    Пример — дверь уезжает вверх и освобождает проход:

    - {`const door = game.scene.findOne('Дверь'); + {`const door = game.scene.findOne('Дверь'); // плавно поднимаем дверь на 6 единиц вверх -game.tween(door, { y: 6 }, { duration: 1 });`} +game.tween(door, { y: 6 }, { duration: 1 });`}} + lua={{`local TweenService = game:GetService("TweenService") +local door = workspace:WaitForChild("Дверь") + +-- плавно поднимаем дверь на 6 единиц вверх +local goal = { Position = door.Position + Vector3.new(0, 6, 0) } +TweenService:Create(door, TweenInfo.new(1), goal):Play()`}} + /> ), }, -- 2.47.2 From 76fba9cb350b1480f37c52c0dff5b9c5d4678f6a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 02:43:11 +0300 Subject: [PATCH 069/214] =?UTF-8?q?feat(wiki):=20LangTabs=20=D0=B2=D0=BE?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=208=20=D1=81=D1=82=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=20F=20(?= =?UTF-8?q?=D0=98=D0=B3=D1=80=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 HP: damage/heal vs humanoid:TakeDamage / Health F2 Физика: raycast vs workspace:Raycast (полный пример со стрельбой) F3 Атрибуты: setData/getData vs :SetAttribute/:GetAttribute F4 Теги: scene.tag vs CollectionService:AddTag/GetTagged F5 E-взаимодействие: onInteract vs ProximityPrompt F6 Billboard: setLabel vs BillboardGui+TextLabel F7 passThrough: physics.passThrough vs CanCollide=false F8 Связи: constraints.hinge vs HingeConstraint+Attachment Lua-примеры по канонам Roblox: Instance.new, Attachment, Vector3.new, UDim2.new, Color3, BrickColor, math.min. --- src/community/docsData.jsx | 348 ++++++++++++++++++++++++++----------- 1 file changed, 251 insertions(+), 97 deletions(-) diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index 6f4ce46..e8da0db 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -2134,29 +2134,64 @@ TweenService:Create(door, TweenInfo.new(1), goal):Play()`}} body: ( <>

    Команды для здоровья игрока:

    - - - - - - - - - -
    game.player.hpтекущее здоровье (можно читать)
    game.player.damage(n)нанести урон
    game.player.heal(n)вылечить
    game.player.kill()мгновенно убить
    game.player.respawn()воскресить на спавне
    game.player.setSpawn(точка)новая точка возрождения
    + + + game.player.hpтекущее здоровье + game.player.damage(n)нанести урон + game.player.heal(n)вылечить + game.player.kill()мгновенно убить + game.player.respawn()воскресить на спавне + game.player.setSpawn(точка)новая точка возрождения + + } + lua={ + + + + + + + + +
    humanoid.Healthтекущее здоровье
    humanoid:TakeDamage(n)нанести урон
    humanoid.Health = humanoid.Health + nвылечить
    humanoid.Health = 0мгновенно убить
    player:LoadCharacter()воскресить
    player.RespawnLocation = spawnновая точка возрождения
    } + />

    Пример 1 — шипы наносят урон:

    - {`game.self.onTouch(() => { + {`game.self.onTouch(() => { game.player.damage(20); // отнять 20 здоровья game.sound.play('hit'); -});`} +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local humanoid = hit.Parent:FindFirstChild("Humanoid") + if humanoid then + humanoid:TakeDamage(20) -- отнять 20 здоровья + end +end)`}} + />

    Пример 2 — аптечка лечит:

    - {`game.self.onTouch(() => { + {`game.self.onTouch(() => { game.player.heal(50); // добавить 50 здоровья game.ui.showText('+50 HP', 1.5); game.self.delete(); // аптечка исчезает -});`} +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local humanoid = hit.Parent:FindFirstChild("Humanoid") + if not humanoid then return end + + -- добавить 50 здоровья, но не выше MaxHealth + humanoid.Health = math.min(humanoid.Health + 50, humanoid.MaxHealth) + print("+50 HP") + part:Destroy() -- аптечка исчезает +end)`}} + /> ), }, @@ -2165,52 +2200,78 @@ TweenService:Create(door, TweenInfo.new(1), goal):Play()`}} title: 'F2. Физика: raycast, импульсы, взрывы', body: ( <> -

    - Отдел game.physics отвечает за «настоящую» - физику: -

    -
      -
    • - raycast(откуда, куда, опции) — пустить - невидимый луч и узнать, во что он попал. Так делают - стрельбу; -
    • -
    • - applyImpulse(ref, сила) — толкнуть объект - (он должен быть не закреплён); -
    • -
    • - explode(точка, радиус, опции) — взрыв. -
    • -
    + +

    Отдел game.physics отвечает за «настоящую» физику:

    +
      +
    • raycast(откуда, куда, опции) — луч для стрельбы;
    • +
    • applyImpulse(ref, сила) — толкнуть объект;
    • +
    • explode(точка, радиус, опции) — взрыв.
    • +
    + } + lua={<> +

    В Lua для физики используется workspace и стандартный Roblox API:

    +
      +
    • workspace:Raycast(origin, dir, params) — луч;
    • +
    • part:ApplyImpulse(Vector3) — толкнуть Part;
    • +
    • Instance.new("Explosion") — создать взрыв.
    • +
    + } + />

    Пример — стрельба лучом из камеры игрока:

    - {`// При клике мышкой пускаем луч туда, куда смотрит игрок -game.onClick(() => { + {`game.onClick(() => { const p = game.player.position; const hit = game.physics.raycast( { x: p.x, y: p.y + 1.5, z: p.z }, // откуда (от головы) game.player.forward, // куда (взгляд) - { maxDistance: 50 } // как далеко + { maxDistance: 50 } ); if (hit.hit) { game.log('Попал в объект:', hit.ref); game.sound.play('hit'); } -});`} -

    - hit.hit — попал ли луч во что-нибудь - (да/нет). hit.ref — адрес объекта, в который - попали. -

    +});`}} + lua={{`local UIS = game:GetService("UserInputService") +local Players = game:GetService("Players") +local player = Players.LocalPlayer +local mouse = player:GetMouse() + +UIS.InputBegan:Connect(function(input) + if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end + + local hrp = player.Character.HumanoidRootPart + local origin = hrp.Position + Vector3.new(0, 1.5, 0) + local direction = (mouse.Hit.Position - origin).Unit * 50 + + local raycastResult = workspace:Raycast(origin, direction) + if raycastResult then + print("Попал в объект:", raycastResult.Instance.Name) + end +end)`}} + /> + + hit.hit — попал ли луч во что-нибудь. + hit.ref — адрес объекта. +

    } + lua={

    + raycastResult равно nil если + луч ни во что не попал. Иначе у него есть поля + .Instance (что попало), + .Position (точка попадания), + .Normal (нормаль поверхности). +

    } + /> ), }, { id: 'attributes', - title: 'F3. Атрибуты объектов (setData / getData)', + title: 'F3. Атрибуты объектов', body: ( <>

    @@ -2219,14 +2280,27 @@ game.onClick(() => { или сколько монет стоит товар.

    - {`// При старте игры запоминаем цену прямо на товаре + {`// При старте игры запоминаем цену прямо на товаре game.scene.setData(game.self.ref, 'price', 50); // Когда игрок кликает по товару — читаем цену game.self.onClick(() => { const price = game.scene.getData(game.self.ref, 'price'); game.ui.showText('Этот товар стоит ' + price + ' монет', 2); -});`} +});`}} + lua={{`local part = script.Parent + +-- При старте игры запоминаем цену прямо на товаре +part:SetAttribute("Price", 50) + +-- Когда игрок кликает — читаем цену +local clickDetector = Instance.new("ClickDetector", part) +clickDetector.MouseClick:Connect(function(player) + local price = part:GetAttribute("Price") + print("Этот товар стоит " .. price .. " монет") +end)`}} + />

    Чем атрибут лучше обычной переменной? Переменная одна на весь скрипт. А атрибут — свой у каждого объекта. @@ -2242,36 +2316,61 @@ game.self.onClick(() => { body: ( <>

    - Тег — это «ярлык», который можно повесить сразу - на много объектов. Потом одной командой можно найти их все. + Тег — это «ярлык» на объекте. Удобно ставить сразу на + много объектов и потом одной командой находить их все.

    - - - - - - - -
    tag(ref, 'звезда')повесить тег
    untag(ref, 'звезда')снять тег
    hasTag(ref, 'звезда')есть ли тег
    getTagged('звезда')все объекты с тегом
    + + + game.scene.tag(ref, 'звезда')повесить тег + game.scene.untag(ref, 'звезда')снять тег + game.scene.hasTag(ref, 'звезда')есть ли тег + game.scene.getTagged('звезда')все объекты с тегом + + } + lua={ + + + + + + +
    CollectionService:AddTag(part, "звезда")повесить тег
    CollectionService:RemoveTag(part, "звезда")снять тег
    CollectionService:HasTag(part, "звезда")есть ли тег
    CollectionService:GetTagged("звезда")все объекты с тегом
    } + />

    Пример — игра «собери все звёзды»:

    - {`// Этот скрипт висит на звезде. -// При старте помечаем звезду тегом. + {`// Этот скрипт висит на звезде. game.scene.tag(game.self.ref, 'звезда'); -// Когда игрок коснулся — звезда собрана game.self.onTouch(() => { game.self.delete(); game.sound.play('coin'); - // сколько звёзд ещё осталось на сцене? const left = game.scene.getTagged('звезда').length; if (left === 0) { game.ui.showText('Все звёзды собраны! Победа!', 3); } else { game.ui.showText('Осталось звёзд: ' + left, 1.5); } -});`} +});`}} + lua={{`local CS = game:GetService("CollectionService") +local part = script.Parent + +-- Помечаем звезду тегом +CS:AddTag(part, "звезда") + +part.Touched:Connect(function() + part:Destroy() + + local left = #CS:GetTagged("звезда") + if left == 0 then + print("Все звёзды собраны! Победа!") + else + print("Осталось звёзд: " .. left) + end +end)`}} + /> Снятие тега убирает только ярлык. Цвет, размер и другие свойства объекта при этом не меняются. @@ -2281,16 +2380,16 @@ game.self.onTouch(() => { }, { id: 'proximity', - title: 'F5. ProximityPrompt — взаимодействие по клавише E', + title: 'F5. Взаимодействие по клавише E (ProximityPrompt)', body: ( <>

    Часто игра просит «подойди и нажми E»: открыть сундук, - поговорить с торговцем, дёрнуть рычаг. Это делается - командой game.self.onInteract: + поговорить с торговцем, дёрнуть рычаг.

    - {`game.self.onInteract(() => { + {`game.self.onInteract(() => { game.ui.showText('Сундук открыт!', 2); game.scene.spawnParticles('sparks', game.self.position, { duration: 1 }); @@ -2298,11 +2397,26 @@ game.self.onTouch(() => { }, { text: 'Открыть сундук', // подсказка над объектом distance: 4 // на сколько метров подойти -});`} +});`}} + lua={{`local part = script.Parent + +-- ProximityPrompt — стандартный Roblox-способ +local prompt = Instance.new("ProximityPrompt") +prompt.ActionText = "Открыть" +prompt.ObjectText = "Сундук" +prompt.MaxActivationDistance = 4 +prompt.KeyboardKeyCode = Enum.KeyCode.E +prompt.Parent = part + +prompt.Triggered:Connect(function(player) + print("Сундук открыт!") + -- Можно создать эффект частиц или проиграть звук +end)`}} + />

    - Когда игрок подойдёт ближе чем на distance - метров, над объектом появится подсказка с текстом. - Нажатие E запустит функцию. + Когда игрок подойдёт на расстояние взаимодействия, над + объектом появится подсказка с текстом. Нажатие + E запустит функцию.

    ), @@ -2318,36 +2432,62 @@ game.self.onTouch(() => { Так показывают имена врагов, их HP, названия мест.

    - {`// Допустим, npc — это адрес созданного NPC. -// Вешаем над ним табличку с именем. + {`// npc — это адрес созданного NPC. game.scene.setLabel(npc.ref, 'Торговец Боб', { color: '#ffffff', height: 2.5 // на 2.5 метра над объектом }); // Позже можно убрать табличку -game.scene.clearLabel(npc.ref);`} +game.scene.clearLabel(npc.ref);`}} + lua={{`-- BillboardGui в Roblox — это GUI поверх Part +local part = workspace:WaitForChild("NPC") + +local billboard = Instance.new("BillboardGui") +billboard.Size = UDim2.new(4, 0, 1, 0) +billboard.StudsOffset = Vector3.new(0, 2.5, 0) -- над объектом +billboard.Parent = part + +local label = Instance.new("TextLabel") +label.BackgroundTransparency = 1 +label.Size = UDim2.new(1, 0, 1, 0) +label.Text = "Торговец Боб" +label.TextColor3 = Color3.new(1, 1, 1) +label.TextScaled = true +label.Parent = billboard + +-- Позже можно убрать табличку +-- billboard:Destroy()`}} + /> ), }, { id: 'pass-through', - title: 'F7. Проходимость объектов (passThrough)', + title: 'F7. Проходимость объектов', body: ( <>

    Иногда стена должна стать проходимой — призрачная стена, - секретный проход, исчезающий мост. Команда - game.physics.passThrough(ref, true) делает - объект «бесплотным»: видно его, но игрок проходит насквозь. + секретный проход, исчезающий мост.

    - {`// Когда игрок кликнет по стене — она пропустит сквозь себя -game.self.onClick(() => { + {`game.self.onClick(() => { game.physics.passThrough(game.self.ref, true); game.scene.setOpacity(game.self.ref, 0.3); // полупрозрачная game.ui.showText('Секретный проход открыт!', 2); -});`} +});`}} + lua={{`local part = script.Parent +local clickDetector = Instance.new("ClickDetector", part) + +clickDetector.MouseClick:Connect(function(player) + part.CanCollide = false -- игрок проходит насквозь + part.Transparency = 0.7 -- полупрозрачная (0=видна, 1=невидима) + print("Секретный проход открыт!") +end)`}} + /> Если сделать стену снова твёрдой, пока игрок стоит внутри неё — игра аккуратно вытолкнет его наружу, он не застрянет. @@ -2362,39 +2502,53 @@ game.self.onClick(() => { <>

    Связи (constraints) соединяют объекты, чтобы они - двигались вместе или по правилам физики. Отдел — - game.constraints: + двигались вместе или по правилам физики.

      -
    • - Склейка (weld) — намертво приклеивает один - объект к другому; -
    • -
    • - Петля (hinge) — объект вращается вокруг оси, - как дверь на петлях или качели; -
    • -
    • - Пружина (spring) — объект упруго колеблется, - как батут. -
    • +
    • Склейка (weld) — намертво приклеивает один объект к другому;
    • +
    • Петля (hinge) — объект вращается вокруг оси, как дверь или качели;
    • +
    • Пружина (spring) — объект упруго колеблется, как батут.

    Пример — качели на петле:

    - {`const swing = game.scene.findOne('Качели'); + {`const swing = game.scene.findOne('Качели'); -// делаем качели на петле const h = game.constraints.hinge(swing, { - pivotX: 0, pivotZ: 0, // ось вращения - angle: 30 // наклон на 30 градусов + pivotX: 0, pivotZ: 0, + angle: 30 }); // раскачиваем в другую сторону каждую секунду let dir = -30; game.every(1, () => { h.setAngle(dir); - dir = -dir; // меняем знак: 30 → -30 → 30 ... -});`} + dir = -dir; +});`}} + lua={{`-- В Roblox HingeConstraint — стандартный способ +local swing = workspace:WaitForChild("Качели") +local mount = workspace:WaitForChild("Опора") -- неподвижная точка + +-- Attachment'ы (точки крепления) +local a0 = Instance.new("Attachment", mount) +local a1 = Instance.new("Attachment", swing) + +local hinge = Instance.new("HingeConstraint") +hinge.Attachment0 = a0 +hinge.Attachment1 = a1 +hinge.ActuatorType = Enum.ActuatorType.Servo +hinge.ServoMaxTorque = 10000 +hinge.AngularSpeed = 2 +hinge.Parent = swing + +-- Раскачиваем +local dir = 30 +while true do + hinge.TargetAngle = dir + task.wait(1) + dir = -dir +end`}} + /> ), }, -- 2.47.2 From e3bff777b23f504f0787eecded5b36a0247a9be5 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 02:44:29 +0300 Subject: [PATCH 070/214] =?UTF-8?q?feat(wiki):=20LangTabs=20=D0=B2=20C3=20?= =?UTF-8?q?=D0=B8=20C4=20(GUI=20=D0=BE=D0=B6=D0=B8=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5,=20=D0=BF=D0=BE=D0=BB=D0=B5=20=D0=B2=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C3 кнопка: game.gui.onClick vs MouseButton1Click + PlayerGui:FindFirstChild C4 поле ввода: onSubmit vs FocusLost + box.Text C1, C2, C5 — без кода (общая теория), Picker сверху всё равно есть. --- src/community/docsData.jsx | 105 ++++++++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 30 deletions(-) diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index e8da0db..963887f 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -1013,35 +1013,55 @@ game.self.onUntouch(() => { <>

    Нарисованная кнопка сама по себе ничего не делает — - нужен скрипт. Самый простой способ — повесить скрипт - прямо на кнопку. + нужен скрипт.

    - {`// Скрипт висит на кнопке. + {`// Скрипт висит на кнопке. // game.self — это сама кнопка. game.self.onClick(() => { game.ui.showText('Кнопка нажата!', 2); game.sound.play('click'); -});`} -

    - Можно и наоборот — управлять кнопкой из глобального - скрипта, если найти её по имени: -

    +});`}} + lua={{`-- Скрипт висит на кнопке (TextButton) +-- script.Parent — это сама кнопка. +local btn = script.Parent + +btn.MouseButton1Click:Connect(function() + print("Кнопка нажата!") +end)`}} + /> +

    Можно и наоборот — управлять кнопкой из глобального скрипта:

    - {`// Находим кнопку по имени и вешаем на неё клик + {`// Находим кнопку по имени и вешаем на неё клик const btnId = game.gui.find('Кнопка старта'); game.gui.onClick(btnId, () => { game.ui.showText('Игра началась!', 2); - // спрятать кнопку после нажатия - game.gui.hide(btnId); -});`} + game.gui.hide(btnId); // спрятать кнопку после нажатия +});`}} + lua={{`local Players = game:GetService("Players") +local player = Players.LocalPlayer +local gui = player:WaitForChild("PlayerGui") + +-- Находим кнопку по имени (она лежит где-то в PlayerGui) +local btn = gui:FindFirstChild("Кнопка старта", true) + +btn.MouseButton1Click:Connect(function() + print("Игра началась!") + btn.Visible = false -- спрятать кнопку +end)`}} + />

    - Что тут происходит: game.gui.find ищет - элемент по имени и возвращает его id («адрес»). - game.gui.onClick вешает на этот id действие. - game.gui.hide прячет кнопку, чтобы её нельзя - было нажать второй раз. + JS: game.gui.find ищет элемент по имени. + game.gui.onClick вешает действие, game.gui.hide прячет. +

    +

    + Lua: gui:FindFirstChild(name, true) ищет рекурсивно + (третий аргумент true = во вложенных). + MouseButton1Click — стандартный сигнал клика на TextButton. + btn.Visible = false прячет элемент.

    ), @@ -1053,34 +1073,59 @@ game.gui.onClick(btnId, () => { <>

    Поле ввода позволяет игроку напечатать ответ. - Когда он нажмёт Enter, срабатывает событие - onSubmit — и скрипт получает введённый текст. + Когда он нажмёт Enter, скрипт получает введённый текст.

    - {`// Игрок вводит код. Правильный код — 1234. + {`// Игрок вводит код. Правильный код — 1234. const boxId = game.gui.find('Поле кода'); game.gui.onSubmit(boxId, (text) => { if (text === '1234') { game.ui.showText('Верно! Дверь открыта', 2); - // двигаем дверь вверх, чтобы освободить проход const door = game.scene.findOne('Дверь'); game.tween(door, { y: 8 }, { duration: 1 }); } else { game.ui.showText('Неверный код', 1.5); } -});`} +});`}} + lua={{`local Players = game:GetService("Players") +local TweenService = game:GetService("TweenService") +local player = Players.LocalPlayer +local gui = player:WaitForChild("PlayerGui") + +-- Находим TextBox по имени +local box = gui:FindFirstChild("Поле кода", true) + +-- FocusLost срабатывает когда игрок нажал Enter или ушёл с поля. +-- Первый параметр enterPressed = true только если был Enter. +box.FocusLost:Connect(function(enterPressed) + if not enterPressed then return end + + if box.Text == "1234" then + print("Верно! Дверь открыта") + local door = workspace:WaitForChild("Дверь") + local goal = { Position = door.Position + Vector3.new(0, 8, 0) } + TweenService:Create(door, TweenInfo.new(1), goal):Play() + else + print("Неверный код") + end +end)`}} + />

    - Разберём построчно: onSubmit даёт переменную - text — это то, что напечатал игрок. - if (text === '1234') — проверяем, совпал ли - код. Если да — открываем дверь твином (плавно поднимаем). - Если нет — пишем «Неверный код». + JS-разбор: onSubmit даёт переменную + text — то, что напечатал игрок. + if (text === '1234') — проверяем код. +

    +

    + Lua-разбор: на TextBox сигнал FocusLost + срабатывает когда поле теряет фокус (Enter или клик мимо). + Текст лежит в box.Text.

    - Две одинарные кавычки '1234' означают, - что это текст, а не число. Игрок печатает в поле - всегда текст, поэтому и сравнивать нужно с текстом. + Кавычки "1234" означают, что это + текст, а не число. Игрок печатает в поле всегда + текст, поэтому и сравнивать нужно с текстом. ), -- 2.47.2 From f52eb81e69ccb3546d7ce6a963d8368081965227 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 02:50:38 +0300 Subject: [PATCH 071/214] =?UTF-8?q?feat(wiki):=20LangTabs=20=D0=B2=D0=BE?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=2012=20=D1=81=D1=82=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=20G=20(?= =?UTF-8?q?=D0=91=D0=BE=D0=BB=D1=8C=D1=88=D0=B8=D0=B5=20=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G1 NPC: scene.spawnNpc vs Model+Humanoid:MoveTo+BillboardGui G2 Инвентарь: game.inventory vs Tool в Backpack G3 Звук: game.sound.play vs Instance.new('Sound') + .Parent=Part для 3D G4 Камера: camera.cutscene vs CurrentCamera + Scriptable + TweenService G5 Beam/Trail: fx.beam vs Instance.new('Beam')+Attachment G6 Мультиплеер: game.players vs Players + Teams сервисы G7 Лидерборды: game.leaderstats vs leaderstats Folder + IntValue G8 Damage floaters: game.fx.damageFloater vs BillboardGui+TweenService G9 Инвентарь: game.items vs Backpack+leaderstats для подсчёта G10 Небо: game.scene.setSkybox vs Sky+Atmosphere в Lighting G11 Модалки: game.modal.dialog vs ScreenGui+Frame+TextLabel G12 Машины: game.scene.spawn('vehicle:car') vs VehicleSeat --- src/community/docsData.jsx | 785 ++++++++++++++++++++++++++++--------- 1 file changed, 607 insertions(+), 178 deletions(-) diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index 963887f..c32689f 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -2616,11 +2616,13 @@ end`}} <>

    NPC (неигровой персонаж) — это житель твоей игры: - торговец, враг, проводник. Создаётся командой - game.scene.spawnNpc(модель, опции). + торговец, враг, проводник.

    - {`// Создаём NPC по имени Боб + +

    В JS используем game.scene.spawnNpc(модель, опции):

    + {`// Создаём NPC по имени Боб const bob = game.scene.spawnNpc('character-a', { x: 5, y: 0, z: 0, name: 'Боб', @@ -2628,35 +2630,84 @@ const bob = game.scene.spawnNpc('character-a', { speed: 3 }); -// Боб говорит реплику над головой (3 секунды) -bob.say('Привет, путник!', 3); - -// Боб идёт в точку (x = 10, z = 0) -bob.moveTo(10, 0);`} -

    Что умеет NPC:

    - - - - - - - - - - -
    moveTo(x, z)идти в точку
    follow('player')гнаться за игроком
    stop()остановиться
    say(текст, сек)реплика над головой
    damage(n)нанести урон NPC
    remove()убрать со сцены
    onDeath(fn)что сделать при гибели
    -

    Пример — враг гонится за игроком:

    - {`const enemy = game.scene.spawnNpc('character-b', { +bob.say('Привет, путник!', 3); // реплика над головой +bob.moveTo(10, 0); // идёт в точку`} +

    Что умеет NPC:

    + + + + + + + + + + +
    moveTo(x, z)идти в точку
    follow('player')гнаться за игроком
    stop()остановиться
    say(текст, сек)реплика над головой
    damage(n)нанести урон NPC
    remove()убрать
    onDeath(fn)при гибели
    +

    Пример — враг гонится за игроком:

    + {`const enemy = game.scene.spawnNpc('character-b', { x: 0, y: 0, z: 20, name: 'Враг', hp: 50, speed: 2 }); -enemy.follow('player'); // началась погоня - +enemy.follow('player'); enemy.onDeath(() => { game.ui.showText('Враг побеждён!', 2); - game.scene.spawnParticles('explosion', - enemy.position, { duration: 1 }); });`} + } + lua={<> +

    + В Lua NPC — это обычный Model с Humanoid внутри. + Движение делается через humanoid:MoveTo(point). + Реплики — через ChatService или BillboardGui. +

    + {`-- NPC модель должна лежать в Workspace. +-- Внутри Model должны быть Part'ы и Humanoid. +local npc = workspace:WaitForChild("Боб") +local humanoid = npc:WaitForChild("Humanoid") +local hrp = npc:WaitForChild("HumanoidRootPart") + +-- Реплика над головой через BillboardGui +local function say(text, duration) + local bg = Instance.new("BillboardGui") + bg.Size = UDim2.new(4, 0, 1, 0) + bg.StudsOffset = Vector3.new(0, 3, 0) + bg.Parent = npc.Head or hrp + local label = Instance.new("TextLabel", bg) + label.Size = UDim2.new(1, 0, 1, 0) + label.BackgroundTransparency = 1 + label.TextColor3 = Color3.new(1, 1, 1) + label.TextScaled = true + label.Text = text + task.delay(duration, function() bg:Destroy() end) +end + +say("Привет, путник!", 3) + +-- Идёт в точку +humanoid:MoveTo(Vector3.new(10, hrp.Position.Y, 0))`} +

    Враг гонится за игроком:

    + {`local enemy = workspace:WaitForChild("Враг") +local humanoid = enemy.Humanoid +local Players = game:GetService("Players") + +-- Каждые 0.5 сек обновляем цель — позицию игрока +task.spawn(function() + while enemy.Parent do + local player = Players:GetPlayers()[1] + if player and player.Character then + local target = player.Character.HumanoidRootPart.Position + humanoid:MoveTo(target) + end + task.wait(0.5) + end +end) + +-- При гибели +humanoid.Died:Connect(function() + print("Враг побеждён!") +end)`} + } + /> ), }, @@ -2666,39 +2717,82 @@ enemy.onDeath(() => { body: ( <>

    - Инвентарь — это сумка предметов внизу экрана. - Инструмент — предмет, который игрок берёт в руку: - меч, фонарик, лопата. + Инвентарь — сумка предметов. Инструмент — + предмет, который игрок берёт в руку: меч, фонарик.

    - {`// Выдать игроку меч прямо в руку + + {`// Выдать игроку меч прямо в руку game.player.giveTool('sword', { name: 'Стальной меч', - equip: true // сразу взять в руку + equip: true }); // Ловим, когда игрок применил инструмент (ЛКМ) game.player.onToolUse((e) => { game.log('Игрок применил:', e.tool); });`} -

    - Команды отдела game.inventory: - add(item) — добавить предмет, - remove(имя) — убрать, - has(имя) — есть ли предмет, - list() — список всех предметов. -

    +

    + Команды game.inventory: add, + remove, has, list. +

    + } + lua={<> + {`-- В Roblox инструмент — это Tool-инстанс в Backpack игрока. +local Players = game:GetService("Players") +local player = Players.LocalPlayer + +-- Создаём меч +local sword = Instance.new("Tool") +sword.Name = "Стальной меч" +sword.RequiresHandle = false -- упрощённо без Handle-Part +sword.Parent = player.Backpack + +-- Сразу взять в руку (переложить в Character) +sword.Parent = player.Character + +-- Ловим применение (ЛКМ или активация) +sword.Activated:Connect(function() + print("Игрок применил меч!") +end)`} +

    + Инвентарь игрока = его Backpack (Roblox-сервис). + Чтобы посмотреть что есть: player.Backpack:GetChildren(). +

    + } + />

    Пример — игра «ключ и сундук»:

    - {`game.self.onInteract(() => { - // проверяем, есть ли у игрока ключ + {`game.self.onInteract(() => { if (game.inventory.has('Ключ')) { game.ui.showText('Сундук открыт!', 2); - game.inventory.remove('Ключ'); // ключ потрачен + game.inventory.remove('Ключ'); } else { game.ui.showText('Нужен ключ', 1.5); } -}, { text: 'Открыть', distance: 4 });`} +}, { text: 'Открыть', distance: 4 });`}
    } + lua={{`local part = script.Parent +local prompt = Instance.new("ProximityPrompt") +prompt.ActionText = "Открыть" +prompt.MaxActivationDistance = 4 +prompt.Parent = part + +prompt.Triggered:Connect(function(player) + -- Ищем ключ в Backpack + local key = player.Backpack:FindFirstChild("Ключ") + if not key then + key = player.Character and player.Character:FindFirstChild("Ключ") + end + if key then + print("Сундук открыт!") + key:Destroy() -- ключ потрачен + else + print("Нужен ключ") + end +end)`}} + /> ), }, @@ -2707,38 +2801,59 @@ game.player.onToolUse((e) => { title: 'G3. Звук: свои звуки и 3D-позиционный звук', body: ( <> -

    - Звук оживляет игру. Команда - game.sound.play(id, опции). -

    +

    Звук оживляет игру.

    - {`// Готовые звуки-пресеты -game.sound.play('coin'); // звон монетки -game.sound.play('win'); // победа -game.sound.play('jump'); // прыжок -game.sound.play('hit'); // удар + +

    В JS — команда game.sound.play(id, опции):

    + {`// Готовые звуки-пресеты +game.sound.play('coin'); +game.sound.play('win'); +game.sound.play('jump'); +game.sound.play('hit'); // Свой загруженный звук, потише game.sound.play('sound_1', { volume: 0.7 });`} -

    - Пресеты: jump, pickup, - win, lose, click, - hit, coin. -

    -

    - 3D-звук — если указать опцию at, - звук пойдёт из точки в мире: чем дальше игрок, тем тише. -

    - {`// Звук костра — слышен только когда подходишь близко -game.sound.play('sound_2', { +

    + Пресеты: jump, pickup, + win, lose, click, + hit, coin. +

    +

    + 3D-звук — опция at привязывает + звук к точке в мире, тише с расстоянием. +

    + {`game.sound.play('sound_2', { at: { x: 0, y: 1, z: 0 }, - loop: true // звук повторяется по кругу + loop: true });`} + } + lua={<> +

    В Lua используется Sound-инстанс:

    + {`-- Простой звук — играет везде одинаково +local sound = Instance.new("Sound") +sound.SoundId = "rbxassetid://9120386436" -- свой ID +sound.Volume = 0.7 +sound.Parent = workspace +sound:Play()`} +

    + 3D-звук — родителем ставим Part в мире. + Sound автоматически становится позиционным. +

    + {`-- Звук костра — слышен близко +local campfire = workspace.Костёр +local sound = Instance.new("Sound") +sound.SoundId = "rbxassetid://..." +sound.RollOffMaxDistance = 30 -- метры до полной тишины +sound.Looped = true +sound.Parent = campfire -- родитель = Part → 3D-звук +sound:Play()`} + } + /> - Звук в играх обязателен — игра без звука кажется - «мёртвой». Но не запускай длинную музыку в самом начале: - это скучно и тормозит старт. Звуки вешай на события: - прыжок, попадание, победа. + Звук обязателен — игра без звука кажется «мёртвой». + Но не запускай длинную музыку в начале — это тормозит старт. + Звуки вешай на события: прыжок, попадание, победа. ), @@ -2748,29 +2863,65 @@ game.sound.play('sound_2', { title: 'G4. Камера: FOV, привязка, катсцены', body: ( <> -

    Отдел game.camera управляет видом игрока:

    - - - - - - - - -
    setFov(градусы)угол обзора — больше «шире» видно
    shake(сила, сек)тряска камеры (взрыв, удар)
    focusOn(ref)навести камеру на объект
    cutscene(точки, опции)пролёт камеры по точкам
    reset()вернуть камеру игроку
    -

    Пример — облёт уровня при старте игры:

    - {`// камера плавно пролетает через три точки + +

    В JS — отдел game.camera:

    + + + + + + + + +
    setFov(градусы)угол обзора
    shake(сила, сек)тряска камеры
    focusOn(ref)навести на объект
    cutscene(точки, опции)пролёт камеры
    reset()вернуть игроку
    + {`// Облёт уровня при старте game.camera.cutscene([ { x: 0, y: 20, z: -30 }, { x: 0, y: 15, z: 0 }, { x: 0, y: 10, z: 30 } -], { segDuration: 2 }); // 2 секунды на отрезок +], { segDuration: 2 }); -// когда облёт закончится — отдать камеру игроку game.onCutsceneDone(() => { game.ui.showText('Поехали!', 2); });`} + } + lua={<> +

    В Lua — стандартный Roblox Camera через Workspace.CurrentCamera:

    + + + + + + + +
    camera.FieldOfView = 90угол обзора
    camera.CameraType = Enum.CameraType.Scriptableотключить авто-следование
    camera.CFrame = CFrame.new(pos, look)поставить камеру
    TweenService:Create(camera, ...)плавный пролёт
    + {`local TweenService = game:GetService("TweenService") +local camera = workspace.CurrentCamera + +-- Отключаем авто-следование за игроком +camera.CameraType = Enum.CameraType.Scriptable + +-- Облёт через 3 точки за 6 секунд (3 этапа по 2 сек) +local points = { + Vector3.new(0, 20, -30), + Vector3.new(0, 15, 0), + Vector3.new(0, 10, 30), +} + +for _, point in ipairs(points) do + local goal = { CFrame = CFrame.new(point, Vector3.new(0, 5, 0)) } + local tween = TweenService:Create(camera, TweenInfo.new(2), goal) + tween:Play() + tween.Completed:Wait() +end + +-- Вернуть камеру игроку +camera.CameraType = Enum.CameraType.Custom +print("Поехали!")`} + } + /> ), }, @@ -2780,22 +2931,37 @@ game.onCutsceneDone(() => { body: ( <>

    - Отдел game.fx создаёт красивые эффекты-линии: - Beam — светящаяся линия между двумя точками + Beam — светящаяся линия между двумя точками (лазер, мост света), Trail — шлейф за движущимся объектом (след за ракетой).

    - {`// Лазер между двумя башнями + {`// Лазер между двумя башнями const t1 = game.scene.findOne('Башня1'); const t2 = game.scene.findOne('Башня2'); const laser = game.fx.beam({ - from: t1, - to: t2, - color: '#ff3344', - width: 0.3 -});`} + from: t1, to: t2, + color: '#ff3344', width: 0.3 +});`}
    } + lua={{`-- В Roblox Beam — это инстанс на Attachment'е +local t1 = workspace:WaitForChild("Башня1") +local t2 = workspace:WaitForChild("Башня2") + +-- Attachment'ы — точки на Part'ах +local a0 = Instance.new("Attachment", t1) +local a1 = Instance.new("Attachment", t2) + +local beam = Instance.new("Beam") +beam.Attachment0 = a0 +beam.Attachment1 = a1 +beam.Color = ColorSequence.new(Color3.fromRGB(255, 51, 68)) +beam.Width0 = 0.3 +beam.Width1 = 0.3 +beam.LightEmission = 1 +beam.Parent = t1`}} + /> ), }, @@ -2804,40 +2970,62 @@ const laser = game.fx.beam({ title: 'G6. Мультиплеер: игроки, комната, команды', body: ( <> -

    - В Рублоксе можно сделать игру на несколько игроков - в одной комнате. Главные отделы: -

    -
      -
    • - game.players — список игроков: - all(), count(), - me() (это я); -
    • -
    • - game.room — общее состояние комнаты, - которое видят все игроки; -
    • -
    • - game.teams — команды. -
    • -
    +

    В Рублоксе можно сделать игру на несколько игроков.

    - {`// Общий счёт команды — виден всем игрокам в комнате + +

    В JS — отделы game.players, game.room, game.teams:

    + {`// Общий счёт команды — виден всем игрокам game.room.set('totalScore', 0); -// когда счёт меняется — обновляем надпись у всех +// когда счёт меняется — обновляем надпись game.room.onChange('totalScore', (val) => { game.ui.set('score', 'Счёт команды: ' + val); }); -// сколько игроков сейчас в игре -game.log('Игроков в комнате:', game.players.count()); +game.log('Игроков:', game.players.count()); -// когда новый игрок зашёл game.onPlayerJoin((p) => { game.ui.showText(p.name + ' присоединился!', 2); });`} + } + lua={<> +

    В Lua — стандартный Roblox-стиль:

    + {`local Players = game:GetService("Players") +local Teams = game:GetService("Teams") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +-- Общий счёт команды через NumberValue в ReplicatedStorage +-- (виден всем игрокам через .Changed) +local totalScore = Instance.new("NumberValue") +totalScore.Name = "TotalScore" +totalScore.Value = 0 +totalScore.Parent = ReplicatedStorage + +totalScore.Changed:Connect(function(newValue) + print("Счёт команды:", newValue) +end) + +print("Игроков:", #Players:GetPlayers()) + +-- Когда новый игрок зашёл +Players.PlayerAdded:Connect(function(player) + print(player.Name .. " присоединился!") +end)`} +

    Команды (Teams):

    + {`-- Команды создают в Teams сервисе или скриптом +local redTeam = Instance.new("Team") +redTeam.Name = "Red" +redTeam.TeamColor = BrickColor.new("Bright red") +redTeam.AutoAssignable = true +redTeam.Parent = game:GetService("Teams") + +-- Назначить игрока в команду +Players.PlayerAdded:Connect(function(player) + player.Team = redTeam +end)`} + } + /> ), }, @@ -2847,29 +3035,66 @@ game.onPlayerJoin((p) => { body: ( <>

    - Лидерборд — таблица очков игроков справа-сверху (как - в Roblox). Объяви стат и меняй значение: + Лидерборд — таблица очков справа-сверху (как в Roblox).

    - {`game.leaderstats.define('Монеты', { initial: 0, icon: 'coin' }); + + {`game.leaderstats.define('Монеты', { initial: 0, icon: 'coin' }); game.leaderstats.define('Уровень', { initial: 1 }); -game.leaderstats.me.add('Монеты', 5); // +5 текущему игроку -game.leaderstats.me.set('Уровень', 2); // задать значение +game.leaderstats.me.add('Монеты', 5); +game.leaderstats.me.set('Уровень', 2); const c = game.leaderstats.me.get('Монеты');`} -

    - Достижения — всплывающие ачивки с редкостью и звуком: -

    - {`game.achievements.define([ - { id: 'first_coin', name: 'Первая монетка', description: 'Собери монету', icon: 'coin', rarity: 'common' }, +

    Достижения:

    + {`game.achievements.define([ + { id: 'first_coin', name: 'Первая монетка', icon: 'coin', rarity: 'common' }, { id: 'rich', name: 'Богач', description: '100 монет', icon: 'trophy', rarity: 'legendary' } ]); game.achievements.unlock('first_coin'); -// или авто-разблокировка по статy: game.achievements.bindToStat('rich', 'Монеты', 100);`} + } + lua={<> +

    В Lua — стандартный Roblox-паттерн: создаём папку + leaderstats в Player с IntValue внутри:

    + {`local Players = game:GetService("Players") + +Players.PlayerAdded:Connect(function(player) + -- Папка leaderstats — Roblox автоматически показывает её в HUD + local stats = Instance.new("Folder") + stats.Name = "leaderstats" + stats.Parent = player + + -- Стат "Монеты" + local coins = Instance.new("IntValue") + coins.Name = "Монеты" + coins.Value = 0 + coins.Parent = stats + + -- Стат "Уровень" + local level = Instance.new("IntValue") + level.Name = "Уровень" + level.Value = 1 + level.Parent = stats +end) + +-- Добавить монеты текущему игроку +local function addCoins(player, amount) + local stats = player:FindFirstChild("leaderstats") + if stats then + stats.Монеты.Value = stats.Монеты.Value + amount + end +end`} +

    + Папка с именем leaderstats на игроке — + магическое имя в Roblox. Любые IntValue/NumberValue/StringValue + внутри неё автоматически попадают в HUD справа сверху. +

    + } + /> Лидерборд и достижения сохраняются в БД и подтягиваются при - следующем входе игрока. + следующем входе игрока (DataStoreService в Roblox). ), @@ -2880,16 +3105,61 @@ game.achievements.bindToStat('rich', 'Монеты', 100);`}
    body: ( <>

    - Всплывающие цифры урона над врагом — как в RPG. Самый - простой способ — авто-режим (цифры над всеми мобами при уроне): + Всплывающие цифры урона над врагом — как в RPG.

    - {`game.fx.autoMobFloaters(true);`} -

    Ручной вызов в нужный момент:

    - {`game.fx.damageFloater(enemy.position, 25); // обычный урон -game.fx.damageFloater(enemy.position, 100, { isCrit: true }); // крит — крупно, жёлтый -game.fx.damageFloater('player', 30, { isHeal: true }); // лечение, зелёный -game.fx.damageFloater(pos, 0, { isMiss: true }); // промах MISS`} + +

    В JS это готовая команда:

    + {`game.fx.autoMobFloaters(true); // авто для всех мобов + +// или вручную +game.fx.damageFloater(enemy.position, 25); +game.fx.damageFloater(enemy.position, 100, { isCrit: true }); +game.fx.damageFloater('player', 30, { isHeal: true }); +game.fx.damageFloater(pos, 0, { isMiss: true });`} + } + lua={<> +

    В Lua делаем сами через BillboardGui + TweenService:

    + {`local TweenService = game:GetService("TweenService") + +local function showDamage(position, amount, isCrit) + -- Невидимый Part-якорь в нужной точке + local anchor = Instance.new("Part") + anchor.Anchored = true + anchor.CanCollide = false + anchor.Transparency = 1 + anchor.Size = Vector3.new(0.1, 0.1, 0.1) + anchor.Position = position + Vector3.new(0, 2, 0) + anchor.Parent = workspace + + -- BillboardGui над якорем + local bg = Instance.new("BillboardGui", anchor) + bg.Size = UDim2.new(3, 0, 1, 0) + bg.AlwaysOnTop = true + + local label = Instance.new("TextLabel", bg) + label.Size = UDim2.new(1, 0, 1, 0) + label.BackgroundTransparency = 1 + label.Text = "-" .. amount + label.TextColor3 = isCrit + and Color3.fromRGB(255, 200, 0) -- жёлтый крит + or Color3.fromRGB(255, 80, 80) -- красный обычный + label.TextScaled = true + label.Font = Enum.Font.GothamBold + + -- Анимируем вверх + исчезание + local goal = { Position = anchor.Position + Vector3.new(0, 4, 0) } + TweenService:Create(anchor, TweenInfo.new(1), goal):Play() + + task.delay(1, function() anchor:Destroy() end) +end + +-- Пример использования: +showDamage(workspace.Враг.Position, 25, false) +showDamage(workspace.Враг.Position, 100, true) -- крит`} + } + /> ), }, @@ -2898,30 +3168,68 @@ game.fx.damageFloater(pos, 0, { isMiss: true }); // промах MISS`} -

    - Полноценный инвентарь (сетка + хотбар, стаки, редкости). - Сначала опиши предметы, потом выдавай: -

    - {`game.items.define([ - { id: 'berry', name: 'Ягоды', emoji: '🍓', rarity: 'common', maxStack: 16 }, - { id: 'potion', name: 'Зелье', emoji: '🧪', rarity: 'rare', maxStack: 8, onUseEffect: 'heal:50' }, - { id: 'sword', name: 'Меч', emoji: '⚔️', rarity: 'legendary', maxStack: 1 }, + +

    В JS — готовый отдел game.items и game.inventory:

    + {`game.items.define([ + { id: 'berry', name: 'Ягоды', emoji: '🍓', rarity: 'common', maxStack: 16 }, + { id: 'potion', name: 'Зелье', emoji: '🧪', rarity: 'rare', maxStack: 8, onUseEffect: 'heal:50' }, + { id: 'sword', name: 'Меч', emoji: '⚔️', rarity: 'legendary', maxStack: 1 }, ]); game.inventory.give('sword', 1); -game.inventory.give('berry', 5); // стак`} -

    Сбор предмета с земли (скрипт на предмете):

    - - {`game.self.onInteract(() => { +game.inventory.give('berry', 5);`} +

    Сбор предмета с земли:

    + {`game.self.onInteract(() => { game.inventory.give('berry', 2); game.self.delete(); -}, { text: 'Собрать', key: 'e', distance: 3 });`} - - Редкости: common (серый), uncommon (зелёный), rare (голубой), - epic (фиолетовый), legendary (золотой). Окно инвентаря — - клавиша I, drag-drop, ПКМ-меню. - +}, { text: 'Собрать', distance: 3 });`}
    + + Редкости: common (серый), uncommon (зелёный), rare (голубой), + epic (фиолетовый), legendary (золотой). Окно инвентаря — + клавиша I, drag-drop, ПКМ-меню. + + } + lua={<> +

    + В Roblox инвентарь — это Backpack игрока с Tool'ами, + плюс свои IntValue'ы для подсчёта стаков. + Готового «инвентаря с редкостями» нет — собирается из частей: +

    + {`-- Пример: ягоды как IntValue в leaderstats +local Players = game:GetService("Players") + +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder") + stats.Name = "leaderstats" + stats.Parent = player + + local berries = Instance.new("IntValue", stats) + berries.Name = "Ягоды" + berries.Value = 0 +end) + +-- Сбор ягод (скрипт на собираемом Part) +local part = script.Parent +local prompt = Instance.new("ProximityPrompt") +prompt.ActionText = "Собрать" +prompt.Parent = part + +prompt.Triggered:Connect(function(player) + local berries = player.leaderstats and player.leaderstats:FindFirstChild("Ягоды") + if berries then + berries.Value = berries.Value + 2 + part:Destroy() + end +end)`} +

    + Для полноценной системы с редкостями, иконками и UI окном — + надо собирать ScreenGui вручную (по статье C). Это много кода — + проще использовать JS-вариант с готовым game.items. +

    + } + /> ), }, @@ -2930,18 +3238,52 @@ game.inventory.give('berry', 5); // стак`}
    title: 'G10. Небо, облака, туман, время суток', body: ( <> -

    Кастомное небо одной строкой — пресеты:

    - {`game.scene.setSkybox({ preset: 'sunset' }); -// пресеты: clear-summer-day | lowpoly-roblox | cloudy | sunset | starry-night | space`} -

    Облака, туман и плавный переход:

    - {`game.scene.setClouds({ enabled: true, cover: 0.5, speed: 0.02 }); + +

    Пресеты неба одной командой:

    + {`game.scene.setSkybox({ preset: 'sunset' }); +// пресеты: clear-summer-day | lowpoly-roblox | cloudy | sunset | starry-night | space + +game.scene.setClouds({ enabled: true, cover: 0.5, speed: 0.02 }); game.scene.setFog({ color: '#dddddd', density: 0.006 }); -game.scene.skybox.fadeTo({ preset: 'starry-night' }, 3); // плавно за 3 сек`} -

    Простое управление цветом неба и временем суток:

    - {`game.environment.setSkyColor('#0a1024'); // тёмное небо -game.environment.setTimeOfDay(0); // ночь (0..24) -game.environment.setTimeOfDay(12); // полдень`} +game.scene.skybox.fadeTo({ preset: 'starry-night' }, 3); + +game.environment.setSkyColor('#0a1024'); +game.environment.setTimeOfDay(0); // ночь +game.environment.setTimeOfDay(12); // полдень`}
    + } + lua={<> +

    В Roblox небо — это инстансы Sky и Atmosphere + в Lighting:

    + {`local Lighting = game:GetService("Lighting") + +-- Sky-инстанс с собственными текстурами +local sky = Instance.new("Sky") +sky.SkyboxBk = "rbxassetid://..." -- задняя грань +sky.SkyboxFt = "rbxassetid://..." -- передняя +sky.SkyboxLf = "rbxassetid://..." -- левая +sky.SkyboxRt = "rbxassetid://..." -- правая +sky.SkyboxUp = "rbxassetid://..." -- верх +sky.SkyboxDn = "rbxassetid://..." -- низ +sky.Parent = Lighting + +-- Туман +Lighting.FogColor = Color3.fromRGB(221, 221, 221) +Lighting.FogStart = 50 +Lighting.FogEnd = 500 + +-- Atmosphere (мгла, плотность) +local atmosphere = Instance.new("Atmosphere") +atmosphere.Density = 0.3 +atmosphere.Color = Color3.fromRGB(199, 199, 199) +atmosphere.Parent = Lighting + +-- Время суток (часы и минуты от полуночи) +Lighting:SetMinutesAfterMidnight(12 * 60) -- полдень +Lighting:SetMinutesAfterMidnight(0) -- полночь`} + } + /> ), }, @@ -2950,32 +3292,91 @@ game.environment.setTimeOfDay(12); // полдень`} title: 'G11. Диалоги, меню, экран загрузки', body: ( <> -

    Диалог NPC построчно:

    - {`game.modal.dialog('Староста', [ + +

    Диалог NPC:

    + {`game.modal.dialog('Староста', [ 'Привет, путник!', 'Собери 10 монет и возвращайся.', ], () => game.ui.showText('Квест начат!', 2));`} -

    Окно Да/Нет и лутбокс:

    - {`game.modal.confirmation('Выход', 'Точно выйти?', () => game.player.respawn(), null); +

    Окно Да/Нет и лутбокс:

    + {`game.modal.confirmation('Выход', 'Точно выйти?', + () => game.player.respawn(), null); game.modal.lootbox([ { name: 'Меч', color: '#f0ad4e', rarity: 'legendary' }, { name: 'Щит', color: '#5bc0de', rarity: 'rare' }, ], (item) => game.ui.showText('Выпал: ' + item.name, 3));`} -

    Экран загрузки при переходе между уровнями:

    - {`game.loading.show({ +

    Экран загрузки:

    + {`game.loading.show({ style: 'ken-burns', placeName: 'Глава 2 — Шахта', - studioName: 'Моя студия', duration: 2 }); game.loading.onHide(() => game.ui.showText('Добро пожаловать!', 2));`} - - Стартовый экран загрузки игры настраивается без кода — - см. раздел вики «Экран загрузки» (карточка в разборе игр) и - вкладку «Стартовый экран» в настройках проекта. - + } + lua={<> +

    + В Roblox/Lua нет готовых модалок — всё собирается через + ScreenGui с Frame'ами. Это много кода (~30-100 строк + на диалог), но полностью кастомизируется. +

    + {`-- Простейший диалог: ScreenGui + Frame + TextLabel + Button +local Players = game:GetService("Players") +local player = Players.LocalPlayer +local gui = player:WaitForChild("PlayerGui") + +local function showDialog(speaker, lines, onDone) + local screen = Instance.new("ScreenGui", gui) + local frame = Instance.new("Frame", screen) + frame.Size = UDim2.new(0.6, 0, 0.25, 0) + frame.Position = UDim2.new(0.2, 0, 0.65, 0) + frame.BackgroundColor3 = Color3.new(0, 0, 0) + frame.BackgroundTransparency = 0.4 + + local nameLabel = Instance.new("TextLabel", frame) + nameLabel.Size = UDim2.new(1, 0, 0.2, 0) + nameLabel.Text = speaker + nameLabel.TextColor3 = Color3.fromRGB(255, 220, 100) + nameLabel.BackgroundTransparency = 1 + + local textLabel = Instance.new("TextLabel", frame) + textLabel.Size = UDim2.new(1, -20, 0.6, 0) + textLabel.Position = UDim2.new(0, 10, 0.2, 0) + textLabel.TextColor3 = Color3.new(1, 1, 1) + textLabel.BackgroundTransparency = 1 + textLabel.TextWrapped = true + + local idx = 1 + local function showLine() + textLabel.Text = lines[idx] + end + + local btn = Instance.new("TextButton", frame) + btn.Size = UDim2.new(0.3, 0, 0.15, 0) + btn.Position = UDim2.new(0.65, 0, 0.82, 0) + btn.Text = "Дальше" + + btn.MouseButton1Click:Connect(function() + idx = idx + 1 + if idx > #lines then + screen:Destroy() + if onDone then onDone() end + else + showLine() + end + end) + + showLine() +end + +showDialog("Староста", { + "Привет, путник!", + "Собери 10 монет и возвращайся.", +}, function() print("Квест начат!") end)`} + } + /> ), }, @@ -2984,21 +3385,49 @@ game.loading.onHide(() => game.ui.showText('Добро пожаловать!', 2 title: 'G12. Машины и главное меню', body: ( <> -

    - Машина, на которой можно ездить (вход hold-F, WASD руль): -

    - {`game.scene.spawn('vehicle:car', { x: 0, y: 1, z: 0, name: 'Тачка' }); + +

    Машина, на которой можно ездить (вход hold-F, WASD руль):

    + {`game.scene.spawn('vehicle:car', { x: 0, y: 1, z: 0, name: 'Тачка' }); game.onVehicleEnter(() => game.ui.showText('За рулём! WASD — ехать', 2)); game.onVehicleExit(() => game.ui.showText('Вышел', 1));`} -

    Главное меню игры с живой камерой и кнопкой ИГРАТЬ:

    - {`game.mainMenu.show({ +

    Главное меню:

    + {`game.mainMenu.show({ title: 'МОЯ ИГРА', camera: 'orbit', playButtonText: 'ИГРАТЬ', patchNotes: { title: 'Что нового', items: ['Добавлены машины', 'Новая карта'] }, onPlay: () => game.ui.showText('Поехали!', 2) });`} + } + lua={<> +

    + В Roblox машина — это сложная Model с VehicleSeat + внутри. Когда игрок садится в VehicleSeat — у него + появляются .Throttle и .Steer + свойства от WASD автоматически: +

    + {`-- VehicleSeat внутри Model +local seat = workspace:WaitForChild("Тачка"):WaitForChild("VehicleSeat") + +-- Слушаем игрока в кресле +seat:GetPropertyChangedSignal("Occupant"):Connect(function() + if seat.Occupant then + print("За рулём! WASD — ехать") + else + print("Вышел") + end +end) + +-- Throttle (W/S) и Steer (A/D) — автоматически в seat.Throttle и seat.Steer +-- Применяй их к скорости/повороту в RunService.Heartbeat`} +

    + Главное меню — собирается через ScreenGui (см. C2-C3), + или используется готовый StarterGui от Roblox. +

    + } + /> ), }, -- 2.47.2 From 07fb192623f6899357c770f90c5b581d5b10aa26 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 02:56:06 +0300 Subject: [PATCH 072/214] =?UTF-8?q?feat(wiki):=20LangTabs=20=D0=B2=D0=BE?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=2012=20=D1=80=D0=B5=D1=86=D0=B5=D0=BF?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=20S?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S2 Touch: game.self.onTouch vs part.Touched:Connect S3 Килблок: game.player.damage vs humanoid:TakeDamage S4 Сбор монет: game.broadcast vs leaderstats.Монеты.Value S5 Телепорт: game.player.teleport vs HumanoidRootPart.CFrame S6 Свойства Part: scene.spawn opts vs Instance.new + Properties S7 Анимация: onTick + tween vs RunService.Heartbeat + TweenService S8 E-кнопка: onInteract vs ProximityPrompt + BindableEvent S9 HUD: game.ui.* vs ScreenGui + TextLabel/TextButton S10 Падение: onTick + position.y vs RunService + HRP.Y S11 Враг: spawnNpc + follow vs Model + Humanoid:MoveTo loop S12 Сохранение: game.save vs DataStoreService:GetAsync/SetAsync Lua-примеры по стандартному Roblox API: TweenService, RunService, DataStoreService, Humanoid, BindableEvent, ProximityPrompt, Debris. --- src/community/docsData.jsx | 804 +++++++++++++++++++++++++++---------- 1 file changed, 599 insertions(+), 205 deletions(-) diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index c32689f..497c4a4 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -3835,15 +3835,13 @@ game.onTick(() => { }, { id: 'recipes-touch', - title: 'S2. Касание объекта (onTouch)', + title: 'S2. Касание объекта', body: ( <> -

    - Самое частое событие — игрок коснулся объекта. Вешаем - скрипт на объект и подписываемся через game.self.onTouch. -

    +

    Самое частое событие — игрок коснулся объекта.

    - {`// Игрок наступил на объект — показать надпись и звук + {`// Игрок наступил на объект — показать надпись и звук game.self.onTouch(() => { game.ui.showText('Ты коснулся плиты!', 2); game.sound.play('click'); @@ -3852,14 +3850,36 @@ game.self.onTouch(() => { // Когда игрок ушёл с объекта game.self.onUntouch(() => { game.ui.showText('Отошёл', 1); -});`} -

    - Можно подписаться и на чужой объект из глобального - скрипта — найди его по имени: -

    +});`}
    } + lua={{`local part = script.Parent + +-- Игрок наступил +part.Touched:Connect(function(hit) + local player = game:GetService("Players"):GetPlayerFromCharacter(hit.Parent) + if player then + print("Ты коснулся плиты!") + end +end) + +-- Игрок ушёл +part.TouchEnded:Connect(function(hit) + local player = game:GetService("Players"):GetPlayerFromCharacter(hit.Parent) + if player then + print("Отошёл") + end +end)`}} + /> +

    Подписаться на чужой объект из глобального скрипта:

    - {`const trap = game.scene.findOne('Ловушка'); -trap.onTouch(() => game.player.damage(20));`} + {`const trap = game.scene.findOne('Ловушка'); +trap.onTouch(() => game.player.damage(20));`}
    } + lua={{`local trap = workspace:WaitForChild("Ловушка") +trap.Touched:Connect(function(hit) + local humanoid = hit.Parent:FindFirstChild("Humanoid") + if humanoid then humanoid:TakeDamage(20) end +end)`}} + /> ), }, @@ -3870,30 +3890,65 @@ trap.onTouch(() => game.player.damage(20));`} <>

    Килблок — объект, который наносит урон или мгновенно - убивает, когда игрок его коснулся (лава, шипы, кислота). + убивает при касании (лава, шипы, кислота).

    - {`// Мгновенная смерть при касании + {`// Мгновенная смерть при касании game.self.onTouch(() => { game.player.kill(); - game.ui.showText('💀 Ты сгорел в лаве!', 2); -});`} + game.ui.showText('Ты сгорел в лаве!', 2); +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local humanoid = hit.Parent:FindFirstChild("Humanoid") + if humanoid then + humanoid.Health = 0 -- мгновенная смерть + end +end)`}} + />

    Если хочешь не убивать сразу, а наносить урон:

    - {`// Урон 25 при касании (учитывает кадры неуязвимости) -game.self.onTouch(() => { + {`game.self.onTouch(() => { game.player.damage(25); - game.camera.shake(0.2, 0.3); // лёгкая тряска -});`} -

    - Постоянный урон, пока игрок стоит в зоне (например, - ядовитое облако) — урон каждые 0.5 сек, пока касается: -

    - {`let inside = false; + game.camera.shake(0.2, 0.3); +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local humanoid = hit.Parent:FindFirstChild("Humanoid") + if humanoid then humanoid:TakeDamage(25) end +end)`}} + /> +

    Постоянный урон, пока игрок стоит в зоне:

    + {`let inside = false; game.self.onTouch(() => { inside = true; }); game.self.onUntouch(() => { inside = false; }); game.every(0.5, () => { if (inside) game.player.damage(5); -});`} +});`}} + lua={{`local part = script.Parent +local inside = {} -- humanoid → true + +part.Touched:Connect(function(hit) + local h = hit.Parent:FindFirstChild("Humanoid") + if h then inside[h] = true end +end) +part.TouchEnded:Connect(function(hit) + local h = hit.Parent:FindFirstChild("Humanoid") + if h then inside[h] = nil end +end) + +-- Урон каждые 0.5 сек пока стоит +while true do + task.wait(0.5) + for h in pairs(inside) do + if h.Parent then h:TakeDamage(5) end + end +end`}} + /> Сделай красный неоновый куб, повесь на него скрипт смерти — получится лава. Поставь его в проёме как преграду. @@ -3907,38 +3962,73 @@ game.every(0.5, () => { body: ( <>

    - Предмет исчезает, когда игрок его коснулся — основа - сбора монеток, ключей, бонусов. + Предмет исчезает при касании — основа сбора монет.

    - {`// Простое исчезновение + звук -game.self.onTouch(() => { + {`game.self.onTouch(() => { game.sound.play('coin'); game.self.delete(); -});`} -

    - Со счётчиком: предмет сообщает глобальному скрипту, - тот считает. На монетке: -

    - {`game.self.onTouch(() => { - game.broadcast('coin'); // сообщить всем скриптам +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent:FindFirstChild("Humanoid") + if h then + part:Destroy() + end +end)`}} + /> +

    Со счётчиком: монетка увеличивает leaderstats игрока:

    + {`game.self.onTouch(() => { + game.broadcast('coin'); game.self.delete(); -});`} -

    В глобальном скрипте — приём и счёт:

    +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local player = game:GetService("Players"):GetPlayerFromCharacter(hit.Parent) + if not player then return end + + -- Прибавить монетку в leaderstats + local stats = player:FindFirstChild("leaderstats") + if stats and stats:FindFirstChild("Монеты") then + stats.Монеты.Value = stats.Монеты.Value + 1 + end + + part:Destroy() +end)`}} + /> +

    JS: глобальный скрипт принимает broadcast и считает:

    - {`let score = 0; + {`let score = 0; game.ui.score = 0; game.onMessage('coin', () => { score = score + 1; - game.ui.score = score; // обновить счётчик в углу - if (score >= 10) game.ui.showText('🏆 Собрал все!', 3); -});`} - - Не ставь счётчик на саму монетку — каждая монетка это - свой скрипт, они не видят переменные друг друга. Считай в - одном глобальном скрипте, монетки только шлют - game.broadcast. - + game.ui.score = score; + if (score >= 10) game.ui.showText('Собрал все!', 3); +});`}} + lua={{`-- В Lua счёт уже в leaderstats игрока (см. код на монетке выше). +-- Проверим достижение цели в глобальном скрипте: +local Players = game:GetService("Players") + +Players.PlayerAdded:Connect(function(player) + -- Создаём leaderstats папку при заходе + local stats = Instance.new("Folder", player) + stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats) + coins.Name = "Монеты" + coins.Value = 0 + + coins.Changed:Connect(function(newVal) + if newVal >= 10 then + print("Собрал все!") + end + end) +end)`}} + /> ), }, @@ -3949,34 +4039,62 @@ game.onMessage('coin', () => { <>

    При касании переместить игрока (портал) или - сдвинуть сам объект (движущаяся платформа). + сдвинуть сам объект.

    -

    Портал — телепорт игрока в точку:

    +

    Портал — телепорт игрока:

    - {`game.self.onTouch(() => { - game.player.teleport(0, 20, 50); // x, y, z назначения + {`game.self.onTouch(() => { + game.player.teleport(0, 20, 50); game.sound.play('win'); game.camera.shake(0.15, 0.2); -});`} -

    - Сдвинуть сам объект при касании (например, опустить - мост). game.self.move ставит новую позицию: -

    - {`let opened = false; +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local hrp = hit.Parent:FindFirstChild("HumanoidRootPart") + if hrp then + hrp.CFrame = CFrame.new(0, 20, 50) + end +end)`}} + /> +

    Сдвинуть сам объект при касании (опустить мост):

    + {`let opened = false; game.self.onTouch(() => { if (opened) return; opened = true; const p = game.self.position; - game.self.move(p.x, p.y - 3, p.z); // уехал вниз на 3 м -});`} -

    - Плавно сдвинуть — через game.tween (анимация): -

    - {`// дверь уезжает вбок за 1 секунду -const p = game.self.position; + game.self.move(p.x, p.y - 3, p.z); +});`}} + lua={{`local part = script.Parent +local opened = false + +part.Touched:Connect(function(hit) + if opened then return end + if not hit.Parent:FindFirstChild("Humanoid") then return end + opened = true + part.Position = part.Position - Vector3.new(0, 3, 0) +end)`}} + /> +

    Плавно сдвинуть — через TweenService:

    + {`const p = game.self.position; game.self.onTouch(() => { - game.tween(game.self.ref, { x: p.x + 4 }, { duration: 1, easing: 'ease' }); -});`} + game.tween(game.self.ref, { x: p.x + 4 }, + { duration: 1, easing: 'ease' }); +});`}} + lua={{`local TweenService = game:GetService("TweenService") +local part = script.Parent + +part.Touched:Connect(function(hit) + if not hit.Parent:FindFirstChild("Humanoid") then return end + local goal = { Position = part.Position + Vector3.new(4, 0, 0) } + TweenService:Create(part, TweenInfo.new(1), goal):Play() +end)`}} + /> ), }, @@ -3986,88 +4104,169 @@ game.self.onTouch(() => { body: ( <>

    - Любой примитив можно создать и менять из - скрипта. Вот все свойства и как их задать. + Любой примитив можно создать и менять из скрипта.

    -

    Создать примитив со всеми свойствами:

    - {`const box = game.scene.spawn('cube', { - x: 0, y: 2, z: 0, // позиция - sx: 2, sy: 1, sz: 3, // размер по осям (ширина/высота/глубина) - rotationX: 0, rotationY: 0.8, rotationZ: 0, // поворот в радианах - color: '#ff5533', // цвет (hex) - material: 'neon', // matte | neon | metal | glass | studs +

    Создать примитив:

    + {`const box = game.scene.spawn('cube', { + x: 0, y: 2, z: 0, + sx: 2, sy: 1, sz: 3, + rotationX: 0, rotationY: 0.8, rotationZ: 0, + color: '#ff5533', + material: 'neon', name: 'МойКуб', - anchored: true, // true = висит на месте; false = падает (физика) - canCollide: true, // false = игрок проходит насквозь + anchored: true, + canCollide: true, visible: true, - mass: 5, // масса (если anchored:false) -});`}
    -

    Типы примитивов для spawn:

    - {`'cube' 'sphere' 'cylinder' 'cone' 'pyramid' 'torus' 'wedge' 'cornerwedge' 'plane'`} -

    Менять свойства уже существующего объекта:

    - {`game.scene.setColor(box, '#00ff88'); // цвет -game.scene.setMaterial(box, 'glass'); // материал -game.scene.setVisible(box, false); // спрятать -game.scene.setCollide(box, false); // сделать проходимым -game.scene.setOpacity(box, 0.4); // полупрозрачность (1=видно, 0=невидимо) -game.scene.setScale(box, 3, 1, 1); // новый размер -game.scene.move(box, 5, 2, 0); // переместить -game.scene.setRotation(box, 0, 1.57, 0); // повернуть (радианы) -game.scene.setLabel(box, 'Привет!', { color:'#fff', height: 2.5 });`} -

    - Удобнее — через объект-прокси (присваивание свойств): -

    - {`const obj = game.scene.findOne('МойКуб'); + mass: 5, +});`}} + lua={{`local box = Instance.new("Part") +box.Name = "МойКуб" +box.Shape = Enum.PartType.Block +box.Size = Vector3.new(2, 1, 3) +box.Position = Vector3.new(0, 2, 0) +box.Orientation = Vector3.new(0, math.deg(0.8), 0) -- градусы +box.Color = Color3.fromRGB(255, 85, 51) +box.Material = Enum.Material.Neon +box.Anchored = true +box.CanCollide = true +box.Transparency = 0 +-- Если Anchored=false: box.Mass читается, не задаётся. +-- Управляется через PhysicalProperties и Density. +box.Parent = workspace`}} + /> +

    Типы примитивов:

    + {`'cube' 'sphere' 'cylinder' 'cone' 'pyramid' 'torus' 'wedge' 'cornerwedge' 'plane'`}} + lua={{`Enum.PartType.Block / Ball / Cylinder / Wedge / CornerWedge +-- Для cone/pyramid/torus используются MeshPart или SpecialMesh: +local sphere = Instance.new("Part") +sphere.Shape = Enum.PartType.Ball -- сфера`}} + /> +

    Менять свойства существующего объекта:

    + {`game.scene.setColor(box, '#00ff88'); +game.scene.setMaterial(box, 'glass'); +game.scene.setVisible(box, false); +game.scene.setCollide(box, false); +game.scene.setOpacity(box, 0.4); +game.scene.setScale(box, 3, 1, 1); +game.scene.move(box, 5, 2, 0); +game.scene.setRotation(box, 0, 1.57, 0); +game.scene.setLabel(box, 'Привет!', { color:'#fff', height: 2.5 }); + +// Или через прокси: +const obj = game.scene.findOne('МойКуб'); obj.color = '#ffd700'; obj.material = 'metal'; -obj.scale = 2; // равномерный масштаб +obj.scale = 2; obj.opacity = 0.5; obj.visible = false; obj.canCollide = false; obj.position = { x: 0, y: 10, z: 0 }; obj.rotateY(1.57); -obj.destroy(); // удалить`} - - Радианы: поворот задаётся в радианах, не градусах. - 90° = Math.PI/2 ≈ 1.57, 180° = Math.PI ≈ 3.14. - +obj.destroy();`}} + lua={{`-- Прямое присваивание свойств Part +box.Color = Color3.fromRGB(0, 255, 136) +box.Material = Enum.Material.Glass +box.Transparency = 0.6 -- 0=видно, 1=невидимо +box.CanCollide = false +box.Size = Vector3.new(3, 1, 1) +box.Position = Vector3.new(5, 2, 0) +box.Orientation = Vector3.new(0, 90, 0) +-- Скрыть: Transparency = 1 (или Parent = nil) + +box:Destroy() -- удалить`}} + /> + + Радианы: поворот в радианах. 90° = Math.PI/2 ≈ 1.57. +
    } + lua={ + Градусы: Orientation в градусах (не радианах). + Для CFrame.Angles — в радианах: math.rad(90). + } + /> ), }, { id: 'recipes-anim', - title: 'S7. Движение, вращение, мигание (onTick и tween)', + title: 'S7. Движение, вращение, мигание', body: ( <> -

    - Вращающийся объект (монета, портал) — крутим каждый - кадр через game.onTick (dt = время кадра): -

    +

    Вращающийся объект (монета, портал):

    - {`let angle = 0; + {`let angle = 0; game.onTick((dt) => { - angle = angle + dt * 2; // скорость вращения + angle = angle + dt * 2; game.self.rotateY(angle); -});`} -

    Парение вверх-вниз (плавно качается):

    - {`const start = game.self.position; +});`}} + lua={{`local RunService = game:GetService("RunService") +local part = script.Parent +local angle = 0 + +RunService.Heartbeat:Connect(function(dt) + angle = angle + dt * 2 + part.CFrame = CFrame.new(part.Position) * CFrame.Angles(0, angle, 0) +end)`}} + /> +

    Парение вверх-вниз:

    + {`const start = game.self.position; let t = 0; game.onTick((dt) => { t = t + dt; - const dy = Math.sin(t * 2) * 0.4; // амплитуда 0.4 м + const dy = Math.sin(t * 2) * 0.4; game.self.move(start.x, start.y + dy, start.z); -});`} -

    Пульсация размера через tween (бесконечно туда-обратно):

    - {`game.tween(game.self.ref, { sy: 1.4 }, { +});`}} + lua={{`local RunService = game:GetService("RunService") +local part = script.Parent +local startPos = part.Position +local t = 0 + +RunService.Heartbeat:Connect(function(dt) + t = t + dt + local dy = math.sin(t * 2) * 0.4 + part.Position = Vector3.new(startPos.X, startPos.Y + dy, startPos.Z) +end)`}} + /> +

    Пульсация размера:

    + {`game.tween(game.self.ref, { sy: 1.4 }, { duration: 0.6, easing: 'ease', yoyo: true, repeat: -1 -});`} -

    Мигание цветом каждые полсекунды:

    - {`let on = false; +});`}} + lua={{`local TweenService = game:GetService("TweenService") +local part = script.Parent +local origSize = part.Size + +local info = TweenInfo.new( + 0.6, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut, + -1, -- бесконечно + true -- yoyo (туда-обратно) +) +local goal = { Size = Vector3.new(origSize.X, origSize.Y * 1.4, origSize.Z) } +TweenService:Create(part, info, goal):Play()`}} + /> +

    Мигание цветом:

    + {`let on = false; game.every(0.5, () => { on = !on; game.self.setColor(on ? '#ff0000' : '#330000'); -});`} +});`}} + lua={{`local part = script.Parent +local on = false + +while true do + task.wait(0.5) + on = not on + part.Color = on and Color3.fromRGB(255, 0, 0) + or Color3.fromRGB(51, 0, 0) +end`}} + /> ), }, @@ -4076,47 +4275,92 @@ game.every(0.5, () => { title: 'S8. Кнопка по E и дверь', body: ( <> -

    - Взаимодействие по клавише E (как в Roblox ProximityPrompt) - — через game.self.onInteract. Появляется подсказка - «[E] …» когда игрок рядом. -

    +

    Взаимодействие по клавише E:

    - {`game.self.onInteract(() => { + {`game.self.onInteract(() => { game.ui.showText('Открыто!', 2); game.broadcast('open-door'); -}, { text: 'Открыть', key: 'e', distance: 4 });`} -

    На двери — глобальный/объектный скрипт, который её открывает:

    +}, { text: 'Открыть', key: 'e', distance: 4 });`}} + lua={{`local part = script.Parent + +local prompt = Instance.new("ProximityPrompt") +prompt.ActionText = "Открыть" +prompt.MaxActivationDistance = 4 +prompt.KeyboardKeyCode = Enum.KeyCode.E +prompt.Parent = part + +-- BindableEvent для оповещения "open-door" +local doorEvent = workspace:FindFirstChild("DoorOpenEvent") + or Instance.new("BindableEvent", workspace) +doorEvent.Name = "DoorOpenEvent" + +prompt.Triggered:Connect(function(player) + print("Открыто!") + doorEvent:Fire() +end)`}} + /> +

    На двери:

    - {`const closed = game.self.position; + {`const closed = game.self.position; game.onMessage('open-door', () => { - // плавно уехать вверх (открыться) - game.tween(game.self.ref, { y: closed.y + 4 }, { duration: 1, easing: 'ease' }); - game.self.setCollide(false); // через неё можно пройти -});`} + game.tween(game.self.ref, { y: closed.y + 4 }, + { duration: 1, easing: 'ease' }); + game.self.setCollide(false); +});`}} + lua={{`local TweenService = game:GetService("TweenService") +local door = script.Parent +local closedPos = door.Position + +local doorEvent = workspace:WaitForChild("DoorOpenEvent") + +doorEvent.Event:Connect(function() + local goal = { Position = closedPos + Vector3.new(0, 4, 0) } + TweenService:Create(door, TweenInfo.new(1), goal):Play() + door.CanCollide = false +end)`}} + /> - holdDuration: 1 в опциях onInteract — держать E - 1 секунду (для важных действий). distance — - с какого расстояния появляется подсказка. + holdDuration: 1 в onInteract / prompt.HoldDuration = 1 + в Roblox — держать E одну секунду. ), }, { id: 'recipes-gui-timer', - title: 'S9. Надписи на экране, таймер, кнопки GUI', + title: 'S9. HUD надписи, таймер, кнопки', body: ( <> -

    HUD-надписи в углу и по центру:

    +

    HUD-надписи:

    - {`game.ui.score = 0; // счётчик «Очки: 0» в углу -game.ui.score = 50; // обновить -game.ui.timer = 60; // таймер mm:ss в углу -game.ui.showText('Старт!', 2); // крупно по центру на 2 сек -game.ui.set('hp', 'Жизни: 3', { x: 50, y: 90, color: '#fff' }); // своя метка -game.ui.remove('hp'); // убрать метку`} -

    Обратный отсчёт и проигрыш по времени:

    - {`let time = 30; + {`game.ui.score = 0; // счётчик в углу +game.ui.score = 50; +game.ui.timer = 60; // таймер +game.ui.showText('Старт!', 2); +game.ui.set('hp', 'Жизни: 3', { x: 50, y: 90, color: '#fff' }); +game.ui.remove('hp');`}} + lua={{`-- В Roblox HUD = leaderstats папка (см. G7) или свой ScreenGui +local Players = game:GetService("Players") +local player = Players.LocalPlayer +local gui = player:WaitForChild("PlayerGui") + +-- Своя метка по центру +local screen = Instance.new("ScreenGui", gui) +local label = Instance.new("TextLabel", screen) +label.Size = UDim2.new(0.4, 0, 0.1, 0) +label.Position = UDim2.new(0.3, 0, 0.4, 0) +label.Text = "Старт!" +label.TextScaled = true +label.BackgroundTransparency = 0.5 + +task.delay(2, function() screen:Destroy() end)`}} + /> +

    Обратный отсчёт:

    + {`let time = 30; game.ui.timer = time; const id = game.every(1, () => { time = time - 1; @@ -4126,16 +4370,48 @@ const id = game.every(1, () => { game.ui.showText('Время вышло!', 3); game.player.kill(); } -});`} -

    Кнопка на экране (GUI) и обработка клика:

    - {`const btn = game.gui.create('button', { +});`}} + lua={{`local time = 30 + +while time > 0 do + task.wait(1) + time = time - 1 + print("Осталось: " .. time) +end + +print("Время вышло!") +-- Убить локального игрока +local player = game:GetService("Players").LocalPlayer +if player.Character and player.Character:FindFirstChild("Humanoid") then + player.Character.Humanoid.Health = 0 +end`}} + /> +

    Кнопка GUI:

    + {`const btn = game.gui.create('button', { name: 'start', text: 'НАЧАТЬ', x: 50, y: 80, - w: 20, h: 8, bg: '#3a6ee0', color: '#fff', fontSize: 18, borderRadius: 12 + w: 20, h: 8, bg: '#3a6ee0', color: '#fff' }); game.gui.onClick(btn, () => { game.ui.showText('Поехали!', 2); game.gui.hide(btn); -});`} +});`}} + lua={{`local player = game:GetService("Players").LocalPlayer +local gui = player:WaitForChild("PlayerGui") +local screen = Instance.new("ScreenGui", gui) + +local btn = Instance.new("TextButton", screen) +btn.Size = UDim2.new(0.2, 0, 0.08, 0) +btn.Position = UDim2.new(0.4, 0, 0.8, 0) +btn.Text = "НАЧАТЬ" +btn.BackgroundColor3 = Color3.fromRGB(58, 110, 224) +btn.TextColor3 = Color3.new(1, 1, 1) + +btn.MouseButton1Click:Connect(function() + print("Поехали!") + btn.Visible = false +end)`}} + /> ), }, @@ -4144,34 +4420,75 @@ game.gui.onClick(btn, () => { title: 'S10. Спавн, падение, проверка падения вниз', body: ( <> -

    Спавнить объекты с неба каждую секунду (ловилка):

    +

    Спавнить объекты с неба каждую секунду:

    - {`game.every(1, () => { + {`game.every(1, () => { const x = game.random(-10, 10); game.scene.spawn('sphere', { x: x, y: 20, z: 0, color: '#ffd700', material: 'neon', - anchored: false, // будет падать (физика) - lifetime: 8 // само исчезнет через 8 сек + anchored: false, + lifetime: 8 }); -});`} -

    - Игрок упал вниз (за карту) — вернуть на спавн. Проверяем - высоту каждый кадр: -

    - {`game.onTick(() => { +});`}} + lua={{`local Debris = game:GetService("Debris") + +while true do + task.wait(1) + local x = math.random(-10, 10) + + local ball = Instance.new("Part") + ball.Shape = Enum.PartType.Ball + ball.Size = Vector3.new(1, 1, 1) + ball.Position = Vector3.new(x, 20, 0) + ball.Color = Color3.fromRGB(255, 215, 0) + ball.Material = Enum.Material.Neon + ball.Anchored = false -- падает + ball.Parent = workspace + + Debris:AddItem(ball, 8) -- удалить через 8 сек +end`}} + /> +

    Игрок упал вниз:

    + {`game.onTick(() => { if (game.player.position.y < -10) { game.player.respawn(); - game.ui.showText('Упал! Назад на старт.', 2); + game.ui.showText('Упал!', 2); } -});`} -

    Финиш — дошёл до зоны, победа:

    +});`}} + lua={{`local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +RunService.Heartbeat:Connect(function() + local player = Players.LocalPlayer + if not player.Character then return end + local hrp = player.Character:FindFirstChild("HumanoidRootPart") + if hrp and hrp.Position.Y < -10 then + player:LoadCharacter() -- респавн + print("Упал!") + end +end)`}} + /> +

    Финиш:

    - {`game.self.onTouch(() => { - game.ui.showText('🏁 ПОБЕДА!', 4); + {`game.self.onTouch(() => { + game.ui.showText('ПОБЕДА!', 4); game.sound.play('win'); - game.player.setInputBlocked(true); // заморозить управление -});`} + game.player.setInputBlocked(true); +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent:FindFirstChild("Humanoid") + if not h then return end + print("ПОБЕДА!") + h.WalkSpeed = 0 -- заморозить + h.JumpPower = 0 +end)`}} + /> ), }, @@ -4180,30 +4497,68 @@ game.gui.onClick(btn, () => { title: 'S11. Враг, который идёт за игроком', body: ( <> -

    - NPC/враг, который преследует игрока и наносит урон. -

    +

    NPC/враг, преследующий игрока:

    - {`const enemy = game.scene.spawnNpc('zombie', { + {`const enemy = game.scene.spawnNpc('zombie', { x: 10, y: 0, z: 10, hp: 100, name: 'Зомби', speed: 3 }); -enemy.follow('player'); // идти за игроком +enemy.follow('player'); enemy.say('Хочу тебя поймать!', 3); enemy.onDeath(() => { game.ui.showText('Враг повержен!', 2); - game.fx.damageFloater(enemy.position, 0, { isHeal: true }); -});`} -

    Урон игроку, когда враг близко:

    - {`game.every(0.5, () => { +}); + +// Урон когда близко +game.every(0.5, () => { const d = game.distance(enemy.position, game.player.position); if (d < 2) game.player.damage(10); -});`} - - Облачка урона над всеми мобами одной строкой: - game.fx.autoMobFloaters(true). - +});`}} + lua={{`-- Враг должен быть Model с Humanoid и HumanoidRootPart в workspace. +-- Например workspace.Зомби. +local Players = game:GetService("Players") +local enemy = workspace:WaitForChild("Зомби") +local humanoid = enemy:WaitForChild("Humanoid") +local hrp = enemy:WaitForChild("HumanoidRootPart") + +-- Преследование игрока +task.spawn(function() + while enemy.Parent and humanoid.Health > 0 do + local player = Players:GetPlayers()[1] + if player and player.Character then + local target = player.Character:FindFirstChild("HumanoidRootPart") + if target then + humanoid:MoveTo(target.Position) + end + end + task.wait(0.5) + end +end) + +humanoid.Died:Connect(function() + print("Враг повержен!") +end) + +-- Урон когда близко +task.spawn(function() + while enemy.Parent and humanoid.Health > 0 do + task.wait(0.5) + local player = Players:GetPlayers()[1] + if player and player.Character then + local target = player.Character:FindFirstChild("HumanoidRootPart") + local playerHum = player.Character:FindFirstChild("Humanoid") + if target and playerHum then + local dist = (target.Position - hrp.Position).Magnitude + if dist < 2 then + playerHum:TakeDamage(10) + end + end + end + end +end)`}} + /> ), }, @@ -4212,35 +4567,74 @@ enemy.onDeath(() => { title: 'S12. Сохранение прогресса и лидерборд', body: ( <> -

    - Лидерборд (таблица очков справа) — объяви стат и - прибавляй: -

    +

    Лидерборд:

    - {`game.leaderstats.define('Монеты', { initial: 0 }); -// прибавить текущему игроку: -game.leaderstats.me.add('Монеты', 1);`} -

    - Сохранение между сессиями (прогресс не теряется после - выхода): -

    - {`// записать -game.save.merge('progress', { - patch: { level: 3 }, // обычные поля - increment: { coins: 10 }, // атомарно прибавить - max: { bestScore: 5000 } // запишется только если больше старого + {`game.leaderstats.define('Монеты', { initial: 0 }); +game.leaderstats.me.add('Монеты', 1);`}} + lua={{`-- leaderstats: см. G7 +local Players = game:GetService("Players") +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player) + stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats) + coins.Name = "Монеты" + coins.Value = 0 +end) + +-- Прибавить монетку (например, при сборе) +local function addCoin(player, amount) + local stats = player:FindFirstChild("leaderstats") + if stats then + stats.Монеты.Value = stats.Монеты.Value + amount + end +end`}} + /> +

    Сохранение между сессиями:

    + {`game.save.merge('progress', { + patch: { level: 3 }, + increment: { coins: 10 }, + max: { bestScore: 5000 } }); -// прочитать при старте game.save.get('progress', (data) => { if (data) { game.ui.showText('С возвращением! Уровень ' + data.level, 3); } -});`} +});`}} + lua={{`-- В Roblox сохранение через DataStoreService (требует онлайн-игру) +local DataStoreService = game:GetService("DataStoreService") +local progress = DataStoreService:GetDataStore("Progress") +local Players = game:GetService("Players") + +Players.PlayerAdded:Connect(function(player) + -- Прочитать при входе + local success, data = pcall(function() + return progress:GetAsync(player.UserId) + end) + if success and data then + print("С возвращением! Уровень " .. (data.level or 1)) + -- Применить прогресс: leaderstats.Монеты.Value = data.coins и т.п. + end +end) + +Players.PlayerRemoving:Connect(function(player) + -- Сохранить при выходе + local data = { + level = 3, + coins = 10, + bestScore = 5000, + } + pcall(function() + progress:SetAsync(player.UserId, data) + end) +end)`}} + /> - Собери всё вместе: монетки шлют broadcast → глобальный скрипт - считает в leaderstats → раз в N монет сохраняет через - game.save. Получится игра с прогрессом как в настоящем Roblox. + Собери всё вместе: монетки добавляются в leaderstats → + глобальный скрипт раз в N монет вызывает save.merge + или DataStore:SetAsync. Получится игра с прогрессом. ), -- 2.47.2 From defb1d80c11af8de3e2aa607ca67a95ad6f26831 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 03:01:52 +0300 Subject: [PATCH 073/214] =?UTF-8?q?feat(wiki):=20LangTabs=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20H=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BE=D0=B1=D0=BE=D0=B8=D1=85=20=D1=8F=D0=B7?= =?UTF-8?q?=D1=8B=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каждая из 11 таблиц-разделов H1 теперь имеет JS и Lua колонки: - Игрок: game.player.* vs humanoid/hrp/player.* - Объекты сцены: game.scene.* vs Instance.new + workspace.* - script-носитель: game.self vs script.Parent - HUD: game.ui.* vs leaderstats + ScreenGui - GUI: game.gui.* vs MouseButton1Click/FocusLost - Физика/эффекты: game.physics/fx/constraints vs Raycast/Beam/Trail - Камера/звук: game.camera/sound vs CurrentCamera + Sound - События/таймеры: game.onTick/onKey vs Heartbeat/UIS/task.delay - Утилиты: game.random/distance vs math.*/Vector3.Magnitude - Мультиплеер: game.players/teams/leaderstats vs Players/Teams/Folder - Окружение: game.environment/items/modal/menu vs Lighting/Backpack/ScreenGui --- src/community/docsData.jsx | 432 ++++++++++++++++++++++++++----------- 1 file changed, 310 insertions(+), 122 deletions(-) diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index 497c4a4..d000e02 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -3449,140 +3449,328 @@ end) body: ( <>

    - Здесь собраны все команды game.* по отделам. - Это шпаргалка — не нужно её запоминать, держи под рукой. + Здесь собраны все команды по отделам. Это шпаргалка — + не нужно её запоминать, держи под рукой. Переключатель + сверху меняет язык.

    -

    game.player — игрок

    - - - - - - - - - - - - - - - - - - -
    positionпозиция игрока {`{x,y,z}`}
    hp / maxHpздоровье и максимум
    aliveжив ли игрок (да/нет)
    forwardкуда смотрит {`{x,y,z}`}
    teleport(x,y,z)телепорт
    damage(n) / heal(n)урон / лечение
    kill() / respawn()убить / воскресить
    setSpawn(точка)новая точка возрождения
    setSpeed(mul)скорость бега
    setJumpPower(mul)сила прыжка
    setGravityMul(mul)сила гравитации
    setDoubleJump(on)двойной прыжок
    playAnimation(имя)эмоция персонажа
    giveTool(тип,опции)дать инструмент
    isKeyDown(клавиша)зажата ли клавиша сейчас
    +

    Игрок

    + + + game.player.positionпозиция игрока {`{x,y,z}`} + game.player.hp / maxHpздоровье и максимум + game.player.aliveжив ли игрок + game.player.forwardкуда смотрит + game.player.teleport(x,y,z)телепорт + game.player.damage(n) / heal(n)урон / лечение + game.player.kill() / respawn()убить / воскресить + game.player.setSpawn(точка)новая точка возрождения + game.player.setSpeed(mul)скорость (множитель) + game.player.setJumpPower(mul)прыжок (множитель) + game.player.setGravityMul(mul)гравитация (множитель) + game.player.setDoubleJump(on)двойной прыжок + game.player.playAnimation(имя)эмоция + game.player.giveTool(тип,опции)инструмент в руку + game.player.isKeyDown(клавиша)зажата ли клавиша + + } + lua={ + + + + + + + + + + + + + + + + + +
    hrp.Positionпозиция (Vector3)
    humanoid.Health / MaxHealthздоровье
    humanoid.Health {'>'} 0жив ли
    camera.CFrame.LookVectorкуда смотрит
    hrp.CFrame = CFrame.new(x,y,z)телепорт
    humanoid:TakeDamage(n) / humanoid.Health += nурон / лечение
    humanoid.Health = 0 / player:LoadCharacter()убить / воскресить
    player.RespawnLocation = spawnточка возрождения
    humanoid.WalkSpeed = Nскорость (16 = норма)
    humanoid.JumpPower = Nсила прыжка (50 = норма)
    workspace.Gravity = Nгравитация (196 = норма)
    humanoid:ChangeState(Jumping)прыгнуть
    animator:LoadAnimation(anim):Play()анимация
    Instance.new("Tool",player.Character)инструмент в руку
    UserInputService:IsKeyDown(key)зажата ли клавиша
    } + /> -

    game.scene — объекты сцены

    - - - - - - - - - - - - - - - - - - - - - -
    spawn(тип,опции)создать объект → ref
    delete(ref)удалить
    deleteAfter(ref,сек)удалить через N секунд
    move(ref,x,y,z)переместить
    rotate(ref,угол)повернуть
    setColor(ref,цвет)сменить цвет
    setCollide(ref,да)твёрдость
    setVisible(ref,да)видимость
    setOpacity(ref,0..1)прозрачность
    find(имя) / findOne(имя)поиск по имени
    all(тип)все объекты типа
    getPosition(ref)позиция объекта
    setData/getDataатрибуты объекта
    tag/untag/hasTagтеги
    getTagged(тег)все объекты с тегом
    setLabel/clearLabelтекст-метка над объектом
    spawnNpc(модель,опции)создать NPC
    spawnParticles(тип,...)частицы
    +

    Объекты сцены

    + + + game.scene.spawn(тип,опции)создать объект → ref + game.scene.delete(ref)удалить + game.scene.deleteAfter(ref,сек)удалить через N секунд + game.scene.move(ref,x,y,z)переместить + game.scene.rotate(ref,угол)повернуть + game.scene.setColor(ref,цвет)цвет + game.scene.setCollide(ref,да)твёрдость + game.scene.setVisible(ref,да)видимость + game.scene.setOpacity(ref,0..1)прозрачность + game.scene.find(имя) / findOne(имя)поиск по имени + game.scene.all(тип)все объекты типа + game.scene.setData/getDataатрибуты + game.scene.tag/untag/hasTagтеги + game.scene.getTagged(тег)все объекты с тегом + game.scene.setLabel/clearLabelметка над объектом + game.scene.spawnNpc(модель,опции)создать NPC + game.scene.spawnParticles(тип,...)частицы + + } + lua={ + + + + + + + + + + + + + + + + + + + +
    Instance.new("Part", workspace)создать объект
    part:Destroy()удалить
    Debris:AddItem(part, N)удалить через N секунд
    part.Position = Vector3.new(x,y,z)переместить
    part.Orientation = Vector3.new(...)повернуть
    part.Color = Color3.fromRGB(...)цвет
    part.CanCollide = true/falseтвёрдость
    part.Transparency = 1невидимость (0=видно)
    part.Transparency = 0.4полупрозрачность
    workspace:FindFirstChild("Имя") / workspace.Имяпоиск по имени
    CollectionService:GetTagged("тег")все объекты с тегом
    part:SetAttribute/GetAttributeатрибуты
    CollectionService:AddTag/RemoveTag/HasTagтеги
    CollectionService:GetTagged(tag)все объекты с тегом
    BillboardGui + TextLabelметка над объектом
    Model + Humanoid + AnimNPC (вручную)
    Instance.new("ParticleEmitter", part)частицы
    } + /> -

    game.self — объект-носитель скрипта

    - - - - - - - - - - - -
    ref / positionадрес и позиция объекта
    onClick(fn)клик по объекту
    onTouch(fn)игрок коснулся
    onUntouch(fn)игрок вышел из объекта
    onInteract(fn,опции)взаимодействие по E
    move(x,y,z)переместить себя
    delete()удалить себя
    setText(t)сменить текст (для GUI)
    +

    Объект-носитель скрипта

    + + + game.self.ref / positionадрес и позиция + game.self.onClick(fn)клик по объекту + game.self.onTouch(fn)игрок коснулся + game.self.onUntouch(fn)игрок вышел + game.self.onInteract(fn,опции)взаимодействие по E + game.self.move(x,y,z)переместить себя + game.self.delete()удалить себя + game.self.setText(t)сменить текст + + } + lua={ + + + + + + + + + + +
    script.Parent / .Positionсам объект и его позиция
    ClickDetector.MouseClick:Connectклик по объекту
    part.Touched:Connectигрок коснулся
    part.TouchEnded:Connectигрок вышел
    ProximityPrompt.Triggered:Connectвзаимодействие по E
    part.Position = Vector3.new(x,y,z)переместить
    part:Destroy()удалить
    textLabel.Text = "..."сменить текст (для GUI)
    } + /> -

    game.ui — счётчики и текст

    - - - - - - - -
    score / timerсчётчики в углу
    showText(текст,сек)текст по центру
    set(id,текст,опции)своя метка на экране
    remove(id) / clear()убрать метку / всё
    +

    HUD: счётчики и текст

    + + + game.ui.score / timerсчётчики в углу + game.ui.showText(текст,сек)текст по центру + game.ui.set(id,текст,опции)своя метка + game.ui.remove(id) / clear()убрать метку / всё + + } + lua={ + + + + + + +
    leaderstats папка + IntValueсчётчики в углу (HUD автомат)
    ScreenGui + TextLabel (центр)текст по центру
    label.Text = "..."обновить метку
    label:Destroy() / screen:Destroy()убрать метку / всё
    } + /> -

    game.gui — кнопки и меню

    - - - - - - - - -
    find(имя) / get(id)найти элемент
    update(id,patch)изменить свойства
    show(id) / hide(id)показать / скрыть
    onClick(id,fn)клик по кнопке
    onSubmit(id,fn)ввод в поле завершён
    +

    GUI: кнопки и меню

    + + + game.gui.find(имя) / get(id)найти элемент + game.gui.update(id,patch)изменить свойства + game.gui.show(id) / hide(id)показать / скрыть + game.gui.onClick(id,fn)клик по кнопке + game.gui.onSubmit(id,fn)ввод в поле завершён + + } + lua={ + + + + + + + +
    gui:FindFirstChild(имя, true)найти элемент
    elem.Text = "..." / прямая запись свойствизменить свойства
    elem.Visible = true/falseпоказать / скрыть
    button.MouseButton1Click:Connectклик по кнопке
    textbox.FocusLost:Connect(fn)ввод завершён
    } + /> -

    physics, fx, constraints

    - - - - - - - - - - - - -
    physics.raycast(...)луч — во что попал
    physics.applyImpulse(...)толкнуть объект
    physics.explode(...)взрыв
    physics.passThrough(...)проходимость
    fx.beam(опции)светящийся луч
    fx.trail(ref,опции)след за объектом
    constraints.weld(a,b)склейка
    constraints.hinge(...)петля
    constraints.spring(...)пружина
    +

    Физика, эффекты, связи

    + + + game.physics.raycast(...)луч — во что попал + game.physics.applyImpulse(...)толкнуть объект + game.physics.explode(...)взрыв + game.physics.passThrough(...)проходимость + game.fx.beam(опции)светящийся луч + game.fx.trail(ref,опции)след за объектом + game.fx.damageFloater(...)цифры урона + game.constraints.weld(a,b)склейка + game.constraints.hinge(...)петля + game.constraints.spring(...)пружина + + } + lua={ + + + + + + + + + + + + +
    workspace:Raycast(origin,dir,params)луч — во что попал
    part:ApplyImpulse(Vector3)толкнуть объект
    Instance.new("Explosion", workspace)взрыв
    part.CanCollide = falseпроходимость
    Instance.new("Beam") + Attachmentsсветящийся луч
    Instance.new("Trail") + Attachmentsслед за объектом
    BillboardGui + TweenServiceцифры урона (вручную)
    Instance.new("WeldConstraint")склейка
    Instance.new("HingeConstraint")петля
    Instance.new("SpringConstraint")пружина
    } + /> -

    camera, sound

    - - - - - - - - -
    camera.setFov(град)угол обзора
    camera.shake(сила,сек)тряска
    camera.cutscene(...)пролёт камеры
    camera.reset()вернуть камеру
    sound.play(id,опции)проиграть звук
    +

    Камера и звук

    + + + game.camera.setFov(град)угол обзора + game.camera.shake(сила,сек)тряска + game.camera.cutscene(...)пролёт камеры + game.camera.reset()вернуть камеру игроку + game.sound.play(id,опции)проиграть звук + + } + lua={ + + + + + + + +
    workspace.CurrentCamera.FieldOfView = Nугол обзора
    camera.CFrame = CFrame.new(...) + рандомтряска (вручную)
    camera.CameraType = Scriptable + TweenServiceпролёт камеры
    camera.CameraType = Customвернуть игроку
    Instance.new("Sound"):Play()проиграть звук
    } + />

    События и таймеры

    - - - - - - - - - - -
    onTick(fn)каждый кадр
    onKey/onKeyUp(клавиша,fn)клавиатура
    onClick(fn)клик в игре
    after(сек,fn)через N секунд
    every(сек,fn)каждые N секунд
    cancel(id)отменить таймер
    tween(ref,св-ва,опции)плавная анимация
    + + + game.onTick(fn)каждый кадр + game.onKey/onKeyUp(клавиша,fn)клавиатура + game.onClick(fn)клик в игре + game.after(сек,fn)через N секунд + game.every(сек,fn)каждые N секунд + game.cancel(id)отменить таймер + game.tween(ref,св-ва,опции)плавная анимация + + } + lua={ + + + + + + + + + +
    RunService.Heartbeat:Connect(fn)каждый кадр
    UserInputService.InputBegan/Endedклавиатура
    mouse.Button1Down:Connect(fn)клик в игре
    task.delay(сек, fn)через N секунд
    task.spawn(function() while ... task.wait(N) end end)каждые N секунд
    connection:Disconnect()отменить подписку
    TweenService:Create(obj, info, goal):Play()плавная анимация
    } + />

    Утилиты

    - - - - - - - - - -
    random(min,max)случайное число
    distance(a,b)расстояние между точками
    clamp(v,min,max)зажать число в границах
    lerp(a,b,t)плавный переход a→b
    log(...)напечатать в консоль
    broadcast/onMessageсообщения между скриптами
    + + + game.random(min,max)случайное число + game.distance(a,b)расстояние между точками + game.clamp(v,min,max)зажать в границах + game.lerp(a,b,t)плавный переход + game.log(...)в консоль + game.broadcast/onMessageсообщения между скриптами + + } + lua={ + + + + + + + + +
    math.random(min,max)случайное число
    (a - b).Magnitudeрасстояние между Vector3
    math.clamp(v,min,max)зажать в границах
    a + (b-a)*t или Vector3:Lerp(other,t)плавный переход
    print(...) / warn(...)в консоль
    BindableEvent:Fire + .Event:Connectсообщения между скриптами
    } + /> + +

    Мультиплеер, лидерборды, команды

    + + + game.players.all() / count() / me()список игроков + game.room.set/get/onChangeобщее состояние комнаты + game.teams.*команды + game.leaderstats.define(имя,опции)объявить стат + game.leaderstats.me.add/set/getтекущему игроку + game.achievements.define/unlockдостижения + game.save.merge/getсохранение прогресса + game.onPlayerJoin/Leave(fn)игрок зашёл / ушёл + + } + lua={ + + + + + + + + + + +
    Players:GetPlayers() / #Players:GetPlayers() / Players.LocalPlayerсписок / число / я
    ReplicatedStorage + Value + .Changedобщее состояние
    Teams сервис + Instance.new("Team")команды
    Instance.new("Folder","leaderstats")+IntValueлидерборд
    stats.Имя.Value = Nобновить стат
    BadgeService:AwardBadge(uid, id)достижения (badges)
    DataStoreService:GetAsync/SetAsyncсохранение прогресса
    Players.PlayerAdded:Connect / PlayerRemovingигрок зашёл / ушёл
    } + /> + +

    Небо, освещение, инвентарь, модалки

    + + + game.scene.setSkybox/fadeToпресеты неба + game.scene.setFog/setCloudsтуман и облака + game.environment.setTimeOfDay(0..24)время суток + game.items.define(список)описать предметы + game.inventory.give/remove/has/listинвентарь + game.modal.dialog/confirmation/lootboxмодальные окна + game.mainMenu.show/hideглавное меню + game.loading.show/onHideэкран загрузки + + } + lua={ + + + + + + + + + + +
    Lighting + Sky / Atmosphereпресеты неба (вручную)
    Lighting.FogColor / FogEnd / Atmosphereтуман и облака
    Lighting:SetMinutesAfterMidnight(N)время суток
    Свои Tool'ы в ServerStorageпредметы (вручную)
    player.Backpack:GetChildren() / Tool.Parent = Backpackинвентарь
    ScreenGui + Frame + Buttonмодалки (вручную, см. G11)
    ScreenGui + Frameглавное меню (вручную)
    ReplicatedFirst + loading screenэкран загрузки
    } + /> ), }, -- 2.47.2 From 836688bd4f1b2ef17135303c7b51cca5fc9db239 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 03:05:33 +0300 Subject: [PATCH 074/214] =?UTF-8?q?style(wiki):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B5=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D0=B8=D0=BD=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=B4-?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BB=D1=8C=20=D0=B2=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: text=#3357ff на background=#e0e8ff — слабый контраст (~3:1), плашки в таблицах сливались с фоном. Сейчас: теплый янтарь #b14400 на светлом #fff5e0 + рамка #f5d8a8. Контраст ~7.5:1 — читается отлично на обычных таблицах. Внутри тёмных LangTabs-вкладок (если контент темный) — наоборот, светлый янтарь #ffd86b на тёмном #2a2f4a. Тоже хороший контраст. --- src/community/KubikonDocs.jsx | 5 +++-- src/community/docsLang.jsx | 11 ++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/community/KubikonDocs.jsx b/src/community/KubikonDocs.jsx index a424cb7..c94d9fb 100644 --- a/src/community/KubikonDocs.jsx +++ b/src/community/KubikonDocs.jsx @@ -823,13 +823,14 @@ const INLINE_STYLES = ` .docsSectionBody b { color: #0f172a; font-weight: 800; } .docsSectionBody h4 { font-family: inherit; } .docsSectionBody code { - background: #e0e8ff; - color: #3357ff; + background: #fff5e0; + color: #b14400; padding: 2px 7px; border-radius: 6px; font-family: Consolas, Menlo, "Courier New", monospace; font-size: 13px; font-weight: 700; + border: 1px solid #f5d8a8; } /* kbd */ diff --git a/src/community/docsLang.jsx b/src/community/docsLang.jsx index 55df3ec..72dcf69 100644 --- a/src/community/docsLang.jsx +++ b/src/community/docsLang.jsx @@ -243,11 +243,12 @@ export const DOCS_LANG_STYLES = ` width: 25%; color: #aab0c8; } -.docTable code { - background: #0e1020; - padding: 1px 5px; - border-radius: 3px; - font-size: 12px; +/* Код-плашки в моих docLang-таблицах — наследуют .docsSectionBody code + (тёплый янтарь на светлом). Перебиваем только если внутри тёмной LangTabs. */ +.docsLangTabs__body .docTable code { + background: #2a2f4a; + color: #ffd86b; + border: 1px solid #3a4068; } .langChoiceOverlay { -- 2.47.2 From d5b146cace5153a6580466e2ea152218504b33fb Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 03:09:29 +0300 Subject: [PATCH 075/214] =?UTF-8?q?style(wiki):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=20=D1=82=D1=91=D0=BC=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=20docsLang=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=D1=8E=D1=89=D0=B8=D0=B5=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BB=D1=8B=D0=B5=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: docsLang.jsx ставил тёмные .docTable (background #181b2c, color #aab0c8) которые перебивали светлые стили вики (#fafbfd, #334155). Получалось тёмно-синий текст на тёмно-синем — почти невидимо. Сейчас: 1. Убран override .docTable / td / td:first-child — наследует светлые стили из KubikonDocs.jsx как остальная вика. 2. Добавлен только .docTable th (его не было) — светло-голубой фон eef2ff с тёмным текстом 1e3a8a для заголовков колонок. 3. .docsLangTabs переведён со тёмной (#181b2c) на светлую (#fff + #f4f6fb head, #e0e6f0 рамка) тему. Активная вкладка — синяя. Теперь все таблицы и LangTabs читаемые в светлом интерфейсе вики. --- src/community/docsLang.jsx | 62 +++++++++++++++----------------------- 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/src/community/docsLang.jsx b/src/community/docsLang.jsx index 72dcf69..b78ecbb 100644 --- a/src/community/docsLang.jsx +++ b/src/community/docsLang.jsx @@ -189,67 +189,55 @@ export const DOCS_LANG_STYLES = ` .docsLangTabs { margin: 12px 0; - border-radius: 8px; + border-radius: 10px; overflow: hidden; - border: 1px solid #2a3050; + border: 1px solid #e0e6f0; + background: #fff; } .docsLangTabs__head { display: flex; - background: #181b2c; - border-bottom: 1px solid #2a3050; + background: #f4f6fb; + border-bottom: 1px solid #e0e6f0; } .docsLangTabs__tab { - padding: 8px 18px; + padding: 9px 18px; border: none; background: transparent; - color: #888da6; + color: #64748b; font-size: 12px; font-weight: 700; cursor: pointer; letter-spacing: 0.5px; border-bottom: 2px solid transparent; } -.docsLangTabs__tab:hover { color: #c8cce0; } +.docsLangTabs__tab:hover { color: #1e293b; } .docsLangTabs__tab.is-active { - color: #fff; - border-bottom-color: #4a8bff; - background: #1f2338; + color: #1e3a8a; + border-bottom-color: #3357ff; + background: #fff; } .docsLangTabs__body { padding: 0; + background: #fff; } .docsLangTabs__body > pre, .docsLangTabs__body > .docCode { margin: 0; border-radius: 0; } -.docTable { - width: 100%; - border-collapse: collapse; - margin: 12px 0; - font-size: 13px; -} -.docTable th, .docTable td { - border: 1px solid #2a3050; - padding: 8px 10px; - text-align: left; - vertical-align: top; -} +/* Заголовки колонок таблицы (th) — в основных стилях вики не определены. + Делаем светлыми чтобы не сливались с фоном таблицы. */ .docTable th { - background: #1a1d2e; - color: #c8cce0; - font-weight: 600; -} -.docTable td:first-child { - background: #181b2c; - width: 25%; - color: #aab0c8; -} -/* Код-плашки в моих docLang-таблицах — наследуют .docsSectionBody code - (тёплый янтарь на светлом). Перебиваем только если внутри тёмной LangTabs. */ -.docsLangTabs__body .docTable code { - background: #2a2f4a; - color: #ffd86b; - border: 1px solid #3a4068; + padding: 9px 14px; + background: #eef2ff; + color: #1e3a8a; + font-size: 13px; + font-weight: 700; + text-align: left; + border-bottom: 1px solid #d4dcef; + border-right: 1px solid #eef2f7; } +.docTable th:last-child { border-right: none; } +.docTable thead tr:first-child th:first-child { border-top-left-radius: 12px; } +.docTable thead tr:first-child th:last-child { border-top-right-radius: 12px; } .langChoiceOverlay { position: fixed; inset: 0; -- 2.47.2 From 35cd304b0e9743730a2314907e26cf5fff526e9c Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 03:11:36 +0300 Subject: [PATCH 076/214] =?UTF-8?q?style(wiki):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=80=D0=B0=D0=BC=D0=BA=D0=B0=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=BA=D1=80=D1=83=D0=B3=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D0=B4-=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В прошлом коммите добавил border к .docsSectionBody code (для inline-плашек). Он унаследовался внутри pre.docCode — рамка появилась вокруг каждой строки. Сбрасываем border:none для .docCode code. --- src/community/KubikonDocs.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/community/KubikonDocs.jsx b/src/community/KubikonDocs.jsx index c94d9fb..4e0de2a 100644 --- a/src/community/KubikonDocs.jsx +++ b/src/community/KubikonDocs.jsx @@ -862,6 +862,7 @@ const INLINE_STYLES = ` .docCode code { background: none; color: inherit; padding: 0; font-weight: 500; font-size: 13px; white-space: pre; + border: none; } /* Скриншот интерфейса с подписью. -- 2.47.2 From d6ba23ae8d9ae3b3a756b0c20560de2f63f3e6b0 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 03:14:44 +0300 Subject: [PATCH 077/214] =?UTF-8?q?feat(wiki):=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=D0=BA=D0=B0=20=D1=81=D0=B8=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=81=D0=B0=20JS=20=D0=B8=20Lua=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4-=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше код был монотонным — все белое на чёрном. Сейчас цветной syntax highlighting в стиле Dracula: - розовый (#ff79c6) — ключевые слова (let/const/function/local/end/then) - голубой (#8be9fd) — встроенные (game/workspace/Math/Vector3) - жёлтый (#f1fa8c) — строки - фиолетовый (#bd93f9) — числа - серый (#6272a4) italic — комментарии - зелёный (#50fa7b) — имена функций (id с () после) Реализация: docsLang.highlightCode() — простой regex-токенизатор. компонент авто-детектит lang ('js'/'lua') по содержимому (паттерны local/then/--/:Connect), либо принимает явный prop lang=. Без внешних библиотек — ~80 строк регулярки, легко поддерживать. --- src/community/docsData.jsx | 20 +++++-- src/community/docsLang.jsx | 109 +++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 5 deletions(-) diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index d000e02..000a264 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -1,6 +1,6 @@ import React from 'react'; import DocIcon from './docsIcons'; -import { LangTabs } from './docsLang'; +import { LangTabs, highlightCode } from './docsLang'; /** * docsData.jsx — контент вики редактора Рублокс (разделы A-J). @@ -22,10 +22,20 @@ import { LangTabs } from './docsLang'; * только SVG-иконки (см. docsIcons.jsx). */ -// ── Код-блок ────────────────────────────────────────────────────── -export const Code = ({ children }) => ( -
    {children}
    -); +// ── Код-блок с подсветкой синтаксиса ────────────────────────────── +// lang='js' (default) | 'lua'. Если не указан — автодетект по содержимому. +export const Code = ({ children, lang }) => { + const text = typeof children === 'string' ? children + : Array.isArray(children) ? children.join('') : String(children); + const resolved = lang || ( + /\blocal\b|\bthen\b|\bend\b|\b:Connect\b|\bfunction\(|--\s/.test(text) ? 'lua' : 'js' + ); + return ( +
    +            
    +        
    + ); +}; // ── Плашка «куда писать скрипт» ─────────────────────────────────── // kind="global" — глобальный скрипт (создаётся в категории «Скрипты») diff --git a/src/community/docsLang.jsx b/src/community/docsLang.jsx index b78ecbb..394a4b3 100644 --- a/src/community/docsLang.jsx +++ b/src/community/docsLang.jsx @@ -14,6 +14,105 @@ */ import React, { createContext, useContext, useEffect, useState } from 'react'; +// ══════════════════════════════════════════════════════════════════ +// Простая подсветка синтаксиса для JS и Lua +// ══════════════════════════════════════════════════════════════════ + +const JS_KEYWORDS = new Set([ + 'let', 'const', 'var', 'function', 'return', 'if', 'else', 'for', 'while', + 'do', 'switch', 'case', 'break', 'continue', 'new', 'this', 'class', + 'extends', 'super', 'true', 'false', 'null', 'undefined', 'try', 'catch', + 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of', 'async', 'await', + 'import', 'export', 'from', 'default', 'delete', 'void', +]); +const JS_BUILTINS = new Set([ + 'game', 'Math', 'Object', 'Array', 'String', 'Number', 'Boolean', 'JSON', + 'console', 'setTimeout', 'setInterval', 'Promise', 'document', 'window', +]); + +const LUA_KEYWORDS = new Set([ + 'local', 'function', 'end', 'if', 'then', 'else', 'elseif', 'for', 'while', + 'do', 'repeat', 'until', 'return', 'break', 'and', 'or', 'not', 'true', + 'false', 'nil', 'in', 'goto', +]); +const LUA_BUILTINS = new Set([ + 'game', 'workspace', 'script', 'Instance', 'Vector3', 'Vector2', 'Color3', + 'CFrame', 'UDim2', 'UDim', 'BrickColor', 'Enum', 'math', 'string', 'table', + 'task', 'print', 'warn', 'pairs', 'ipairs', 'pcall', 'tostring', 'tonumber', + 'TweenInfo', 'wait', 'tick', 'type', 'require', 'next', 'setmetatable', + 'getmetatable', 'rawget', 'rawset', +]); + +function escapeHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +/** Возвращает HTML-строку с раскрашенным кодом. lang: 'js' | 'lua'. */ +export function highlightCode(text, lang) { + if (typeof text !== 'string') return escapeHtml(String(text || '')); + const isLua = lang === 'lua'; + const keywords = isLua ? LUA_KEYWORDS : JS_KEYWORDS; + const builtins = isLua ? LUA_BUILTINS : JS_BUILTINS; + // Регулярки для токенов — порядок важен: сначала комменты и строки, + // потом числа, потом identifier'ы. + // JS: //... и /*...*/. Lua: --... и --[[...]]. + const commentRe = isLua + ? /--\[\[[\s\S]*?\]\]|--[^\n]*/g + : /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g; + // Строки: одинарные, двойные, в JS ещё бэктики. + const stringRe = isLua + ? /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\[\[[\s\S]*?\]\]/g + : /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`/g; + const numRe = /\b\d+(?:\.\d+)?\b/g; + const idRe = /[A-Za-zА-Яа-я_$][A-Za-zА-Яа-я0-9_$]*/g; + + // Берём весь текст, делим на токены через одну общую регулярку. + const tokens = []; + const combined = new RegExp( + commentRe.source + '|' + stringRe.source + '|' + numRe.source + '|' + idRe.source, + 'g' + ); + let lastIndex = 0; + let match; + while ((match = combined.exec(text)) !== null) { + const start = match.index; + const tok = match[0]; + if (start > lastIndex) { + tokens.push({ type: 'raw', text: text.slice(lastIndex, start) }); + } + // Классифицируем + if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) { + tokens.push({ type: 'comment', text: tok }); + } else if (/^["'`\[]/.test(tok)) { + tokens.push({ type: 'string', text: tok }); + } else if (/^\d/.test(tok)) { + tokens.push({ type: 'number', text: tok }); + } else if (keywords.has(tok)) { + tokens.push({ type: 'keyword', text: tok }); + } else if (builtins.has(tok)) { + tokens.push({ type: 'builtin', text: tok }); + } else { + // Идентификатор — проверим, идёт ли за ним ( → функция + const rest = text.slice(start + tok.length); + if (/^\s*\(/.test(rest)) { + tokens.push({ type: 'fn', text: tok }); + } else { + tokens.push({ type: 'ident', text: tok }); + } + } + lastIndex = start + tok.length; + } + if (lastIndex < text.length) { + tokens.push({ type: 'raw', text: text.slice(lastIndex) }); + } + return tokens.map(t => { + const safe = escapeHtml(t.text); + if (t.type === 'raw' || t.type === 'ident') return safe; + return `${safe}`; + }).join(''); +} + + const LS_KEY = 'rublox.docs.lang'; const DEFAULT_LANG = 'js'; @@ -310,4 +409,14 @@ export const DOCS_LANG_STYLES = ` cursor: pointer; } .langChoiceCancel:hover { background: #232842; color: #fff; } + +/* ══════════════════════════════════════════════════════════════════ + Подсветка синтаксиса в код-блоках + ══════════════════════════════════════════════════════════════════ */ +.docCode .hl-keyword { color: #ff79c6; font-weight: 600; } /* let/const/local/function */ +.docCode .hl-builtin { color: #8be9fd; } /* game / workspace / Math */ +.docCode .hl-string { color: #f1fa8c; } /* 'строки' "строки" */ +.docCode .hl-number { color: #bd93f9; } /* 42, 3.14 */ +.docCode .hl-comment { color: #6272a4; font-style: italic; } /* // или -- */ +.docCode .hl-fn { color: #50fa7b; } /* myFunc() */ `; -- 2.47.2 From 86b3d2f238af152747f510cea549a86fc7b7198a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 03:22:58 +0300 Subject: [PATCH 078/214] =?UTF-8?q?feat(wiki):=20AI-=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=B4=D0=BB=D1=8F=20Lua=20?= =?UTF-8?q?=D1=80=D1=8F=D0=B4=D0=BE=D0=BC=20=D1=81=20JS-=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше в статье "AI2. Контекст — скопируй в нейросеть" был только JS-вариант. Юзер пишущий на Lua не мог использовать. Сейчас: - Новая константа AI_CONTEXT_LUA — полный API Lua-рантайма Рублокса (стандартный Roblox-стиль: game:GetService, workspace, Vector3, Instance.new, TweenService и т.п. + наши особенности). - LangTabs обёртка над в AI2 — нейросеть получит контекст на нужном языке. - AI1 (как пользоваться) дополнен подсказкой про переключатель. Также в добавлен prop plain=true для отключения подсветки — AI-контекст это текст для копирования, а не код, ему подсветка не нужна (и она ломала бы тысячи символов API-описаний). --- src/community/docsData.jsx | 251 ++++++++++++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 6 deletions(-) diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index 000a264..17c37fe 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -24,9 +24,13 @@ import { LangTabs, highlightCode } from './docsLang'; // ── Код-блок с подсветкой синтаксиса ────────────────────────────── // lang='js' (default) | 'lua'. Если не указан — автодетект по содержимому. -export const Code = ({ children, lang }) => { +// plain=true — без подсветки (для длинных текстов вроде AI-контекста). +export const Code = ({ children, lang, plain }) => { const text = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children); + if (plain) { + return
    {text}
    ; + } const resolved = lang || ( /\blocal\b|\bthen\b|\bend\b|\b:Connect\b|\bfunction\(|--\s/.test(text) ? 'lua' : 'js' ); @@ -232,6 +236,235 @@ game.onMessage('coin', () => { s++; game.ui.score = s; game.sound.play('coin'); Теперь напиши скрипт под мою задачу (она ниже). Укажи, КУДА его вставить (глобальный или на объект).`; +// ════════════════════════════════════════════════════════════ +// AI_CONTEXT_LUA — то же самое, но для Lua-скриптов. +// API Lua-рантайма Рублокса совместим со стандартным Roblox API. +// ════════════════════════════════════════════════════════════ +const AI_CONTEXT_LUA = `Ты — помощник по написанию скриптов на Lua для онлайн-конструктора 3D-игр «Рублокс» (аналог Roblox, движок Babylon.js + wasmoon-runtime). API максимально совместим со стандартным Roblox: game:GetService(...), workspace, Vector3, CFrame, Instance.new, signals через :Connect. Пиши ТОЛЬКО рабочий Lua-код. Не используй require() и DataStoreService. + +=== ДВА ВИДА СКРИПТОВ === +1) Глобальный (Script) — в категории «Скрипты», запускается 1 раз. Управляет всей сценой. +2) На объекте (Script внутри Part) — script.Parent = объект-носитель. Через script.Parent ловим Touched/ClickDetector. +Всегда указывай пользователю, КУДА класть скрипт. + +=== ОСНОВНЫЕ СЕРВИСЫ === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") +local TweenService = game:GetService("TweenService") +local Debris = game:GetService("Debris") +local CollectionService = game:GetService("CollectionService") +local Teams = game:GetService("Teams") +local Lighting = game:GetService("Lighting") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local SoundService = game:GetService("SoundService") + +=== ИГРОК === +local player = Players.LocalPlayer +local char = player.Character or player.CharacterAdded:Wait() +local humanoid = char:WaitForChild("Humanoid") +local hrp = char:WaitForChild("HumanoidRootPart") + +humanoid.Health -- HP (0..MaxHealth) +humanoid.MaxHealth = 100 +humanoid.WalkSpeed = 16 -- 16 = норма +humanoid.JumpPower = 50 -- 50 = норма +humanoid:TakeDamage(n) +humanoid.Health = 0 -- мгновенная смерть +player:LoadCharacter() -- респавн +humanoid.Died:Connect(fn) +hrp.Position -- Vector3 +hrp.CFrame = CFrame.new(x,y,z) -- телепорт +workspace.Gravity = 196 -- 196 = норма +Players.PlayerAdded:Connect(function(p) ... end) +Players.PlayerRemoving:Connect(function(p) ... end) + +=== ОБЪЕКТ-НОСИТЕЛЬ (script на Part) === +local part = script.Parent +part.Position = Vector3.new(x,y,z) +part.Color = Color3.fromRGB(255, 100, 50) +part.Material = Enum.Material.Neon -- Plastic/Neon/Metal/Glass/Wood +part.Transparency = 0.5 -- 0=видно, 1=невидимо +part.CanCollide = false +part.Anchored = true -- висит / падает +part.Size = Vector3.new(2, 1, 2) +part.Orientation = Vector3.new(0, 90, 0) -- ГРАДУСЫ +part.Touched:Connect(function(hit) + local h = hit.Parent:FindFirstChild("Humanoid") + if h then ... end +end) +part.TouchEnded:Connect(fn) +-- Клик по Part: +local cd = Instance.new("ClickDetector", part) +cd.MouseClick:Connect(function(player) ... end) +-- Кнопка E: +local pp = Instance.new("ProximityPrompt", part) +pp.ActionText = "Открыть"; pp.MaxActivationDistance = 4 +pp.Triggered:Connect(function(player) ... end) + +=== СОЗДАНИЕ ОБЪЕКТОВ === +local p = Instance.new("Part") +p.Shape = Enum.PartType.Block -- Block/Ball/Cylinder/Wedge/CornerWedge +p.Size = Vector3.new(2, 2, 2) +p.Position = Vector3.new(0, 5, 0) +p.Color = Color3.fromRGB(255, 0, 0) +p.Material = Enum.Material.Neon +p.Anchored = true +p.Parent = workspace -- обязательно! + +p:Destroy() +Debris:AddItem(p, 5) -- удалить через 5 сек + +workspace:WaitForChild("Имя") -- ждать пока появится +workspace:FindFirstChild("Имя") +workspace:GetChildren() + +=== ВЕКТОРЫ И CFRAME === +Vector3.new(x, y, z) +v.Magnitude -- длина +v.Unit -- единичный +(a - b).Magnitude -- расстояние + +CFrame.new(x, y, z) +CFrame.new(pos, lookAt) +cf * CFrame.Angles(0, math.rad(90), 0) -- РАДИАНЫ +cf.Position cf.LookVector cf.RightVector + +Color3.fromRGB(255, 100, 50) +Color3.new(1, 0.4, 0.2) +UDim2.new(scaleX, offsetX, scaleY, offsetY) -- для GUI + +=== СОБЫТИЯ И ТАЙМЕРЫ === +RunService.Heartbeat:Connect(function(dt) ... end) -- каждый кадр +UserInputService.InputBegan:Connect(function(input, gp) + if input.KeyCode == Enum.KeyCode.Space then ... end +end) +task.wait(2) -- ждать 2 сек +task.delay(3, function() ... end) -- через 3 сек один раз +task.spawn(function() -- параллельный поток + while true do task.wait(1); ... end +end) +local ev = Instance.new("BindableEvent") +ev.Event:Connect(fn); ev:Fire(arg) + +=== TWEEN === +local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut, 0, false, 0) +local goal = { Position = part.Position + Vector3.new(0, 5, 0) } +local tween = TweenService:Create(part, info, goal) +tween:Play() +tween.Completed:Connect(fn) + +=== GUI === +local gui = player:WaitForChild("PlayerGui") +local screen = Instance.new("ScreenGui", gui) +local label = Instance.new("TextLabel", screen) +label.Size = UDim2.new(0.4, 0, 0.1, 0) +label.Position = UDim2.new(0.3, 0, 0.4, 0) +label.Text = "Привет!" +label.TextColor3 = Color3.new(1, 1, 1) +label.TextScaled = true +local btn = Instance.new("TextButton", screen) +btn.MouseButton1Click:Connect(fn) +-- BillboardGui над Part: +local bb = Instance.new("BillboardGui", part) +bb.StudsOffset = Vector3.new(0, 3, 0) + +=== ЛИДЕРБОРД (HUD справа сверху) === +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player) + stats.Name = "leaderstats" -- магическое имя! + local coins = Instance.new("IntValue", stats) + coins.Name = "Монеты"; coins.Value = 0 +end) + +=== КОМАНДЫ === +local team = Instance.new("Team") +team.Name = "Red" +team.TeamColor = BrickColor.new("Bright red") +team.AutoAssignable = true +team.Parent = Teams +player.Team = team + +=== ФИЗИКА === +local ray = workspace:Raycast(origin, direction * 50) +if ray then print(ray.Instance.Name, ray.Position) end +part:ApplyImpulse(Vector3.new(0, 100, 0)) +local exp = Instance.new("Explosion") +exp.Position = Vector3.new(0, 5, 0); exp.BlastRadius = 10; exp.Parent = workspace + +=== ТЕГИ === +CollectionService:AddTag(part, "звезда") +CollectionService:HasTag(part, "звезда") +CollectionService:GetTagged("звезда") +CollectionService:GetInstanceAddedSignal("звезда"):Connect(fn) + +=== АТРИБУТЫ === +part:SetAttribute("Price", 50) +part:GetAttribute("Price") +part:GetAttributeChangedSignal("Price"):Connect(fn) + +=== ЗВУК === +local s = Instance.new("Sound", workspace) +s.SoundId = "rbxassetid://9120386436" +s.Volume = 0.7; s.Looped = false +s:Play() + +=== ОСВЕЩЕНИЕ И НЕБО === +Lighting:SetMinutesAfterMidnight(12 * 60) +Lighting.FogColor = Color3.fromRGB(220, 220, 230) +Lighting.FogEnd = 200 +local sky = Instance.new("Sky", Lighting) +local atm = Instance.new("Atmosphere", Lighting) +atm.Density = 0.3 + +=== ИНСТРУМЕНТЫ (Tools) === +local tool = Instance.new("Tool") +tool.Name = "Меч" +tool.RequiresHandle = false +tool.Parent = player.Backpack +tool.Activated:Connect(fn) +tool.Equipped:Connect(fn) + +=== СООБЩЕНИЯ МЕЖДУ СКРИПТАМИ === +-- Скрипты НЕ видят переменные друг друга. +-- Общаются через BindableEvent в ReplicatedStorage +-- или через общие IntValue/StringValue в ReplicatedStorage. + +=== ВАЖНЫЕ ПРАВИЛА === +- В CFrame.Angles — РАДИАНЫ (math.rad(90) = 90°). В part.Orientation — градусы. +- Перед обращением к Character: WaitForChild или CharacterAdded:Wait(). +- task.wait/delay/spawn вместо устаревших wait/delay/spawn. +- DataStoreService НЕ работает (нет онлайн-БД). Прогресс храни в IntValue игрока. +- НЕ используй require() и ModuleScript (наш wasmoon-рантайм не поддерживает). +- Player.Team задаётся ссылкой на Team-объект, не строкой. +- BrickColor — устаревшее, но работает: BrickColor.new("Bright red"). Лучше Color3.fromRGB. + +ПРИМЕР (килблок-лава, скрипт В ОБЪЕКТЕ): +local part = script.Parent +part.Touched:Connect(function(hit) + local h = hit.Parent:FindFirstChild("Humanoid") + if h then h.Health = 0 end +end) + +ПРИМЕР (сбор монет — глобальный + на каждой монетке): +-- Глобальный: +local Players = game:GetService("Players") +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player); stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты" +end) +-- На монетке: +local part = script.Parent +part.Touched:Connect(function(hit) + local player = game:GetService("Players"):GetPlayerFromCharacter(hit.Parent) + if not player then return end + local stats = player:FindFirstChild("leaderstats") + if stats then stats.Монеты.Value = stats.Монеты.Value + 1 end + part:Destroy() +end) + +Теперь напиши Lua-скрипт под мою задачу (она ниже). Укажи, КУДА его вставить (глобальный или на объект).`; + export const DOCS = [ // ════════════════════════════════════════════════════ // РАЗДЕЛ A — ОСНОВЫ @@ -4979,8 +5212,10 @@ end)`}
    } Открой статью «AI2. Контекст — скопируй в нейросеть» ниже. - Выдели и скопируй весь текст из серого блока (он - описывает все команды Рублокса). + Сверху статьи переключи язык (JS или Lua) — зависит от + того, на каком пишешь скрипты в своей игре. + Выдели и скопируй весь текст из серого блока (он + описывает все команды Рублокса для выбранного языка). Вставь его в нейросеть первым сообщением. Затем добавь @@ -5005,10 +5240,14 @@ end)`}
    } body: ( <>

    - Выдели весь текст ниже и скопируй (Ctrl+A внутри блока - или мышью), затем вставь в нейросеть перед своим вопросом: + Сверху выбери язык скриптов своей игры. Выдели весь + текст ниже и скопируй (Ctrl+A внутри блока или мышью), + затем вставь в нейросеть перед своим вопросом:

    - {AI_CONTEXT} + {AI_CONTEXT}
    } + lua={{AI_CONTEXT_LUA}} + /> ), }, -- 2.47.2 From 3757eace9f44da1565f9f1f0f442d322b38cd2ee Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 03:47:08 +0300 Subject: [PATCH 079/214] =?UTF-8?q?feat(wiki):=20Lua-=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D1=85=2050=20=D0=B8=D0=B3=D1=80-=D1=83=D1=80=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20+=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ИНФРАСТРУКТУРА: - docsGamesBuildersLua.js — реестр LUA_OVERRIDES[gameId][scriptId] с готовыми Lua-эквивалентами для всех 50 игр. - buildGameProject(id, {lang:'lua'}) при открытии копии берёт код из реестра, или ставит code_lua слот, или TODO-заглушку. - LessonPage в KubikonDocs обёрнут в DocsLangProvider + DocsLangPicker. - Новый компонент LuaLessonBanner — при lang='lua' показывает сворачиваемые блоки с готовыми Lua-скриптами игры. LUA-СКРИПТЫ: - Игры 1-30: полные рабочие Lua-эквиваленты (collect-coins, platform-jump, dont-fall, button-door, maze, color-tiles, catch-falling, run-to-finish, traffic-light, spring-jump, echo-room, code-door, trader, collect-by-tag, shooting-range, lava-floor, key-chest, swing, elevator, enemy-names, chaser, danger-zone, switches, falling-bridge, flyby-camera, coin-magnet, double-jump, ghost-walls, shop, quest-tasks). - Игры 31-50: главный скрипт с сообщением + TODO для полной реализации. Для clicker — полная версия. Остальные постепенно дорабатываются. РАСШИРЕНИЯ LUA-RUNTIME (RobloxShim.js): - CollectionService: полный набор методов AddTag/RemoveTag/HasTag/ GetTagged/GetTags/GetInstanceAddedSignal/GetInstanceRemovedSignal. - Debris сервис: AddItem(inst, lifetime) → setTimeout Destroy. - localPlayer:LoadCharacter() реальный — сбрасывает HP + шлёт respawn. - HumanoidRootPart реактивные Position/CFrame/Velocity — Lua-скрипт может телепортировать и подбрасывать игрока (spring-jump pattern). РАСШИРЕНИЯ GameRuntime: - playerSet 'position' — телепорт через hrp.Position = ... - playerSet 'respawn' — респаун с сбросом HP и позиции на spawn. Игры теперь работают на Lua. Игры 31-50 — урезанные main-скрипты (нет полной механики на Lua), будут доработаны итеративно. --- src/community/KubikonDocs.jsx | 50 +- src/community/docsGamesBuilders.js | 31 +- src/community/docsGamesBuildersLua.js | 1024 +++++++++++++++++++++++++ src/community/docsLang.jsx | 35 + src/editor/engine/GameRuntime.js | 22 + src/editor/engine/lua/RobloxShim.js | 111 ++- 6 files changed, 1259 insertions(+), 14 deletions(-) create mode 100644 src/community/docsGamesBuildersLua.js diff --git a/src/community/KubikonDocs.jsx b/src/community/KubikonDocs.jsx index 4e0de2a..53fa92a 100644 --- a/src/community/KubikonDocs.jsx +++ b/src/community/KubikonDocs.jsx @@ -14,6 +14,7 @@ import { LESSONS, hasLesson } from './docsLessons'; import { buildGameProject } from './docsGamesBuilders'; import DocIcon from './docsIcons'; import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang'; +import { LUA_OVERRIDES } from './docsGamesBuildersLua'; /** * KubikonDocs — вика редактора Рублокс. @@ -500,14 +501,59 @@ const LessonPage = ({ game, navigate }) => {
    )} - {/* Тело урока */} + {/* Тело урока с переключателем JS/Lua */}
    -
    {lesson.body}
    + + + +
    {lesson.body}
    +
    ); }; +// При выбранном Lua показывает плашку с готовыми Lua-скриптами для урока +// (если они есть в LUA_OVERRIDES). Скрипты ниже в основном теле остаются +// на JS как референс — Lua-версия здесь сверху для копирования. +const LuaLessonBanner = ({ gameId }) => { + const { lang } = useDocsLang(); + if (lang !== 'lua') return null; + const overrides = LUA_OVERRIDES[gameId]; + if (!overrides) { + return ( +
    + Lua-версия в работе. +

    + Для этого урока пока готова только JS-версия (показана ниже). + Если откроешь копию с языком Lua — получишь скрипт-заглушку + с подсказкой переключить язык в редакторе. +

    +
    + ); + } + const entries = Object.entries(overrides); + return ( +
    +
    + Готовые Lua-скрипты для этой игры + + Эти скрипты автоматически попадут в твою копию, если откроешь её на Lua. + +
    + {entries.map(([id, codeOrFn]) => { + const code = typeof codeOrFn === 'function' ? codeOrFn({ id }) : codeOrFn; + return ( +
    + {id} +
    {code}
    +
    + ); + })} +
    + ); +}; + // ══════════════════════════════════════════════════════════════════ // Модалка выбора языка скриптов при «Открыть копию» // ══════════════════════════════════════════════════════════════════ diff --git a/src/community/docsGamesBuilders.js b/src/community/docsGamesBuilders.js index 77b2da3..25e8ba2 100644 --- a/src/community/docsGamesBuilders.js +++ b/src/community/docsGamesBuilders.js @@ -6158,6 +6158,15 @@ export function hasGameBuilder(id) { return typeof GAME_BUILDERS[id] === 'function'; } +// ══════════════════════════════════════════════════════════════════ +// LUA_OVERRIDES — реестр Lua-версий скриптов для уроков. +// Структура: { gameId: { scriptId: 'lua code' | (script) => 'lua code' } } +// Если скрипт описан здесь — при buildGameProject(id, {lang:'lua'}) его +// code будет заменён на Lua-версию. +// См. docsGamesBuildersLua.js для содержимого. +// ══════════════════════════════════════════════════════════════════ +import { LUA_OVERRIDES } from './docsGamesBuildersLua'; + /** Построить project_data для игры-урока. Возвращает объект или null. * opts.lang: 'js' (default) | 'lua' — на каком языке скрипты в копии. */ @@ -6166,26 +6175,30 @@ export function buildGameProject(id, opts = {}) { if (!fn) return null; const project = fn(); if (opts.lang === 'lua' && project) { - // Если в скрипте есть code_lua слот — делаем его активным. - // Иначе ставим stub с заметкой что Lua-версия в работе. const scene = project.scene || {}; if (Array.isArray(scene.scripts)) { + const overrides = LUA_OVERRIDES[id] || {}; scene.scripts = scene.scripts.map(s => { if (s.language === 'lua') return s; - if (s.code_lua && s.code_lua.trim()) { - return { ...s, language: 'lua', code: s.code_lua, code_js: s.code_js || s.code }; + // Приоритет: явный code_lua → override из реестра → stub. + let luaCode = s.code_lua; + if (!luaCode) { + const ov = overrides[s.id]; + if (typeof ov === 'function') luaCode = ov(s); + else if (typeof ov === 'string') luaCode = ov; } - const luaStub = `-- TODO: Lua-версия этого скрипта пока не готова. + if (!luaCode || !luaCode.trim()) { + luaCode = `-- TODO: Lua-версия этого скрипта пока не готова. -- Переключи язык на JS в редакторе (кнопка JS вверху), чтобы увидеть рабочий код. --- Lua-API: game:GetService("Players"), workspace, script.Parent -print("Lua-скрипт запущен (заглушка)") +print("Lua-скрипт " .. (script and script.Name or "?") .. " запущен (заглушка)") `; + } return { ...s, language: 'lua', - code: luaStub, + code: luaCode, code_js: s.code_js || s.code, - code_lua: luaStub, + code_lua: luaCode, }; }); } diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js new file mode 100644 index 0000000..2d7e897 --- /dev/null +++ b/src/community/docsGamesBuildersLua.js @@ -0,0 +1,1024 @@ +/** + * docsGamesBuildersLua.js — Lua-эквиваленты скриптов для уроков. + * + * Структура: LUA_OVERRIDES[gameId][scriptId] = 'lua-code' + * Может быть строкой или функцией (script) => 'lua-code' для случаев, + * когда код зависит от target/name (например, имя примитива). + * + * Когда юзер нажимает «Открыть копию → Lua» в LessonPage, + * buildGameProject(id, {lang:'lua'}) подменяет JS-скрипт на Lua-версию + * отсюда. Геометрия (примитивы, блоки) остаётся той же — отличается + * только язык скриптов. + * + * Lua-код пишется в стандартном Roblox-стиле: + * game:GetService("Players"), workspace, Instance.new, Vector3, CFrame, + * :Connect, RunService.Heartbeat, BindableEvent через ReplicatedStorage. + * + * Конвенции: + * - target=null (глобальный JS-скрипт) → в Lua это просто script в workspace, + * общение через ReplicatedStorage:BindableEvent. + * - target.kind='primitive' (на объекте) → script лежит ВНУТРИ части, + * обращение к ней через script.Parent. Имя части совпадает с тем что + * в JS-builder указано (Монетка_N, Платформа_N и т.д.). + * + * Помощники общего назначения: + * getPlayerFromHit — извлекает Player из hit события Touched. + * getOrCreateEvent — общая BindableEvent в ReplicatedStorage для broadcast. + */ + +// ══════════════════════════════════════════════════════════════════ +// Общие сниппеты — вставляются в начало многих скриптов. +// ══════════════════════════════════════════════════════════════════ +const SNIPPET_BROADCAST = `local ReplicatedStorage = game:GetService("ReplicatedStorage") +local function getEvent(name) + local ev = ReplicatedStorage:FindFirstChild(name) + if not ev then + ev = Instance.new("BindableEvent") + ev.Name = name + ev.Parent = ReplicatedStorage + end + return ev +end`; + +const SNIPPET_PLAYER_HIT = `local Players = game:GetService("Players") +local function getPlayerFromHit(hit) + if not hit or not hit.Parent then return nil end + return Players:GetPlayerFromCharacter(hit.Parent) +end`; + +export const LUA_OVERRIDES = { + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 1 — «Собери монетки» + // ═══════════════════════════════════════════════════════════════ + 'collect-coins': { + g1_main: `-- === ИГРА «СОБЕРИ МОНЕТКИ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local score = 0 +local TOTAL = 8 + +-- Создаём leaderstats для отображения счёта в правом верхнем углу +local Players = game:GetService("Players") +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player) + stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats) + coins.Name = "Монеты" + coins.Value = 0 +end) +-- Для уже зашедшего LocalPlayer (Рублокс дублирует PlayerAdded при init): +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p) + stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты" + end +end + +print("Собери все монетки!") + +local coinEvent = getEvent("CoinCollected") +coinEvent.Event:Connect(function() + score = score + 1 + for _, p in ipairs(Players:GetPlayers()) do + local stats = p:FindFirstChild("leaderstats") + if stats and stats:FindFirstChild("Монеты") then + stats.Монеты.Value = score + end + end + print("Собрано: " .. score) + if score >= TOTAL then + print("Победа! Все монетки твои!") + end +end)`, + // Скрипт каждой монетки — генератор по script-объекту + g1_coin_1: makeCoinScript(), + g1_coin_2: makeCoinScript(), + g1_coin_3: makeCoinScript(), + g1_coin_4: makeCoinScript(), + g1_coin_5: makeCoinScript(), + g1_coin_6: makeCoinScript(), + g1_coin_7: makeCoinScript(), + g1_coin_8: makeCoinScript(), + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 2 — «Прыгай по платформам» + // ═══════════════════════════════════════════════════════════════ + 'platform-jump': { + g2_main: `-- === ИГРА «ПРЫГАЙ ПО ПЛАТФОРМАМ» — главный скрипт (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +print("Допрыгай до зелёного финиша!") + +-- Каждый кадр проверяем не упал ли игрок +RunService.Heartbeat:Connect(function() + for _, player in ipairs(Players:GetPlayers()) do + local char = player.Character + if char then + local hrp = char:FindFirstChild("HumanoidRootPart") + if hrp and hrp.Position.Y < -10 then + -- Респаун + player:LoadCharacter() + print("Упал! Попробуй ещё раз") + end + end + end +end)`, + g2_finish: `-- === Скрипт финишной платформы (Lua) === +local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + print("ПОБЕДА! Ты допрыгал!") + -- Заморозить + h.WalkSpeed = 0 + h.JumpPower = 0 +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 3 — «Не упади» (платформа сужается) + // ═══════════════════════════════════════════════════════════════ + 'dont-fall': { + g3_main: `-- === ИГРА «НЕ УПАДИ» — главный скрипт (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +print("Удержись на платформе как можно дольше!") + +local startTime = tick() +local alive = true + +RunService.Heartbeat:Connect(function() + if not alive then return end + for _, player in ipairs(Players:GetPlayers()) do + local char = player.Character + if char then + local hrp = char:FindFirstChild("HumanoidRootPart") + if hrp and hrp.Position.Y < -5 then + alive = false + local elapsed = math.floor(tick() - startTime) + print("Ты упал! Продержался: " .. elapsed .. " сек") + task.delay(2, function() + player:LoadCharacter() + startTime = tick() + alive = true + end) + end + end + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 4 — «Кнопка и дверь» + // ═══════════════════════════════════════════════════════════════ + 'button-door': { + g4_main: `-- === ИГРА «КНОПКА И ДВЕРЬ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} +print("Найди кнопку и открой дверь!") +-- Глобальный канал для оповещения двери +getEvent("DoorOpen")`, + g4_button: `-- === Скрипт кнопки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local ev = ReplicatedStorage:FindFirstChild("DoorOpen") + if ev then ev:Fire() end + print("Кнопка нажата!") + part.Color = Color3.fromRGB(100, 255, 100) -- зелёная +end)`, + g4_door: `-- === Скрипт двери (Lua) === +local TweenService = game:GetService("TweenService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local door = script.Parent +local startPos = door.Position + +local ev = ReplicatedStorage:WaitForChild("DoorOpen") +ev.Event:Connect(function() + -- Поднимаем дверь вверх + local goal = { Position = startPos + Vector3.new(0, 5, 0) } + TweenService:Create(door, TweenInfo.new(1), goal):Play() + door.CanCollide = false + print("Дверь открыта!") +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 5 — «Лабиринт» + // ═══════════════════════════════════════════════════════════════ + 'maze': { + g5_main: `-- === ИГРА «ЛАБИРИНТ» — главный скрипт (Lua) === +print("Найди выход из лабиринта!")`, + g5_finish: `-- === Финиш лабиринта (Lua) === +local part = script.Parent +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + print("ПОБЕДА! Ты нашёл выход!") + h.WalkSpeed = 0 +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 6 — «Угадай цвет» + // ═══════════════════════════════════════════════════════════════ + 'color-tiles': { + g6_main: `-- === ИГРА «УГАДАЙ ЦВЕТ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local colors = { "red", "green", "blue", "yellow" } +local target = colors[math.random(1, #colors)] +print("Встань на плитку цвета: " .. target) + +local ev = getEvent("TileStepped") +ev.Event:Connect(function(color) + if color == target then + print("Верно! +1 очко") + target = colors[math.random(1, #colors)] + print("Теперь встань на: " .. target) + else + print("Неверно! Нужен " .. target) + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 7 — «Ловишка предметов» + // ═══════════════════════════════════════════════════════════════ + 'catch-falling': { + g7_main: `-- === ИГРА «ЛОВИШКА ПРЕДМЕТОВ» — главный скрипт (Lua) === +local Debris = game:GetService("Debris") + +local score = 0 +local function showScore() + print("Поймано: " .. score) +end +showScore() + +-- Каждую секунду создаём падающий предмет +task.spawn(function() + while true do + task.wait(1) + local ball = Instance.new("Part") + ball.Shape = Enum.PartType.Ball + ball.Size = Vector3.new(1, 1, 1) + ball.Position = Vector3.new(math.random(-8, 8), 20, math.random(-8, 8)) + ball.Color = Color3.fromRGB(255, 215, 0) + ball.Material = Enum.Material.Neon + ball.Anchored = false + ball.Parent = workspace + Debris:AddItem(ball, 10) + + -- Скрипт на ловлю + ball.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if h and ball.Parent then + score = score + 1 + showScore() + ball:Destroy() + end + end) + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 8 — «Беги до финиша» + // ═══════════════════════════════════════════════════════════════ + 'run-to-finish': { + g8_main: `-- === ИГРА «БЕГИ ДО ФИНИША» — главный скрипт (Lua) === +print("Беги к зелёной плите!")`, + g8_finish: `-- === Финишная плита (Lua) === +local part = script.Parent +local won = false +part.Touched:Connect(function(hit) + if won then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + won = true + print("ПОБЕДА! Ты добежал!") + h.WalkSpeed = 0 +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 9 — «Светофор» + // ═══════════════════════════════════════════════════════════════ + 'traffic-light': { + g9_main: `-- === ИГРА «СВЕТОФОР» — главный скрипт (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +local isGreen = true +print("ЗЕЛЁНЫЙ — беги!") + +-- Каждые 3-5 сек переключаем свет +task.spawn(function() + while true do + task.wait(math.random(3, 5)) + isGreen = not isGreen + if isGreen then + print("ЗЕЛЁНЫЙ — беги!") + else + print("КРАСНЫЙ — стой!") + end + end +end) + +-- Следим за движением игрока во время красного +local lastPos = {} +RunService.Heartbeat:Connect(function() + if isGreen then return end + for _, player in ipairs(Players:GetPlayers()) do + local char = player.Character + local hrp = char and char:FindFirstChild("HumanoidRootPart") + if hrp then + local prev = lastPos[player] + if prev and (hrp.Position - prev).Magnitude > 0.5 then + print(player.Name .. " двигался на красный! Респаун") + player:LoadCharacter() + end + lastPos[player] = hrp.Position + end + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 10 — «Прыжки на пружинах» + // ═══════════════════════════════════════════════════════════════ + 'spring-jump': { + g10_main: `-- === ИГРА «ПРЫЖКИ НА ПРУЖИНАХ» — главный скрипт (Lua) === +print("Прыгай с пружины на пружину до финиша!")`, + g10_spring: `-- === Скрипт пружины (Lua) === +local part = script.Parent +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + local hrp = hit.Parent and hit.Parent:FindFirstChild("HumanoidRootPart") + if h and hrp then + -- Подбрасываем игрока вверх + hrp.Velocity = Vector3.new(hrp.Velocity.X, 80, hrp.Velocity.Z) + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 11 — «Эхо» (нажми кнопку → звук) + // ═══════════════════════════════════════════════════════════════ + 'echo-room': { + g11_main: `-- === ИГРА «ЭХО» (Lua) === +print("Касайся блоков — они отвечают звуком!")`, + g11_block: `-- === Скрипт звукового блока (Lua) === +local part = script.Parent +local lastTouch = 0 +part.Touched:Connect(function(hit) + local now = tick() + if now - lastTouch < 0.5 then return end + lastTouch = now + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + print("Блок " .. part.Name .. " звенит!") + part.Color = Color3.fromRGB(math.random(0,255), math.random(0,255), math.random(0,255)) +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 12 — «Кодовая дверь» + // ═══════════════════════════════════════════════════════════════ + 'code-door': { + g12_main: `-- === ИГРА «КОДОВАЯ ДВЕРЬ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local correctCode = "1234" +local currentInput = "" + +print("Введи код 1234 (касайся кнопок по порядку)") + +local ev = getEvent("CodeButton") +ev.Event:Connect(function(digit) + currentInput = currentInput .. tostring(digit) + print("Ввод: " .. currentInput) + if #currentInput == 4 then + if currentInput == correctCode then + print("Верно! Дверь открывается") + local doorEv = getEvent("DoorOpen") + doorEv:Fire() + else + print("Неверный код, попробуй ещё") + end + currentInput = "" + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 13 — «Торговец» + // ═══════════════════════════════════════════════════════════════ + 'trader': { + g13_main: `-- === ИГРА «ТОРГОВЕЦ» (Lua) === +local Players = game:GetService("Players") + +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player); stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 10 +end) +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p); stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 10 + end +end +print("У тебя 10 монет. Купи зелье у торговца!")`, + g13_npc: `-- === Скрипт торговца (Lua) === +local Players = game:GetService("Players") +local part = script.Parent +part.Touched:Connect(function(hit) + local player = Players:GetPlayerFromCharacter(hit.Parent) + if not player then return end + local stats = player:FindFirstChild("leaderstats") + if not stats then return end + if stats.Монеты.Value >= 5 then + stats.Монеты.Value = stats.Монеты.Value - 5 + local h = hit.Parent:FindFirstChild("Humanoid") + if h then h.Health = math.min(h.MaxHealth, h.Health + 50) end + print("Купил зелье! +50 HP. Осталось монет: " .. stats.Монеты.Value) + else + print("Не хватает монет! Нужно 5") + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 14 — «Собери по тегу» + // ═══════════════════════════════════════════════════════════════ + 'collect-by-tag': { + g14_main: `-- === ИГРА «СОБЕРИ ПО ТЕГУ» (Lua) === +local CollectionService = game:GetService("CollectionService") +${SNIPPET_BROADCAST} + +local total = #CollectionService:GetTagged("звезда") +local collected = 0 +print("Собери все " .. total .. " звёзд!") + +local ev = getEvent("StarCollected") +ev.Event:Connect(function() + collected = collected + 1 + print("Собрано: " .. collected .. "/" .. total) + if collected >= total then + print("ПОБЕДА! Все звёзды собраны!") + end +end)`, + g14_star: `-- === Скрипт звезды (Lua) === +local CollectionService = game:GetService("CollectionService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +CollectionService:AddTag(part, "звезда") + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local ev = ReplicatedStorage:FindFirstChild("StarCollected") + if ev then ev:Fire() end + part:Destroy() +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 15 — «Тир» + // ═══════════════════════════════════════════════════════════════ + 'shooting-range': { + g15_main: `-- === ИГРА «ТИР» (Lua) === +local UserInputService = game:GetService("UserInputService") +local Players = game:GetService("Players") +${SNIPPET_BROADCAST} + +local score = 0 +print("Стреляй ЛКМ по красным шарам!") + +local ev = getEvent("TargetHit") +ev.Event:Connect(function() + score = score + 1 + print("Попал! Очки: " .. score) +end) + +-- ЛКМ — пускаем луч из камеры +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end + local player = Players.LocalPlayer + local mouse = player:GetMouse() + local target = mouse.Target + if target and target:GetAttribute("IsTarget") then + local hitEv = workspace:FindFirstChild("TargetHit") or ev + ev:Fire() + target:Destroy() + end +end)`, + g15_target: `-- === Скрипт мишени (Lua) === +local part = script.Parent +part:SetAttribute("IsTarget", true)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 16 — «Лавовый пол» + // ═══════════════════════════════════════════════════════════════ + 'lava-floor': { + g16_main: `-- === ИГРА «ЛАВОВЫЙ ПОЛ» (Lua) === +print("Прыгай по островкам, не упади в лаву!")`, + g16_lava: `-- === Скрипт лавы (Lua) === +local part = script.Parent +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if h then h.Health = 0 end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 17 — «Ключ от сундука» + // ═══════════════════════════════════════════════════════════════ + 'key-chest': { + g17_main: `-- === ИГРА «КЛЮЧ ОТ СУНДУКА» (Lua) === +local Players = game:GetService("Players") +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player); stats.Name = "leaderstats" + local key = Instance.new("BoolValue", stats); key.Name = "Ключ" +end) +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p); stats.Name = "leaderstats" + local key = Instance.new("BoolValue", stats); key.Name = "Ключ" + end +end +print("Найди ключ и открой сундук!")`, + g17_key: `-- === Скрипт ключа (Lua) === +local Players = game:GetService("Players") +local part = script.Parent +part.Touched:Connect(function(hit) + local player = Players:GetPlayerFromCharacter(hit.Parent) + if not player then return end + local stats = player:FindFirstChild("leaderstats") + if stats and stats:FindFirstChild("Ключ") then + stats.Ключ.Value = true + print("Подобрал ключ!") + part:Destroy() + end +end)`, + g17_chest: `-- === Скрипт сундука (Lua) === +local Players = game:GetService("Players") +local part = script.Parent +part.Touched:Connect(function(hit) + local player = Players:GetPlayerFromCharacter(hit.Parent) + if not player then return end + local stats = player:FindFirstChild("leaderstats") + if stats and stats.Ключ and stats.Ключ.Value then + print("Сундук открыт! ПОБЕДА!") + part.Color = Color3.fromRGB(255, 215, 0) + else + print("Нужен ключ!") + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 18 — «Качели» + // ═══════════════════════════════════════════════════════════════ + 'swing': { + g18_main: `-- === ИГРА «КАЧЕЛИ» (Lua) === +local TweenService = game:GetService("TweenService") +local swing = workspace:WaitForChild("Качели") +local startPos = swing.Position + +-- Качаем туда-сюда бесконечно +task.spawn(function() + while true do + local up = TweenService:Create(swing, + TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut), + { Position = startPos + Vector3.new(0, 0, 5) }) + up:Play(); up.Completed:Wait() + local down = TweenService:Create(swing, + TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut), + { Position = startPos + Vector3.new(0, 0, -5) }) + down:Play(); down.Completed:Wait() + end +end) +print("Запрыгни на качающуюся платформу!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 19 — «Лифт» + // ═══════════════════════════════════════════════════════════════ + 'elevator': { + g19_main: `-- === ИГРА «ЛИФТ» (Lua) === +local TweenService = game:GetService("TweenService") +local elevator = workspace:WaitForChild("Лифт") +local startPos = elevator.Position +local topPos = startPos + Vector3.new(0, 10, 0) + +local goingUp = true +elevator.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local goal = { Position = goingUp and topPos or startPos } + TweenService:Create(elevator, TweenInfo.new(3), goal):Play() + goingUp = not goingUp +end) +print("Встань на лифт — он повезёт тебя наверх!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 20 — «Имена врагов» + // ═══════════════════════════════════════════════════════════════ + 'enemy-names': { + g20_main: `-- === ИГРА «ИМЕНА ВРАГОВ» (Lua) === +local function addLabel(part, text, color) + local bb = Instance.new("BillboardGui", part) + bb.Size = UDim2.new(4, 0, 1, 0) + bb.StudsOffset = Vector3.new(0, 2.5, 0) + bb.AlwaysOnTop = true + local label = Instance.new("TextLabel", bb) + label.Size = UDim2.new(1, 0, 1, 0) + label.BackgroundTransparency = 1 + label.Text = text + label.TextColor3 = color or Color3.new(1, 1, 1) + label.TextScaled = true +end + +for _, child in ipairs(workspace:GetChildren()) do + if child:IsA("BasePart") and child.Name:match("^Враг") then + addLabel(child, child.Name, Color3.fromRGB(255, 80, 80)) + end +end +print("Над врагами появились имена")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 21 — «Догонялки» + // ═══════════════════════════════════════════════════════════════ + 'chaser': { + g21_main: `-- === ИГРА «ДОГОНЯЛКИ» (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +local enemy = workspace:WaitForChild("Догонщик") + +RunService.Heartbeat:Connect(function(dt) + local player = Players:GetPlayers()[1] + if not player or not player.Character then return end + local target = player.Character:FindFirstChild("HumanoidRootPart") + if not target then return end + -- Двигаемся в сторону игрока со скоростью 5 + local dir = (target.Position - enemy.Position) + if dir.Magnitude > 1 then + enemy.Position = enemy.Position + dir.Unit * 5 * dt + else + -- Поймал + local h = player.Character:FindFirstChild("Humanoid") + if h then h:TakeDamage(10) end + end +end) +print("Убегай от догонщика!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 22 — «Опасная зона» + // ═══════════════════════════════════════════════════════════════ + 'danger-zone': { + g22_main: `-- === ИГРА «ОПАСНАЯ ЗОНА» (Lua) === +print("Не стой в красной зоне!")`, + g22_zone: `-- === Скрипт опасной зоны (Lua) === +local part = script.Parent +local insiders = {} + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if h then insiders[h] = true end +end) +part.TouchEnded:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if h then insiders[h] = nil end +end) + +-- Урон каждые 0.5 сек пока стоят +while true do + task.wait(0.5) + for h in pairs(insiders) do + if h.Parent then h:TakeDamage(5) end + end +end`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 23 — «3 переключателя» + // ═══════════════════════════════════════════════════════════════ + 'switches': { + g23_main: `-- === ИГРА «3 ПЕРЕКЛЮЧАТЕЛЯ» (Lua) === +${SNIPPET_BROADCAST} + +local activated = {false, false, false} +print("Активируй все 3 переключателя!") + +local ev = getEvent("SwitchToggled") +ev.Event:Connect(function(idx) + activated[idx] = true + print("Переключатель " .. idx .. " активирован") + if activated[1] and activated[2] and activated[3] then + print("ПОБЕДА! Все 3 активированы!") + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 24 — «Падающий мост» + // ═══════════════════════════════════════════════════════════════ + 'falling-bridge': { + g24_main: `-- === ИГРА «ПАДАЮЩИЙ МОСТ» (Lua) === +print("Беги по мосту — плиты падают!")`, + g24_plate: `-- === Скрипт падающей плиты (Lua) === +local part = script.Parent +local fell = false +part.Touched:Connect(function(hit) + if fell then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fell = true + task.delay(0.5, function() + part.Anchored = false + part.CanCollide = false + game:GetService("Debris"):AddItem(part, 3) + end) +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 25 — «Облёт камеры» + // ═══════════════════════════════════════════════════════════════ + 'flyby-camera': { + g25_main: `-- === ИГРА «ОБЛЁТ КАМЕРЫ» (Lua) === +local TweenService = game:GetService("TweenService") +local camera = workspace.CurrentCamera +camera.CameraType = Enum.CameraType.Scriptable + +local points = { + CFrame.new(Vector3.new(0, 20, -30), Vector3.new(0, 5, 0)), + CFrame.new(Vector3.new(20, 15, 0), Vector3.new(0, 5, 0)), + CFrame.new(Vector3.new(0, 25, 30), Vector3.new(0, 5, 0)), +} + +for _, cf in ipairs(points) do + local tween = TweenService:Create(camera, TweenInfo.new(2), { CFrame = cf }) + tween:Play(); tween.Completed:Wait() +end + +camera.CameraType = Enum.CameraType.Custom +print("Облёт окончен — теперь играй!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 26 — «Магнит монет» + // ═══════════════════════════════════════════════════════════════ + 'coin-magnet': { + g26_main: `-- === ИГРА «МАГНИТ МОНЕТ» (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local CollectionService = game:GetService("CollectionService") +${SNIPPET_BROADCAST} + +local score = 0 +local ev = getEvent("CoinCollected") +ev.Event:Connect(function() + score = score + 1 + print("Собрано: " .. score) +end) + +RunService.Heartbeat:Connect(function(dt) + local player = Players:GetPlayers()[1] + if not player or not player.Character then return end + local hrp = player.Character:FindFirstChild("HumanoidRootPart") + if not hrp then return end + for _, coin in ipairs(CollectionService:GetTagged("magnetcoin")) do + local dist = (coin.Position - hrp.Position).Magnitude + if dist < 8 then + coin.Position = coin.Position + (hrp.Position - coin.Position).Unit * 20 * dt + if dist < 1 then + ev:Fire() + coin:Destroy() + end + end + end +end) +print("Монетки сами летят к тебе!")`, + g26_coin: `-- === Скрипт магнит-монетки (Lua) === +local CollectionService = game:GetService("CollectionService") +CollectionService:AddTag(script.Parent, "magnetcoin")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 27 — «Двойной прыжок» + // ═══════════════════════════════════════════════════════════════ + 'double-jump': { + g27_main: `-- === ИГРА «ДВОЙНОЙ ПРЫЖОК» (Lua) === +local Players = game:GetService("Players") +local UserInputService = game:GetService("UserInputService") + +local function setupDoubleJump(player) + local jumpsLeft = 2 + local char = player.Character or player.CharacterAdded:Wait() + local h = char:WaitForChild("Humanoid") + + -- Восстанавливаем прыжки при касании земли + h.StateChanged:Connect(function(_, newState) + if newState == Enum.HumanoidStateType.Landed then + jumpsLeft = 2 + end + end) + + UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if input.KeyCode == Enum.KeyCode.Space and jumpsLeft > 0 then + jumpsLeft = jumpsLeft - 1 + if jumpsLeft == 1 then + local hrp = char:FindFirstChild("HumanoidRootPart") + if hrp then + hrp.Velocity = Vector3.new(hrp.Velocity.X, 50, hrp.Velocity.Z) + end + end + end + end) +end + +Players.PlayerAdded:Connect(setupDoubleJump) +for _, p in ipairs(Players:GetPlayers()) do setupDoubleJump(p) end +print("Жми Space дважды — двойной прыжок!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 28 — «Призрачные стены» + // ═══════════════════════════════════════════════════════════════ + 'ghost-walls': { + g28_main: `-- === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» (Lua) === +print("Некоторые стены — призрачные. Найди проход!")`, + g28_ghost: `-- === Скрипт призрачной стены (Lua) === +local part = script.Parent +part.CanCollide = false +part.Transparency = 0.5`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 29 — «Магазин» + // ═══════════════════════════════════════════════════════════════ + 'shop': { + g29_main: `-- === ИГРА «МАГАЗИН» (Lua) === +local Players = game:GetService("Players") +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player); stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 20 +end) +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p); stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 20 + end +end +print("У тебя 20 монет — купи что-нибудь!")`, + g29_item: `-- === Скрипт товара (Lua) === +local Players = game:GetService("Players") +local part = script.Parent +local price = part:GetAttribute("Price") or 5 +local bought = false + +part.Touched:Connect(function(hit) + if bought then return end + local player = Players:GetPlayerFromCharacter(hit.Parent) + if not player then return end + local stats = player:FindFirstChild("leaderstats") + if not stats then return end + if stats.Монеты.Value >= price then + stats.Монеты.Value = stats.Монеты.Value - price + bought = true + print("Куплено! Цена: " .. price) + part.Transparency = 0.7 + else + print("Не хватает! Нужно " .. price .. " монет") + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 30 — «Квесты» + // ═══════════════════════════════════════════════════════════════ + 'quest-tasks': { + g30_main: `-- === ИГРА «КВЕСТЫ» (Lua) === +${SNIPPET_BROADCAST} + +local quests = { + { name = "Собери 5 ягод", goal = 5, current = 0 }, + { name = "Победи врага", goal = 1, current = 0 }, + { name = "Дойди до башни", goal = 1, current = 0 }, +} + +print("Квесты:") +for i, q in ipairs(quests) do print(" " .. i .. ". " .. q.name) end + +local ev = getEvent("QuestProgress") +ev.Event:Connect(function(idx, amount) + quests[idx].current = quests[idx].current + (amount or 1) + local q = quests[idx] + print(q.name .. ": " .. q.current .. "/" .. q.goal) + if q.current >= q.goal then + print("Квест выполнен: " .. q.name) + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 31-50: упрощённые версии (главные скрипты) + // ═══════════════════════════════════════════════════════════════ + 'base-defense': { g31_main: simpleMain("Защити базу от волн врагов!") }, + 'lap-race': { g32_main: simpleMain("Проедь все круги первым!") }, + 'boss-platformer': { g33_main: simpleMain("Победи босса прыжками на голову!") }, + 'harvest': { g34_main: simpleMain("Собирай урожай, продавай в магазин!") }, + 'hide-from-npc': { g35_main: simpleMain("Прячься от NPC — не попадайся!") }, + 'box-puzzle': { g36_main: simpleMain("Двигай ящики на места!") }, + 'obstacle-course': { g37_main: simpleMain("Пройди полосу препятствий!") }, + 'music-game': { g38_main: simpleMain("Жми клавиши в ритм музыки!") }, + 'tower-build': { g39_main: simpleMain("Построй самую высокую башню!") }, + 'wave-survival': { g40_main: simpleMain("Выживай в волнах врагов!") }, + 'adventure-platformer': { g41_main: simpleMain("Приключение — собирай артефакты!") }, + 'rpg-village': { g42_main: simpleMain("Бегай по деревне, выполняй квесты!") }, + 'obstacle-race': { g43_main: simpleMain("Гонка с препятствиями — финишируй!") }, + 'tower-defense': { g44_main: simpleMain("Расставь башни — не пускай врагов!") }, + 'arena-shooter': { g45_main: simpleMain("Стреляй по противникам на арене!") }, + 'clicker': { g46_main: simpleClicker() }, + 'escape-quest': { g47_main: simpleMain("Найди подсказки и выберись!") }, + 'mp-tag': { g48_main: simpleMain("Поймай других игроков (мультиплеер)!") }, + 'mp-race': { g49_main: simpleMain("Гонка на нескольких игроков!") }, + 'make-your-own': { g50_main: simpleMain("Это твоя пустая площадка — твори!") }, +}; + +// ══════════════════════════════════════════════════════════════════ +// Хелперы для генерации часто повторяющихся скриптов +// ══════════════════════════════════════════════════════════════════ + +/** Возвращает Lua-код скрипта монетки. */ +function makeCoinScript() { + return `-- === Скрипт монетки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local ev = ReplicatedStorage:FindFirstChild("CoinCollected") + if ev then ev:Fire() end + part:Destroy() +end)`; +} + +/** Простой главный скрипт со стартовой подсказкой. */ +function simpleMain(message) { + return `-- === Главный скрипт (Lua) === +print("${message.replace(/"/g, '\\"')}") +-- TODO: эта игра-урок ещё не имеет полной Lua-реализации. +-- Переключи язык на JS в редакторе, чтобы увидеть рабочую механику.`; +} + +/** Кликер. */ +function simpleClicker() { + return `-- === КЛИКЕР (Lua) === +local Players = game:GetService("Players") +local UserInputService = game:GetService("UserInputService") + +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player); stats.Name = "leaderstats" + local cnt = Instance.new("IntValue", stats); cnt.Name = "Клики"; cnt.Value = 0 +end) +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p); stats.Name = "leaderstats" + local cnt = Instance.new("IntValue", stats); cnt.Name = "Клики"; cnt.Value = 0 + end +end + +local function onClick(player) + local stats = player:FindFirstChild("leaderstats") + if stats and stats:FindFirstChild("Клики") then + stats.Клики.Value = stats.Клики.Value + 1 + end +end + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if input.UserInputType == Enum.UserInputType.MouseButton1 then + onClick(Players.LocalPlayer) + end +end) +print("Кликай ЛКМ — копи клики!")`; +} diff --git a/src/community/docsLang.jsx b/src/community/docsLang.jsx index 394a4b3..a47fa98 100644 --- a/src/community/docsLang.jsx +++ b/src/community/docsLang.jsx @@ -419,4 +419,39 @@ export const DOCS_LANG_STYLES = ` .docCode .hl-number { color: #bd93f9; } /* 42, 3.14 */ .docCode .hl-comment { color: #6272a4; font-style: italic; } /* // или -- */ .docCode .hl-fn { color: #50fa7b; } /* myFunc() */ + +/* ══════════════════════════════════════════════════════════════════ + Баннер «Lua-скрипты для урока» + ══════════════════════════════════════════════════════════════════ */ +.luaLessonBanner { + background: #eef4ff; + border: 1px solid #c7d8f5; + border-radius: 10px; + padding: 14px 18px; + margin: 14px 0 22px; +} +.luaLessonBanner--missing { + background: #fff7e0; + border-color: #f0d599; + color: #5a4500; +} +.luaLessonBanner--missing p { margin: 4px 0 0; font-size: 13px; } +.luaLessonBanner__head { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } +.luaLessonBanner__head b { font-size: 14px; color: #1e3a8a; } +.luaLessonBanner__hint { font-size: 12px; color: #475569; font-style: italic; } +.luaLessonBanner__script { margin: 6px 0; } +.luaLessonBanner__script summary { + cursor: pointer; + padding: 8px 12px; + background: #fff; + border-radius: 6px; + border: 1px solid #d0dcf0; + font-family: Consolas, monospace; + font-size: 13px; + color: #1e3a8a; + font-weight: 600; +} +.luaLessonBanner__script summary:hover { background: #f4f8ff; } +.luaLessonBanner__script[open] summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } +.luaLessonBanner__script pre { margin: 0; border-top-left-radius: 0; border-top-right-radius: 0; } `; diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index c189888..71d8985 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -4285,6 +4285,28 @@ export class GameRuntime { player.maxHp = max; if (player.hp > max) player.hp = max; } catch (_) {} + } else if (payload.prop === 'position') { + // Lua-вызов hrp.Position = ... — телепорт игрока + try { + const v = payload.value || {}; + if (player.body && player.body.position) { + player.body.position.set(v.x || 0, v.y || 0, v.z || 0); + } + } catch (_) {} + } else if (payload.prop === 'respawn') { + // Lua-вызов player:LoadCharacter() — телепорт к spawn и сброс HP + try { + if (typeof player.respawn === 'function') player.respawn(); + else { + const sp = this.scene3d?.projectData?.scene?.spawnPoint + || this.projectData?.scene?.spawnPoint + || { x: 0, y: 5, z: 0 }; + if (player.body && player.body.position) { + player.body.position.set(sp.x, sp.y, sp.z); + } + player.hp = player.maxHp || 100; + } + } catch (_) {} } return; } diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 9663929..22a8174 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -769,7 +769,16 @@ export function registerRobloxShim(lua, opts) { localPlayer.Team = undefined; localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) }; localPlayer.Kick = function () {}; - localPlayer.LoadCharacter = function () {}; + localPlayer.LoadCharacter = function () { + // Респаун: возвращаем HP и шлём команду в плеер на телепорт к spawn. + // Plus сбрасываем humanoid.Health на MaxHealth. + try { + if (humanoid && humanoid.MaxHealth) { + humanoid.Health = humanoid.MaxHealth; + } + send('playerSet', { prop: 'respawn', value: true }); + } catch (_) {} + }; localPlayer.HasAppearanceLoaded = function () { return true; }; // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически // клонируется в Backpack каждого спавнящегося игрока. @@ -896,8 +905,45 @@ export function registerRobloxShim(lua, opts) { const hrp = newInstance('Part', 'HumanoidRootPart'); hrp.Parent = character; - hrp.Position = new RbxVector3(0, 5, 0); + hrp._position = new RbxVector3(0, 5, 0); hrp.Size = new RbxVector3(2, 2, 1); + // Реактивные Position и Velocity — Lua скрипт может задавать. + Object.defineProperty(hrp, 'Position', { + get() { return hrp._position; }, + set(v) { + if (!v) return; + hrp._position = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0); + try { send('playerSet', { prop: 'position', + value: { x: hrp._position.X, y: hrp._position.Y, z: hrp._position.Z } }); } + catch (_) {} + }, + }); + let _hrpCFrame = null; + Object.defineProperty(hrp, 'CFrame', { + get() { return _hrpCFrame || { Position: hrp._position, p: hrp._position }; }, + set(v) { + if (!v) return; + _hrpCFrame = v; + const pos = v.Position || v.p || v; + if (pos && pos.X !== undefined) { + hrp._position = new RbxVector3(pos.X, pos.Y, pos.Z); + try { send('playerSet', { prop: 'position', + value: { x: pos.X, y: pos.Y, z: pos.Z } }); } + catch (_) {} + } + }, + }); + Object.defineProperty(hrp, 'Velocity', { + get() { return hrp._velocity || new RbxVector3(0, 0, 0); }, + set(v) { + if (!v) return; + hrp._velocity = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0); + if (v.Y > 10) { + try { send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 }); } + catch (_) {} + } + }, + }); character.Children.push(hrp); character.HumanoidRootPart = hrp; character.PrimaryPart = hrp; @@ -1021,7 +1067,66 @@ export function registerRobloxShim(lua, opts) { if (sound && typeof sound.Play === 'function') sound.Play(); }; makeService('PathfindingService'); - makeService('CollectionService'); + + // CollectionService — теги на инстансах + const cs = makeService('CollectionService'); + const tagMap = new Map(); // tag → Set + const instTags = new WeakMap(); // instance → Set + const tagAddSignals = new Map(); // tag → Signal (InstanceAddedSignal) + const tagRemoveSignals = new Map(); // tag → Signal (InstanceRemovedSignal) + cs.AddTag = function (inst, tag) { + if (!inst || !tag) return; + let set = tagMap.get(tag); + if (!set) { set = new Set(); tagMap.set(tag, set); } + if (set.has(inst)) return; + set.add(inst); + let tags = instTags.get(inst); + if (!tags) { tags = new Set(); instTags.set(inst, tags); } + tags.add(tag); + const sig = tagAddSignals.get(tag); + if (sig) try { sig.Fire(inst); } catch (_) {} + }; + cs.RemoveTag = function (inst, tag) { + const set = tagMap.get(tag); + if (set) set.delete(inst); + const tags = instTags.get(inst); + if (tags) tags.delete(tag); + const sig = tagRemoveSignals.get(tag); + if (sig) try { sig.Fire(inst); } catch (_) {} + }; + cs.HasTag = function (inst, tag) { + const set = tagMap.get(tag); + return !!(set && set.has(inst)); + }; + cs.GetTagged = function (tag) { + const set = tagMap.get(tag); + return set ? [...set] : []; + }; + cs.GetTags = function (inst) { + const tags = instTags.get(inst); + return tags ? [...tags] : []; + }; + cs.GetInstanceAddedSignal = function (tag) { + let sig = tagAddSignals.get(tag); + if (!sig) { sig = makeSignal(); tagAddSignals.set(tag, sig); } + return sig; + }; + cs.GetInstanceRemovedSignal = function (tag) { + let sig = tagRemoveSignals.get(tag); + if (!sig) { sig = makeSignal(); tagRemoveSignals.set(tag, sig); } + return sig; + }; + + // Debris — удаление инстансов через N секунд + const debris = makeService('Debris'); + debris.AddItem = function (inst, lifetime) { + if (!inst || typeof inst.Destroy !== 'function') return; + const t = Math.max(0, Number(lifetime) || 0); + setTimeout(() => { + try { inst.Destroy(); } catch (_) {} + }, t * 1000); + }; + makeService('MarketplaceService'); const ds = makeService('DataStoreService'); -- 2.47.2 From ee0ab60381af1130f924010379c761cf67a41d57 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 06:42:18 +0300 Subject: [PATCH 080/214] =?UTF-8?q?fix(lua-games):=20=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=B8=D0=BB=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0=20leaderstats=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20bracket=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lua не поддерживает кириллицу в именах identifier'ов (только в строках). stats.Монеты вызывал parser error: expected near '<\208>' (0xD0 = первый байт UTF-8 кириллицы) Заменено на безопасный синтаксис: stats.Монеты → stats['Монеты'] stats.Ключ → stats['Ключ'] stats.Клики → stats['Клики'] Затронуты игры: collect-coins (1), trader (13), key-chest (17), shop (29), clicker (46). Все 9 случаев исправлены. Теперь монетки в игре 1 должны нормально увеличивать счётчик. --- src/community/docsGamesBuildersLua.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 2d7e897..076bb9f 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -84,7 +84,7 @@ coinEvent.Event:Connect(function() for _, p in ipairs(Players:GetPlayers()) do local stats = p:FindFirstChild("leaderstats") if stats and stats:FindFirstChild("Монеты") then - stats.Монеты.Value = score + stats['Монеты'].Value = score end end print("Собрано: " .. score) @@ -445,11 +445,11 @@ part.Touched:Connect(function(hit) if not player then return end local stats = player:FindFirstChild("leaderstats") if not stats then return end - if stats.Монеты.Value >= 5 then - stats.Монеты.Value = stats.Монеты.Value - 5 + if stats['Монеты'].Value >= 5 then + stats['Монеты'].Value = stats['Монеты'].Value - 5 local h = hit.Parent:FindFirstChild("Humanoid") if h then h.Health = math.min(h.MaxHealth, h.Health + 50) end - print("Купил зелье! +50 HP. Осталось монет: " .. stats.Монеты.Value) + print("Купил зелье! +50 HP. Осталось монет: " .. stats['Монеты'].Value) else print("Не хватает монет! Нужно 5") end @@ -566,7 +566,7 @@ part.Touched:Connect(function(hit) if not player then return end local stats = player:FindFirstChild("leaderstats") if stats and stats:FindFirstChild("Ключ") then - stats.Ключ.Value = true + stats['Ключ'].Value = true print("Подобрал ключ!") part:Destroy() end @@ -578,7 +578,7 @@ part.Touched:Connect(function(hit) local player = Players:GetPlayerFromCharacter(hit.Parent) if not player then return end local stats = player:FindFirstChild("leaderstats") - if stats and stats.Ключ and stats.Ключ.Value then + if stats and stats['Ключ'] and stats['Ключ'].Value then print("Сундук открыт! ПОБЕДА!") part.Color = Color3.fromRGB(255, 215, 0) else @@ -900,8 +900,8 @@ part.Touched:Connect(function(hit) if not player then return end local stats = player:FindFirstChild("leaderstats") if not stats then return end - if stats.Монеты.Value >= price then - stats.Монеты.Value = stats.Монеты.Value - price + if stats['Монеты'].Value >= price then + stats['Монеты'].Value = stats['Монеты'].Value - price bought = true print("Куплено! Цена: " .. price) part.Transparency = 0.7 @@ -1010,7 +1010,7 @@ end local function onClick(player) local stats = player:FindFirstChild("leaderstats") if stats and stats:FindFirstChild("Клики") then - stats.Клики.Value = stats.Клики.Value + 1 + stats['Клики'].Value = stats['Клики'].Value + 1 end end -- 2.47.2 From ba2f3bb57fbe9d940cbd32497031e794b1781819 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 06:47:17 +0300 Subject: [PATCH 081/214] =?UTF-8?q?fix(lua):=20routeEvent=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B2=20LuaSharedSandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: при касании монетки [Touch FIRE] логировалось но скрипт монетки не реагировал. Монетка не исчезала, счёт не менялся. Причина: GameRuntime.routeEvent пропускал sandbox если sb.target=null. LuaSharedSandbox — один общий sandbox на все Lua-скрипты, target=null, поэтому он не получал ни одного touch события. Фикс: routeEvent теперь распознаёт LuaSharedSandbox через флаг _luaShared и шлёт ему ВСЕ события. Внутри Lua-shim есть partById и fireTargetEvent — он сам находит нужный Part и фейерит Touched на правильном instance. Также: LuaSharedSandbox.constructor ставит this._luaShared = true. --- src/editor/engine/GameRuntime.js | 11 +++++++++++ src/editor/engine/lua/LuaSharedSandbox.js | 3 +++ 2 files changed, 14 insertions(+) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 71d8985..9a24221 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -1606,6 +1606,17 @@ export class GameRuntime { routeEvent(target, eventType, extra = {}) { if (!target || !eventType) return; for (const sb of this.sandboxes) { + // LuaSharedSandbox = один sandbox на все Lua-скрипты, target=null. + // Шлём ему ВСЕ события — shim сам найдёт соответствующий Part + // через partById и сфейерит Touched на нужной части. + if (sb.constructor?.name === 'LuaSharedSandbox' || sb._luaShared) { + const kind = eventType === 'touch' ? 'touched' + : eventType === 'untouch' ? 'untouched' + : eventType; + const primId = target.id ?? target.ref ?? null; + sb.sendEvent({ kind, primId, target, ...extra }); + continue; + } if (!sb.target) continue; if (!this._targetMatches(sb.target, target)) continue; sb.sendEvent({ type: eventType, ...extra }); diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index c06a72d..466a257 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -36,6 +36,9 @@ export class LuaSharedSandbox { this._guiTree = null; this._loopHandle = null; this._lastTickAt = 0; + // Маркер для GameRuntime.routeEvent — этот sandbox принимает все + // события и сам маршрутизирует через shim.fireTargetEvent. + this._luaShared = true; } setOnCommand(cb) { this._onCommand = cb; } -- 2.47.2 From 0980ec4a5fab8b6c836ccda601ccdc8f84c146a4 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 06:51:50 +0300 Subject: [PATCH 082/214] =?UTF-8?q?debug(lua):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20console.warn=20=D0=B2=20fireTargetEvent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверяю: при [Touch FIRE] доходит ли событие до Lua-shim, правильно ли резолвится part по primId, есть ли connections у Touched сигнала. --- src/editor/engine/lua/RobloxShim.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 22a8174..30a704f 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1927,6 +1927,8 @@ export function registerRobloxShim(lua, opts) { if (!p) return; const id = p.primId ?? p.target; const part = partById.get(Number(id)); + // eslint-disable-next-line no-console + console.warn(`[shim fireTargetEvent] id=${id} kind=${p.kind} part=${part?.Name || 'NOT_FOUND'} connections=${part?.Touched?.connections?.length || 0}`); if (!part) return; if (p.kind === 'touch' || p.kind === 'touched') { part.Touched.Fire(hrp); -- 2.47.2 From 4835cb59c2697bca9b0c3cbbcebe9fb6dc2d725f Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 06:55:51 +0300 Subject: [PATCH 083/214] =?UTF-8?q?debug(lua):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20=5F=5Flog=20=D0=B2=20drain=5Fhandler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чтобы увидеть запускается ли handler из очереди и нет ли pcall error. Сейчас [shim fireTargetEvent] показывает connections=1 но нигде нет выхлопа от Touched handler — где-то теряется. --- src/editor/engine/lua/RobloxShim.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 30a704f..fdbf566 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1744,20 +1744,20 @@ 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 - -- Оборачиваем call в pcall чтобы поглотить return value handler'а - -- (RayGun возвращает :connect(...) объект как последнее выражение, - -- что приводит к wasmoon promise-detection crash). pcall возвращает - -- (ok, ret1, ret2, ...) — мы их не используем. + __log("warn", "[drain] starting handler " .. handlerId) local co = coroutine.create(function() - -- Тот же watchdog что и в _startSingleScript. debug.sethook(function() coroutine.yield(0.016) end, "", 20000) - pcall(fn, a1, a2, a3, a4) + local ok, err = pcall(fn, a1, a2, a3, a4) + if not ok then + __log("error", "[drain handler error] " .. tostring(err)) + end end) __rbxl_register_coroutine(handlerId, co) local ok, ret = coroutine.resume(co) if not ok then + __log("error", "[drain resume error] " .. tostring(ret)) __rbxl_send_error(handlerId, tostring(ret)) __rbxl_unregister_coroutine(handlerId) elseif type(ret) == 'number' then @@ -1765,7 +1765,6 @@ export function registerRobloxShim(lua, opts) { elseif coroutine.status(co) == 'dead' then __rbxl_unregister_coroutine(handlerId) end - -- Явно ничего не возвращаем чтобы wasmoon не оборачивал nil end `); // Кешируем ссылку на Lua-функцию запуска handler'а -- 2.47.2 From 6ce296570ddc756f9eb704fe6b409469000cad30 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:04:14 +0300 Subject: [PATCH 084/214] =?UTF-8?q?fix(lua):=20=D0=BF=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D1=8F=D0=B5=D0=BC=20wasmoon=20Promise.then(null)?= =?UTF-8?q?=20=D0=B2=20drain=5Fhandler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Симптом: при touched event на монетке в логах: [drain handler error] TypeError: Cannot read properties of null (reading 'then') Скрипт монетки не отрабатывал — Destroy не звался, ev:Fire не было. Причина: wasmoon при вызове Lua-функции из JS возвращает Promise. Если в Lua-handler был crash (например yield-across-C-boundary от debug.sethook), wasmoon пытается .then(null) внутри Promise цепочки. Фикс: 1. Если luaDrainHandler вернул thenable — .catch(()=>{}) подавляем. 2. Откатил debug-логи которые мог ломать handler. 3. Drain-handler опять чистый pcall(fn, args). --- src/editor/engine/lua/RobloxShim.js | 30 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index fdbf566..371185a 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1744,20 +1744,15 @@ 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 - __log("warn", "[drain] starting handler " .. handlerId) local co = coroutine.create(function() debug.sethook(function() coroutine.yield(0.016) end, "", 20000) - local ok, err = pcall(fn, a1, a2, a3, a4) - if not ok then - __log("error", "[drain handler error] " .. tostring(err)) - end + pcall(fn, a1, a2, a3, a4) end) __rbxl_register_coroutine(handlerId, co) local ok, ret = coroutine.resume(co) if not ok then - __log("error", "[drain resume error] " .. tostring(ret)) __rbxl_send_error(handlerId, tostring(ret)) __rbxl_unregister_coroutine(handlerId) elseif type(ret) == 'number' then @@ -1861,16 +1856,23 @@ 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 || []; - 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]); + // wasmoon-функция возвращает Promise. Если внутри Lua + // вернулся nil — wasmoon упадёт на .then(null). Ловим + // через .catch + try-catch синхронно. + let result; + if (a.length === 0) result = luaDrainHandler(h.fn); + else if (a.length === 1) result = luaDrainHandler(h.fn, a[0]); + else if (a.length === 2) result = luaDrainHandler(h.fn, a[0], a[1]); + else if (a.length === 3) result = luaDrainHandler(h.fn, a[0], a[1], a[2]); + else result = luaDrainHandler(h.fn, a[0], a[1], a[2], a[3]); + // Подавляем Promise.then(null) error + if (result && typeof result.then === 'function') { + result.catch(() => {}); + } } catch (e) { - console.error('[handler-drain]', e); + // Тихо — handler-error это норма (yield-across-C-boundary, + // wasmoon promise-detection и т.п.) } } } -- 2.47.2 From eae2ad7cc5c8e9cb5138e0ba58bacffa81f906e5 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:04:45 +0300 Subject: [PATCH 085/214] =?UTF-8?q?debug(lua):=20=5F=5Flog=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=80=D0=B8=20pcall=20fn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чтобы увидеть РЕАЛЬНУЮ ошибку Lua-handler (раньше pcall глотал её). --- src/editor/engine/lua/RobloxShim.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 371185a..61bb1d1 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1748,11 +1748,15 @@ export function registerRobloxShim(lua, opts) { debug.sethook(function() coroutine.yield(0.016) end, "", 20000) - pcall(fn, a1, a2, a3, a4) + local ok, err = pcall(fn, a1, a2, a3, a4) + if not ok then + __log("error", "[handler-fn-error] " .. tostring(err)) + end end) __rbxl_register_coroutine(handlerId, co) local ok, ret = coroutine.resume(co) if not ok then + __log("error", "[handler-resume-error] " .. tostring(ret)) __rbxl_send_error(handlerId, tostring(ret)) __rbxl_unregister_coroutine(handlerId) elseif type(ret) == 'number' then -- 2.47.2 From eddf0b5a23767991c9cb81ff43007e112d2447e5 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:12:36 +0300 Subject: [PATCH 086/214] =?UTF-8?q?fix(lua):=20=D0=BE=D0=B1=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BC=20wasmoon=20null.then=20bug=20=E2=80=94=20?= =?UTF-8?q?=5F=5Frbxl=5Fdrain=5Fhandler=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80?= =?UTF-8?q?=D0=B0=D1=89=D0=B0=D0=B5=D1=82=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень: wasmoon Promise-detection (строка 1026 в index.js): if (Promise.resolve(target) === target || typeof target.then === 'function') typeof null === 'object' → проходит проверку → target.then на null → crash. Когда __rbxl_drain_handler возвращался nil → wasmoon видел null → крашился. Фикс: возвращаем число 1 явно из coroutine.create body и из самой drain_handler. Это не-объект — проверка Promise-detection не сработает. --- src/editor/engine/lua/RobloxShim.js | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 61bb1d1..3e04ba6 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1748,22 +1748,19 @@ export function registerRobloxShim(lua, opts) { debug.sethook(function() coroutine.yield(0.016) end, "", 20000) - local ok, err = pcall(fn, a1, a2, a3, a4) - if not ok then - __log("error", "[handler-fn-error] " .. tostring(err)) - end + -- ВНУТРЕННИЙ pcall: игнорирует runtime errors handler'а + -- ВНЕШНИЙ pcall: ловит yield-across-C-boundary + -- Все возвращаемые значения отбрасываем (не возвращаем из coroutine + -- что-либо, чтобы wasmoon Promise-detection не сработал на null). + pcall(function() pcall(fn, a1, a2, a3, a4) end) + return 1 -- возвращаем НЕ-nil чтобы wasmoon не падал на null.then end) __rbxl_register_coroutine(handlerId, co) - local ok, ret = coroutine.resume(co) - if not ok then - __log("error", "[handler-resume-error] " .. tostring(ret)) - __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 + pcall(coroutine.resume, co) + if coroutine.status(co) == 'dead' then __rbxl_unregister_coroutine(handlerId) end + return 1 -- возвращаем НЕ-nil end `); // Кешируем ссылку на Lua-функцию запуска handler'а -- 2.47.2 From d5b70ac4aad11d8b286a2058709d5b7833cd3075 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:20:27 +0300 Subject: [PATCH 087/214] =?UTF-8?q?debug(lua):=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BC=20=D1=87=D1=82=D0=BE=20=D0=BC=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=85=D0=BE=D0=B4=D0=B8=D0=BC=20=D0=B4=D0=BE=20drain=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D0=BA=D0=B8=D0=B5=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B8=20=D0=BB=D0=BE=D0=B2=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сейчас связь Touched → handler рвётся где-то. Хочу видеть: 1. Срабатывает ли drain цикл (queue.length > 0) 2. Что именно catch съедает --- src/editor/engine/lua/RobloxShim.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 3e04ba6..97c498f 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1855,6 +1855,8 @@ export function registerRobloxShim(lua, opts) { // Запускаем каждый в своей coroutine — wait() внутри безопасен. if (_pendingHandlerQueue.length > 0) { const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length); + // eslint-disable-next-line no-console + console.warn(`[handler-drain] draining ${queue.length} handlers`); for (const h of queue) { try { const a = h.args || []; @@ -1872,8 +1874,8 @@ export function registerRobloxShim(lua, opts) { result.catch(() => {}); } } catch (e) { - // Тихо — handler-error это норма (yield-across-C-boundary, - // wasmoon promise-detection и т.п.) + // eslint-disable-next-line no-console + console.warn('[handler-drain-catch]', e?.message || e); } } } -- 2.47.2 From c6ba06eea6b113a15aa3fbeb00be371df5069af0 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:22:06 +0300 Subject: [PATCH 088/214] =?UTF-8?q?debug(lua):=20log=20start/end/error=20?= =?UTF-8?q?=D0=B2=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/lua/RobloxShim.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 97c498f..d1ba24b 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1748,19 +1748,21 @@ export function registerRobloxShim(lua, opts) { debug.sethook(function() coroutine.yield(0.016) end, "", 20000) - -- ВНУТРЕННИЙ pcall: игнорирует runtime errors handler'а - -- ВНЕШНИЙ pcall: ловит yield-across-C-boundary - -- Все возвращаемые значения отбрасываем (не возвращаем из coroutine - -- что-либо, чтобы wasmoon Promise-detection не сработал на null). - pcall(function() pcall(fn, a1, a2, a3, a4) end) - return 1 -- возвращаем НЕ-nil чтобы wasmoon не падал на null.then + __log("warn", "[lua-handler] " .. handlerId .. " starting") + local ok, err = pcall(fn, a1, a2, a3, a4) + if ok then + __log("warn", "[lua-handler] " .. handlerId .. " finished OK") + else + __log("error", "[lua-handler] " .. handlerId .. " ERROR: " .. tostring(err)) + end + return 1 end) __rbxl_register_coroutine(handlerId, co) pcall(coroutine.resume, co) if coroutine.status(co) == 'dead' then __rbxl_unregister_coroutine(handlerId) end - return 1 -- возвращаем НЕ-nil + return 1 end `); // Кешируем ссылку на Lua-функцию запуска handler'а -- 2.47.2 From 0cbd1d7a823288b2251d904814df19a09ea74ba4 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:26:35 +0300 Subject: [PATCH 089/214] =?UTF-8?q?diag(lua):=20Touched.Fire()=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B0=D1=80=D0=B3=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D0=B8=D0=B7=D0=BE=D0=BB=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=B2=D0=B8=D0=BD=D0=BE=D0=B2=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Гипотеза: hrp передача в Lua-функцию через wasmoon крашит null.then. Если без hrp handler отработает — значит точно hrp. --- src/editor/engine/lua/RobloxShim.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index d1ba24b..6a8bc07 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1933,13 +1933,12 @@ export function registerRobloxShim(lua, opts) { if (!p) return; const id = p.primId ?? p.target; const part = partById.get(Number(id)); - // eslint-disable-next-line no-console - console.warn(`[shim fireTargetEvent] id=${id} kind=${p.kind} part=${part?.Name || 'NOT_FOUND'} connections=${part?.Touched?.connections?.length || 0}`); if (!part) return; + // ДИАГНОСТИКА: пробуем сначала без аргумента — чтобы понять hrp ли виноват if (p.kind === 'touch' || p.kind === 'touched') { - part.Touched.Fire(hrp); + part.Touched.Fire(); // БЕЗ hrp — тест } else if (p.kind === 'untouch' || p.kind === 'untouched') { - part.TouchEnded.Fire(hrp); + part.TouchEnded.Fire(); } }, fireGlobalEvent(p) { -- 2.47.2 From 36b41616b0dbc715af0cd8b75c355b6c33fd4e2a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:28:32 +0300 Subject: [PATCH 090/214] =?UTF-8?q?fix(lua):=20=D0=BF=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=20h.fn()=20=D0=B2?= =?UTF-8?q?=20JS=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D1=87=D0=B8=20=D0=B2=20Lua?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Не передаём JS-обёртку Lua-функции обратно в Lua через luaDrainHandler — это создавало wasmoon Promise-detection crash на null.then. Прямой h.fn(...args) → wasmoon вернёт Promise → .catch ловим. --- src/editor/engine/lua/RobloxShim.js | 30 ++++++++++++----------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 6a8bc07..cb71593 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1857,27 +1857,22 @@ export function registerRobloxShim(lua, opts) { // Запускаем каждый в своей coroutine — wait() внутри безопасен. if (_pendingHandlerQueue.length > 0) { const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length); - // eslint-disable-next-line no-console - console.warn(`[handler-drain] draining ${queue.length} handlers`); for (const h of queue) { try { - const a = h.args || []; - // wasmoon-функция возвращает Promise. Если внутри Lua - // вернулся nil — wasmoon упадёт на .then(null). Ловим - // через .catch + try-catch синхронно. - let result; - if (a.length === 0) result = luaDrainHandler(h.fn); - else if (a.length === 1) result = luaDrainHandler(h.fn, a[0]); - else if (a.length === 2) result = luaDrainHandler(h.fn, a[0], a[1]); - else if (a.length === 3) result = luaDrainHandler(h.fn, a[0], a[1], a[2]); - else result = luaDrainHandler(h.fn, a[0], a[1], a[2], a[3]); - // Подавляем Promise.then(null) error + // ПРЯМОЙ вызов JS-обёртки Lua-функции (без передачи fn + // обратно в Lua через luaDrainHandler — это создаёт + // wasmoon Promise-detection crash на null.then). + // wasmoon вернёт Promise — ловим через .catch. + const result = h.fn(...(h.args || [])); if (result && typeof result.then === 'function') { - result.catch(() => {}); + result.catch((err) => { + // eslint-disable-next-line no-console + console.warn('[handler-async-err]', err?.message || err); + }); } } catch (e) { // eslint-disable-next-line no-console - console.warn('[handler-drain-catch]', e?.message || e); + console.warn('[handler-sync-err]', e?.message || e); } } } @@ -1934,11 +1929,10 @@ export function registerRobloxShim(lua, opts) { const id = p.primId ?? p.target; const part = partById.get(Number(id)); if (!part) return; - // ДИАГНОСТИКА: пробуем сначала без аргумента — чтобы понять hrp ли виноват if (p.kind === 'touch' || p.kind === 'touched') { - part.Touched.Fire(); // БЕЗ hrp — тест + part.Touched.Fire(hrp); } else if (p.kind === 'untouch' || p.kind === 'untouched') { - part.TouchEnded.Fire(); + part.TouchEnded.Fire(hrp); } }, fireGlobalEvent(p) { -- 2.47.2 From bb69ccf9edd935c582148282ca4bd78a6683fa80 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:31:37 +0300 Subject: [PATCH 091/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20UI+=D0=B7=D0=B2=D1=83=D0=BA=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=20'=D0=A1=D0=BE=D0=B1=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BC=D0=BE=D0=BD=D0=B5=D1=82=D0=BA=D0=B8'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: только print в консоль, leaderstats не виден на экране. Стало паритет с JS-версией: - ScreenGui+TextLabel счётчик 'Монеты: N / 8' в правом углу - ScreenGui подсказка 'Собери все монетки!' на 2 сек по центру - Sound 'coin' при сборе (через Sound:Play, SoundId='coin') - Sound 'win' + победный TextLabel когда score >= TOTAL --- src/community/docsGamesBuildersLua.js | 76 ++++++++++++++++++--------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 076bb9f..6cdf886 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -55,41 +55,65 @@ export const LUA_OVERRIDES = { g1_main: `-- === ИГРА «СОБЕРИ МОНЕТКИ» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} +local Players = game:GetService("Players") local score = 0 local TOTAL = 8 --- Создаём leaderstats для отображения счёта в правом верхнем углу -local Players = game:GetService("Players") -Players.PlayerAdded:Connect(function(player) - local stats = Instance.new("Folder", player) - stats.Name = "leaderstats" - local coins = Instance.new("IntValue", stats) - coins.Name = "Монеты" - coins.Value = 0 -end) --- Для уже зашедшего LocalPlayer (Рублокс дублирует PlayerAdded при init): -for _, p in ipairs(Players:GetPlayers()) do - if not p:FindFirstChild("leaderstats") then - local stats = Instance.new("Folder", p) - stats.Name = "leaderstats" - local coins = Instance.new("IntValue", stats); coins.Name = "Монеты" - end -end +-- HUD: счётчик в правом верхнем углу +local player = Players.LocalPlayer +local screenGui = Instance.new("ScreenGui", player.PlayerGui) +screenGui.Name = "CoinHUD" -print("Собери все монетки!") +local label = Instance.new("TextLabel", screenGui) +label.Size = UDim2.new(0, 220, 0, 50) +label.Position = UDim2.new(1, -240, 0, 20) +label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +label.BackgroundTransparency = 0.4 +label.TextColor3 = Color3.fromRGB(255, 215, 0) +label.TextScaled = true +label.Font = Enum.Font.SourceSansBold +label.Text = "Монеты: 0 / " .. TOTAL +-- Подсказка по центру (на 2 секунды) +local hintGui = Instance.new("ScreenGui", player.PlayerGui) +local hint = Instance.new("TextLabel", hintGui) +hint.Size = UDim2.new(0, 400, 0, 60) +hint.Position = UDim2.new(0.5, -200, 0.3, 0) +hint.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +hint.BackgroundTransparency = 0.4 +hint.TextColor3 = Color3.fromRGB(255, 255, 255) +hint.TextScaled = true +hint.Text = "Собери все монетки!" +task.delay(2, function() hintGui:Destroy() end) + +-- Звуки +local coinSound = Instance.new("Sound", workspace) +coinSound.SoundId = "coin" +coinSound.Volume = 1 + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win" +winSound.Volume = 1 + +-- Подписка на сбор монетки local coinEvent = getEvent("CoinCollected") coinEvent.Event:Connect(function() score = score + 1 - for _, p in ipairs(Players:GetPlayers()) do - local stats = p:FindFirstChild("leaderstats") - if stats and stats:FindFirstChild("Монеты") then - stats['Монеты'].Value = score - end - end - print("Собрано: " .. score) + label.Text = "Монеты: " .. score .. " / " .. TOTAL + coinSound:Play() if score >= TOTAL then - print("Победа! Все монетки твои!") + -- Победный текст + local winGui = Instance.new("ScreenGui", player.PlayerGui) + local winLabel = Instance.new("TextLabel", winGui) + winLabel.Size = UDim2.new(0, 500, 0, 80) + winLabel.Position = UDim2.new(0.5, -250, 0.4, 0) + winLabel.BackgroundColor3 = Color3.fromRGB(0, 100, 0) + winLabel.BackgroundTransparency = 0.2 + winLabel.TextColor3 = Color3.fromRGB(255, 255, 0) + winLabel.TextScaled = true + winLabel.Font = Enum.Font.SourceSansBold + winLabel.Text = "Победа! Все монетки твои!" + winSound:Play() end end)`, // Скрипт каждой монетки — генератор по script-объекту -- 2.47.2 From 701125d17b4af911731d3d3ba85e98c42dedf4ba Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:37:18 +0300 Subject: [PATCH 092/214] =?UTF-8?q?fix(lua):=20task.delay/spawn/defer=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=82=D0=B5=D1=80=D1=8F=D0=BB=D0=B8=D1=81=D1=8C?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20prelude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень бага HUD/звука в игре 'Собери монетки': prelude перезаписывал task = { wait = rbx_wait } если type(task)~='table'. task — JS-объект (userdata) → ветка else → методы delay/spawn/defer исчезали. Скрипт g1_main падал на: task.delay(2, function() hintGui:Destroy() end) с 'attempt to call a nil value (field delay)'. Из-за этого Connect на CoinCollected ниже не выполнялся → HUD не обновлялся, звук не играл. Фикс: сохраняем существующие методы task.delay/spawn/defer из shim, добавляем wait. --- src/editor/engine/lua/RobloxShim.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index cb71593..415c353 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1698,8 +1698,21 @@ export function registerRobloxShim(lua, opts) { function __rbxl_yield_frame() coroutine.yield(0.05) end - if type(task) == 'table' then - task.wait = rbx_wait + -- task — JS-object из shim ('userdata'/'table'). Сохраняем + -- существующие методы (delay/spawn/defer) и добавляем wait. + if type(task) == 'table' or type(task) == 'userdata' then + local existing = task + local jsDelay = existing.delay + local jsSpawn = existing.spawn + local jsDefer = existing.defer + task = { + wait = rbx_wait, + delay = jsDelay or function(_, fn) if fn then fn() end end, + spawn = jsSpawn or function(fn) if fn then fn() end end, + defer = jsDefer or function(fn) if fn then fn() end end, + synchronize = function() end, + desynchronize = function() end, + } else task = { wait = rbx_wait } end -- 2.47.2 From 598b91bd9e72c5adf76bdb7d40c71ff9329dda5a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:41:49 +0300 Subject: [PATCH 093/214] =?UTF-8?q?fix(lua):=20inst.Parent=20=3D=20X=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE-=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D1=8F=D0=B5=D1=82=20=D0=B2=20parent.Children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень бага: g1_main делал: ev = Instance.new('BindableEvent') ev.Parent = ReplicatedStorage Но Proxy 'set' просто писал t['Parent']=value, НЕ добавляя ev в ReplicatedStorage.Children. Поэтому скрипт монетки делал: ev = ReplicatedStorage:FindFirstChild('CoinCollected') -- nil! if ev then ev:Fire() end -- false, ничего не происходит HUD/звук не обновлялись. Фикс: Proxy.set теперь: 1) удаляет себя из старого parent.Children 2) пушит в новый parent.Children 3) фейерит ChildRemoved/ChildAdded/AncestryChanged Это паритет с Roblox Instance.Parent setter. --- src/editor/engine/lua/RobloxShim.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 415c353..9dc8639 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -394,7 +394,8 @@ function newInstance(className, name) { GetPropertyChangedSignal: m.GetPropertyChangedSignal, }; // Proxy: для unknown свойств возвращаем stub чтобы скрипты не падали. - return new Proxy(target, { + let proxyRef; + proxyRef = new Proxy(target, { get(t, prop) { // Существующее свойство всегда возвращаем как есть (включая методы) if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) { @@ -417,6 +418,27 @@ function newInstance(className, name) { return stub; }, set(t, prop, value) { + // Авто-управление иерархией при `inst.Parent = X`: + // 1) удаляем себя из Children старого Parent + // 2) пушим в Children нового Parent + // 3) фейерим ChildAdded/ChildRemoved + if (prop === 'Parent') { + const oldP = t.Parent; + if (oldP && oldP.Children) { + const i = oldP.Children.indexOf(proxyRef); + if (i >= 0) { + oldP.Children.splice(i, 1); + try { oldP.ChildRemoved && oldP.ChildRemoved.Fire(proxyRef); } catch (_) {} + } + } + t[prop] = value; + if (value && value.Children && value.Children.indexOf(proxyRef) < 0) { + value.Children.push(proxyRef); + try { value.ChildAdded && value.ChildAdded.Fire(proxyRef); } catch (_) {} + } + try { t.AncestryChanged && t.AncestryChanged.Fire(proxyRef, value); } catch (_) {} + return true; + } t[prop] = value; return true; }, @@ -426,6 +448,7 @@ function newInstance(className, name) { return true; }, }); + return proxyRef; } /** -- 2.47.2 From ad26395e10e345c4434712dc6b6228575c56cc53 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:45:06 +0300 Subject: [PATCH 094/214] =?UTF-8?q?fix(lua):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=B4=D0=B2=D0=BE=D0=B9=D0=BD=D0=BE=D0=B9=20part.Touc?= =?UTF-8?q?hed.Fire=20=E2=80=94=20=D1=81=D1=87=D1=91=D1=82=20+1=20=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=BE=D0=BD=D0=B5=D1=82=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Симптом: счётчик показывает 10/8 при 5 собранных монетах. Корень: BabylonScene на каждое касание шлёт И routeEvent('touch') И routeGlobalEvent('playerTouch'). Lua-shim фейерил part.Touched.Fire(hrp) в обоих обработчиках (fireTargetEvent + fireGlobalEvent). Handler монетки срабатывал 2 раза → 2 Fire CoinCollected → score +2. Фикс: в fireGlobalEvent для playerTouch УБРАЛИ part.Touched.Fire. Остался только humanoid.Touched.Fire(part) — это уникальный для playerTouch сценарий (когда юзер слушает humanoid.Touched). --- src/editor/engine/lua/RobloxShim.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 9dc8639..e3127e9 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1984,8 +1984,9 @@ export function registerRobloxShim(lua, opts) { } if (primId != null) { const part = partById.get(Number(primId)); - if (part?.Touched) part.Touched.Fire(hrp); - if (humanoid.Touched) humanoid.Touched.Fire(part); + // НЕ фейерим part.Touched — это делает fireTargetEvent + // в routeEvent('touch'). Иначе двойной счёт. + if (part && humanoid.Touched) humanoid.Touched.Fire(part); } } // GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id} -- 2.47.2 From 901c770fdc8f213bd9080b8b76b2062720cc0945 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 10:47:56 +0300 Subject: [PATCH 095/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=202=20=C2=AB?= =?UTF-8?q?=D0=9F=D1=80=D1=8B=D0=B3=D0=B0=D0=B9=20=D0=BF=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D0=BC=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS-версия имела: - ui.showText('Допрыгай до зелёной площадки!', 3) - onTick: y<-3 → respawn + 'Упал!' + sound 'lose' - broadcast 'finish' → 'Победа!' + sound 'win' + конфетти - finish-зона: onTouch → broadcast 'finish' Lua-версия (паритет): - ScreenGui подсказка 'Допрыгай до зелёной площадки!' на 3с - RunService.Heartbeat: hrp.Y < -3 → player:LoadCharacter() + Sound 'lose' + красный TextLabel 'Упал! Пробуй снова.' на 1.5с - BindableEvent FinishReached в ReplicatedStorage - g2_finish: Touched на финиш-зоне → ev:Fire (только 1 раз через fired-флаг) - g2_main: FinishReached → Sound 'win' + зелёный 'Победа! Ты дошёл до финиша!' Юзер: открой копию ЗАНОВО на Lua — старый проект 2908 был со старым кодом (только print), новая копия получит обновлённые скрипты. --- src/community/docsGamesBuildersLua.js | 88 +++++++++++++++++++++------ 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 6cdf886..f9d9692 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -132,35 +132,89 @@ end)`, // ═══════════════════════════════════════════════════════════════ 'platform-jump': { g2_main: `-- === ИГРА «ПРЫГАЙ ПО ПЛАТФОРМАМ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + local Players = game:GetService("Players") local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false -print("Допрыгай до зелёного финиша!") +-- Подсказка по центру (на 3 секунды) +local hintGui = Instance.new("ScreenGui", player.PlayerGui) +hintGui.Name = "Hint" +local hint = Instance.new("TextLabel", hintGui) +hint.Size = UDim2.new(0, 480, 0, 60) +hint.Position = UDim2.new(0.5, -240, 0.3, 0) +hint.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +hint.BackgroundTransparency = 0.4 +hint.TextColor3 = Color3.fromRGB(255, 255, 255) +hint.TextScaled = true +hint.Font = Enum.Font.SourceSansBold +hint.Text = "Допрыгай до зелёной площадки!" +task.delay(3, function() hintGui:Destroy() end) --- Каждый кадр проверяем не упал ли игрок +-- Звуки +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose" +loseSound.Volume = 1 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win" +winSound.Volume = 1 + +-- Каждый кадр следим: не упал ли игрок RunService.Heartbeat:Connect(function() - for _, player in ipairs(Players:GetPlayers()) do - local char = player.Character - if char then - local hrp = char:FindFirstChild("HumanoidRootPart") - if hrp and hrp.Position.Y < -10 then - -- Респаун - player:LoadCharacter() - print("Упал! Попробуй ещё раз") - end - end + if won then return end + local char = player.Character + if not char then return end + local hrp = char:FindFirstChild("HumanoidRootPart") + if hrp and hrp.Position.Y < -3 then + player:LoadCharacter() + loseSound:Play() + local fallGui = Instance.new("ScreenGui", player.PlayerGui) + local fallLabel = Instance.new("TextLabel", fallGui) + fallLabel.Size = UDim2.new(0, 400, 0, 60) + fallLabel.Position = UDim2.new(0.5, -200, 0.35, 0) + fallLabel.BackgroundColor3 = Color3.fromRGB(120, 0, 0) + fallLabel.BackgroundTransparency = 0.3 + fallLabel.TextColor3 = Color3.fromRGB(255, 255, 255) + fallLabel.TextScaled = true + fallLabel.Font = Enum.Font.SourceSansBold + fallLabel.Text = "Упал! Пробуй снова." + task.delay(1.5, function() fallGui:Destroy() end) end +end) + +-- Финиш-зона шлёт BindableEvent +local finishEvent = getEvent("FinishReached") +finishEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + local winGui = Instance.new("ScreenGui", player.PlayerGui) + local winLabel = Instance.new("TextLabel", winGui) + winLabel.Size = UDim2.new(0, 540, 0, 80) + winLabel.Position = UDim2.new(0.5, -270, 0.4, 0) + winLabel.BackgroundColor3 = Color3.fromRGB(0, 120, 0) + winLabel.BackgroundTransparency = 0.2 + winLabel.TextColor3 = Color3.fromRGB(255, 255, 0) + winLabel.TextScaled = true + winLabel.Font = Enum.Font.SourceSansBold + winLabel.Text = "Победа! Ты дошёл до финиша!" end)`, - g2_finish: `-- === Скрипт финишной платформы (Lua) === + g2_finish: `-- === Скрипт финиш-зоны (Lua) === +-- Висит на невидимой зоне над зелёной площадкой. +-- Игрок встал — его тело внутри зоны — победа. +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent +local fired = false part.Touched:Connect(function(hit) + if fired then return end local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") if not h then return end - print("ПОБЕДА! Ты допрыгал!") - -- Заморозить - h.WalkSpeed = 0 - h.JumpPower = 0 + fired = true + local ev = ReplicatedStorage:FindFirstChild("FinishReached") + if ev then ev:Fire() end end)`, }, -- 2.47.2 From 660d528ad585d90dab19a3ac7416cef98b3de2c7 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 12:47:44 +0300 Subject: [PATCH 096/214] =?UTF-8?q?feat(lua):=20=5F=5Frbxl=5Fshow=5Ftext?= =?UTF-8?q?=20+=20=5F=5Frbxl=5Fspawn=5Fparticles=20(=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D1=82=20game.ui/scene)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS-версия использует game.ui.showText (красивая центрированная плашка без рамки через RbxlHudOverlay) и game.scene.spawnParticles('confetti'). Lua-версия пыталась рисовать ScreenGui+TextLabel через offset в UDim2, но gui-shim неправильно интерпретировал offset → плашка прижата влево. Также конфетти отсутствовали. Решение — хелперы прямого вызова HUD/particle-systems как в JS: - __rbxl_show_text(text, duration, color?) → shim шлёт ui.showText → GameRuntime → _rbxlHud.showMessage + setTimeout hideMessage - __rbxl_spawn_particles(kind, x, y, z, duration, count) → 'scene.particles' - __rbxl_player_pos() → возвращает текущую позицию игрока Игра 2 переписана: использует __rbxl_show_text для подсказок 'Допрыгай', 'Упал!', 'Победа!' и __rbxl_spawn_particles('confetti', ...) на финише. --- src/community/docsGamesBuildersLua.js | 41 +++++---------------------- src/editor/engine/GameRuntime.js | 17 +++++++++++ src/editor/engine/lua/RobloxShim.js | 25 ++++++++++++++++ 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index f9d9692..ce3cff1 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -139,19 +139,8 @@ local RunService = game:GetService("RunService") local player = Players.LocalPlayer local won = false --- Подсказка по центру (на 3 секунды) -local hintGui = Instance.new("ScreenGui", player.PlayerGui) -hintGui.Name = "Hint" -local hint = Instance.new("TextLabel", hintGui) -hint.Size = UDim2.new(0, 480, 0, 60) -hint.Position = UDim2.new(0.5, -240, 0.3, 0) -hint.BackgroundColor3 = Color3.fromRGB(0, 0, 0) -hint.BackgroundTransparency = 0.4 -hint.TextColor3 = Color3.fromRGB(255, 255, 255) -hint.TextScaled = true -hint.Font = Enum.Font.SourceSansBold -hint.Text = "Допрыгай до зелёной площадки!" -task.delay(3, function() hintGui:Destroy() end) +-- Подсказка по центру (паритет с JS game.ui.showText) +__rbxl_show_text("Допрыгай до зелёной площадки!", 3) -- Звуки local loseSound = Instance.new("Sound", workspace) @@ -170,17 +159,7 @@ RunService.Heartbeat:Connect(function() if hrp and hrp.Position.Y < -3 then player:LoadCharacter() loseSound:Play() - local fallGui = Instance.new("ScreenGui", player.PlayerGui) - local fallLabel = Instance.new("TextLabel", fallGui) - fallLabel.Size = UDim2.new(0, 400, 0, 60) - fallLabel.Position = UDim2.new(0.5, -200, 0.35, 0) - fallLabel.BackgroundColor3 = Color3.fromRGB(120, 0, 0) - fallLabel.BackgroundTransparency = 0.3 - fallLabel.TextColor3 = Color3.fromRGB(255, 255, 255) - fallLabel.TextScaled = true - fallLabel.Font = Enum.Font.SourceSansBold - fallLabel.Text = "Упал! Пробуй снова." - task.delay(1.5, function() fallGui:Destroy() end) + __rbxl_show_text("Упал! Пробуй снова.", 1.5) end end) @@ -190,16 +169,10 @@ finishEvent.Event:Connect(function() if won then return end won = true winSound:Play() - local winGui = Instance.new("ScreenGui", player.PlayerGui) - local winLabel = Instance.new("TextLabel", winGui) - winLabel.Size = UDim2.new(0, 540, 0, 80) - winLabel.Position = UDim2.new(0.5, -270, 0.4, 0) - winLabel.BackgroundColor3 = Color3.fromRGB(0, 120, 0) - winLabel.BackgroundTransparency = 0.2 - winLabel.TextColor3 = Color3.fromRGB(255, 255, 0) - winLabel.TextScaled = true - winLabel.Font = Enum.Font.SourceSansBold - winLabel.Text = "Победа! Ты дошёл до финиша!" + __rbxl_show_text("Победа! Ты дошёл до финиша!", 5) + -- Конфетти над игроком (как JS game.scene.spawnParticles) + local pos = __rbxl_player_pos() + __rbxl_spawn_particles("confetti", pos.x, pos.y + 3, pos.z, 3, 3) end)`, g2_finish: `-- === Скрипт финиш-зоны (Lua) === -- Висит на невидимой зоне над зелёной площадкой. diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 9a24221..90cca57 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -296,6 +296,23 @@ export class GameRuntime { this._ensureRbxlHud(); this._rbxlHud.showWin(payload.text || 'WIN!'); } catch (_) {} + } else if (cmd === 'ui.showText') { + // Lua-helper __rbxl_show_text: красивый центрированный + // текст без рамки (паритет с JS game.ui.showText). + try { + this._ensureRbxlHud(); + this._rbxlHud.showMessage(payload.text || ''); + const dur = Number(payload.duration) || 2; + const t = payload.text || ''; + setTimeout(() => { + try { + if (this._rbxlHud._lastMessage === t) { + this._rbxlHud.hideMessage(); + } + } catch (_) {} + }, dur * 1000); + try { this._rbxlHud._lastMessage = t; } catch (_) {} + } catch (_) {} } else if (cmd === 'leaderstatSet') { // Roblox leaderstats: IntValue.Value меняется → HUD. try { diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index e3127e9..c5e6e12 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1807,6 +1807,31 @@ export function registerRobloxShim(lua, opts) { global.set('__log', (level, text) => { send('log', { level: String(level || 'info'), text: String(text || '') }); }); + // === Хелперы паритета с JS game.ui / game.scene === + // Красивый центрированный текст без рамки (как game.ui.showText). + global.set('__rbxl_show_text', (text, duration, color) => { + send('ui.showText', { + text: String(text || ''), + duration: Number(duration) || 2, + color: color || '#ffffff', + }); + }); + // Эффекты частиц (confetti, sparks и т.п.) — как game.scene.spawnParticles. + global.set('__rbxl_spawn_particles', (kind, x, y, z, duration, count) => { + send('scene.particles', { + kind: String(kind || 'confetti'), + pos: { x: +x, y: +y, z: +z }, + duration: Number(duration) || 2, + count: Number(count) || 1, + }); + }); + // Позиция игрока для удобства (для confetti над головой и т.п.) + global.set('__rbxl_player_pos', () => { + try { + const p = hrp._position || { X: 0, Y: 0, Z: 0 }; + return { x: p.X, y: p.Y, z: p.Z }; + } catch (_) { return { x: 0, y: 0, z: 0 }; } + }); // Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync) const luaResumeCo = lua.global.get('__rbxl_resume_co'); -- 2.47.2 From 7384494c8f48f939c7db8d1ae3e8417f790f61a3 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 12:53:14 +0300 Subject: [PATCH 097/214] =?UTF-8?q?fix(lua):=20scene.particles=20payload?= =?UTF-8?q?=20=E2=80=94=20type=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20ki?= =?UTF-8?q?nd,=20payload.position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BabylonScene._spawnParticleEffect читает payload.type ('confetti') и payload.position {x,y,z}. Я слал {kind, pos} — type=undefined → fallback на 'sparks' → бледные одиночные искорки вместо салюта. После фикса 'confetti' даёт яркий разноцветный салют. --- src/editor/engine/lua/RobloxShim.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index c5e6e12..1e511d6 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1817,10 +1817,11 @@ export function registerRobloxShim(lua, opts) { }); }); // Эффекты частиц (confetti, sparks и т.п.) — как game.scene.spawnParticles. + // BabylonScene._spawnParticleEffect ждёт payload.type и payload.position. global.set('__rbxl_spawn_particles', (kind, x, y, z, duration, count) => { send('scene.particles', { - kind: String(kind || 'confetti'), - pos: { x: +x, y: +y, z: +z }, + type: String(kind || 'confetti'), + position: { x: +x, y: +y, z: +z }, duration: Number(duration) || 2, count: Number(count) || 1, }); -- 2.47.2 From 6fe249033e91732d8fc71d80f59b86de6769f08b Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 12:56:20 +0300 Subject: [PATCH 098/214] =?UTF-8?q?debug:=20=D0=BB=D0=BE=D0=B3=20=D0=B2=20?= =?UTF-8?q?=5FspawnParticles=20=D0=B8=20Lua=20onCommand=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20scene.particles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/GameRuntime.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 90cca57..07a2904 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -326,6 +326,10 @@ export class GameRuntime { } } catch (_) {} } else { + if (cmd === 'scene.particles' || cmd === 'ui.showText') { + // eslint-disable-next-line no-console + console.warn('[Lua onCommand]', cmd, JSON.stringify(payload)); + } this._handleCommand(null, cmd, payload); } }); @@ -4868,6 +4872,8 @@ export class GameRuntime { * Делегирует в BabylonScene._spawnParticleEffect, который знает о Babylon. */ _spawnParticles(payload) { + // eslint-disable-next-line no-console + console.warn('[_spawnParticles] called with:', JSON.stringify(payload), 'scene3d=', !!this.scene3d, 'fn=', !!this.scene3d?._spawnParticleEffect); if (!payload || !this.scene3d?._spawnParticleEffect) return; try { this.scene3d._spawnParticleEffect(payload); -- 2.47.2 From 8321a526cd83b8327a9e893041cc7af0482ef27b Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 13:01:45 +0300 Subject: [PATCH 099/214] =?UTF-8?q?fix(lua):=20=5F=5Frbxl=5Fplayer=5Fpos?= =?UTF-8?q?=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D1=8E=20=D0=B8=D0=B3=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: __rbxl_player_pos() возвращал (0,8,0) — нач. позицию hrp._position, которая не обновлялась. Конфетти всегда вылетали из стартовой точки. Фикс: - api._realPlayerPos обновляется в GameRuntime tick (каждый кадр) через api.updatePlayerPos(x, y, z) из player.body.position. - __rbxl_player_pos() в Lua возвращает api._realPlayerPos если есть. Убраны debug-логи. --- src/community/docsGamesBuildersLua.js | 2 +- src/editor/engine/GameRuntime.js | 19 +++++++++++++------ src/editor/engine/lua/RobloxShim.js | 17 +++++++++++++---- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index ce3cff1..789cb96 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -170,7 +170,7 @@ finishEvent.Event:Connect(function() won = true winSound:Play() __rbxl_show_text("Победа! Ты дошёл до финиша!", 5) - -- Конфетти над игроком (как JS game.scene.spawnParticles) + -- Конфетти над игроком (паритет с JS game.scene.spawnParticles) local pos = __rbxl_player_pos() __rbxl_spawn_particles("confetti", pos.x, pos.y + 3, pos.z, 3, 3) end)`, diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 07a2904..3fd2fe2 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -326,10 +326,6 @@ export class GameRuntime { } } catch (_) {} } else { - if (cmd === 'scene.particles' || cmd === 'ui.showText') { - // eslint-disable-next-line no-console - console.warn('[Lua onCommand]', cmd, JSON.stringify(payload)); - } this._handleCommand(null, cmd, payload); } }); @@ -960,7 +956,20 @@ export class GameRuntime { this._syncPhysicsToScene(); } const state = this._collectState(); + // Реальная позиция игрока для Lua __rbxl_player_pos() + const player = this.scene3d?.player; + let realPos = null; + if (player?.body?.position) { + const p = player.body.position; + realPos = { x: p.x, y: p.y, z: p.z }; + } else if (state?.player) { + realPos = { x: state.player.x, y: state.player.y, z: state.player.z }; + } for (const sb of this.sandboxes) { + // Обновляем реальную позицию игрока для Lua-shim + if (realPos && sb.api?.updatePlayerPos) { + try { sb.api.updatePlayerPos(realPos.x, realPos.y, realPos.z); } catch (_) {} + } // Для скриптов с target — добавляем актуальную позицию self const stateForSb = sb.target ? { ...state, selfPosition: this._collectSelfPosition(sb.target) } @@ -4872,8 +4881,6 @@ export class GameRuntime { * Делегирует в BabylonScene._spawnParticleEffect, который знает о Babylon. */ _spawnParticles(payload) { - // eslint-disable-next-line no-console - console.warn('[_spawnParticles] called with:', JSON.stringify(payload), 'scene3d=', !!this.scene3d, 'fn=', !!this.scene3d?._spawnParticleEffect); if (!payload || !this.scene3d?._spawnParticleEffect) return; try { this.scene3d._spawnParticleEffect(payload); diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 1e511d6..8dcd536 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1827,10 +1827,11 @@ export function registerRobloxShim(lua, opts) { }); }); // Позиция игрока для удобства (для confetti над головой и т.п.) + // Берётся из api.updatePlayerPos который GameRuntime обновляет каждый кадр. global.set('__rbxl_player_pos', () => { try { - const p = hrp._position || { X: 0, Y: 0, Z: 0 }; - return { x: p.X, y: p.Y, z: p.Z }; + const p = api._realPlayerPos || hrp._position || { X: 0, Y: 0, Z: 0 }; + return { x: p.x ?? p.X, y: p.y ?? p.Y, z: p.z ?? p.Z }; } catch (_) { return { x: 0, y: 0, z: 0 }; } }); // Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync) @@ -1845,8 +1846,10 @@ export function registerRobloxShim(lua, opts) { // Для Этапа 3 сделаем proxy через явный метод. В Этапе 4 — введём // metatable на Lua-стороне (более чистый путь). - // Возвращаем api для main-loop - return { + // Возвращаем api для main-loop. api объявляется заранее, чтобы closures + // вроде __rbxl_player_pos и updatePlayerPos могли его видеть. + const api = { + _realPlayerPos: null, onSceneSnapshot(snap) { try { const prims = snap?.primitives || []; @@ -2076,7 +2079,13 @@ export function registerRobloxShim(lua, opts) { return allTools.find(t => t.Name === name); }, getAllTools() { return allTools.slice(); }, + // GameRuntime каждый кадр шлёт реальную позицию игрока сюда. + // __rbxl_player_pos() её возвращает Lua-скриптам. + updatePlayerPos(x, y, z) { + api._realPlayerPos = { x: +x, y: +y, z: +z }; + }, // Доступ к ключевым объектам (для тестов и отладки) partById, localPlayer, humanoid, character, workspace, players, game, }; + return api; } -- 2.47.2 From ba648de09cabf8c990720bd17e1d681455318ed2 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 13:15:11 +0300 Subject: [PATCH 100/214] =?UTF-8?q?debug(lua-games):=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20print=20=D0=B2=20g2=5Fmain=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B5=D1=82=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsGamesBuildersLua.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 789cb96..0253683 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -171,8 +171,18 @@ finishEvent.Event:Connect(function() winSound:Play() __rbxl_show_text("Победа! Ты дошёл до финиша!", 5) -- Конфетти над игроком (паритет с JS game.scene.spawnParticles) + print("[g2] before pos call") local pos = __rbxl_player_pos() - __rbxl_spawn_particles("confetti", pos.x, pos.y + 3, pos.z, 3, 3) + print("[g2] pos=", tostring(pos), "type=", type(pos)) + if pos then + print("[g2] pos.x=", tostring(pos.x), "pos.y=", tostring(pos.y)) + end + local px = (pos and pos.x) or 0 + local py = (pos and pos.y) or 6 + local pz = (pos and pos.z) or 33 + print("[g2] spawning particles at", px, py + 3, pz) + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + print("[g2] particles call returned") end)`, g2_finish: `-- === Скрипт финиш-зоны (Lua) === -- Висит на невидимой зоне над зелёной площадкой. -- 2.47.2 From 96644ede15356975eb53c60f8d42871990af209f Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 13:18:30 +0300 Subject: [PATCH 101/214] =?UTF-8?q?fix(lua):=20=5F=5Frbxl=5Fplayer=5Fx/y/z?= =?UTF-8?q?=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20(wasmoon-userdata?= =?UTF-8?q?=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень: __rbxl_player_pos() возвращал JS-object {x,y,z}, wasmoon оборачивал его в userdata-proxy. В Lua pos.x давал NaN. Конфетти спавнились с NaN. Фикс: 3 отдельные функции __rbxl_player_x/y/z возвращающие числа. В скрипте игры 2 используем их напрямую. --- src/community/docsGamesBuildersLua.js | 14 +++----------- src/editor/engine/lua/RobloxShim.js | 27 +++++++++++++++++++++------ 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 0253683..fdd0591 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -171,18 +171,10 @@ finishEvent.Event:Connect(function() winSound:Play() __rbxl_show_text("Победа! Ты дошёл до финиша!", 5) -- Конфетти над игроком (паритет с JS game.scene.spawnParticles) - print("[g2] before pos call") - local pos = __rbxl_player_pos() - print("[g2] pos=", tostring(pos), "type=", type(pos)) - if pos then - print("[g2] pos.x=", tostring(pos.x), "pos.y=", tostring(pos.y)) - end - local px = (pos and pos.x) or 0 - local py = (pos and pos.y) or 6 - local pz = (pos and pos.z) or 33 - print("[g2] spawning particles at", px, py + 3, pz) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) - print("[g2] particles call returned") end)`, g2_finish: `-- === Скрипт финиш-зоны (Lua) === -- Висит на невидимой зоне над зелёной площадкой. diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 8dcd536..7078249 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1826,13 +1826,28 @@ export function registerRobloxShim(lua, opts) { count: Number(count) || 1, }); }); - // Позиция игрока для удобства (для confetti над головой и т.п.) - // Берётся из api.updatePlayerPos который GameRuntime обновляет каждый кадр. + // Позиция игрока для удобства — отдельные функции для x/y/z, чтобы + // wasmoon не оборачивал результат в userdata-proxy. + global.set('__rbxl_player_x', () => { + const p = api._realPlayerPos || hrp._position || { X: 0 }; + return Number(p.x ?? p.X) || 0; + }); + global.set('__rbxl_player_y', () => { + const p = api._realPlayerPos || hrp._position || { Y: 0 }; + return Number(p.y ?? p.Y) || 0; + }); + global.set('__rbxl_player_z', () => { + const p = api._realPlayerPos || hrp._position || { Z: 0 }; + return Number(p.z ?? p.Z) || 0; + }); + // Совместимость: __rbxl_player_pos() возвращает 3 числа (x, y, z). global.set('__rbxl_player_pos', () => { - try { - const p = api._realPlayerPos || hrp._position || { X: 0, Y: 0, Z: 0 }; - return { x: p.x ?? p.X, y: p.y ?? p.Y, z: p.z ?? p.Z }; - } catch (_) { return { x: 0, y: 0, z: 0 }; } + const p = api._realPlayerPos || hrp._position || { X: 0, Y: 0, Z: 0 }; + return { + x: Number(p.x ?? p.X) || 0, + y: Number(p.y ?? p.Y) || 0, + z: Number(p.z ?? p.Z) || 0, + }; }); // Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync) const luaResumeCo = lua.global.get('__rbxl_resume_co'); -- 2.47.2 From f56e9417c90959a97f1969920463dd0f1e4623aa Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 13:20:51 +0300 Subject: [PATCH 102/214] =?UTF-8?q?fix(lua):=20player.=5Fpos=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20body.position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit player.body.position не существует — позиция в PlayerController._pos. Из-за этого realPos оставался null, api._realPlayerPos не обновлялся, конфетти вылетали из начальной hrp._position (0, 5, 0). --- src/editor/engine/GameRuntime.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 3fd2fe2..bddaa0f 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -957,11 +957,12 @@ export class GameRuntime { } const state = this._collectState(); // Реальная позиция игрока для Lua __rbxl_player_pos() + // PlayerController хранит позицию в player._pos (Vector3). const player = this.scene3d?.player; let realPos = null; - if (player?.body?.position) { - const p = player.body.position; - realPos = { x: p.x, y: p.y, z: p.z }; + if (player?._pos) { + const halfH = player.HALF_H ?? 0.9; + realPos = { x: player._pos.x, y: player._pos.y - halfH, z: player._pos.z }; } else if (state?.player) { realPos = { x: state.player.x, y: state.player.y, z: state.player.z }; } -- 2.47.2 From 0ed2cbf376a473a5419396d299d1e4ae6cd98b4b Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 13:26:38 +0300 Subject: [PATCH 103/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=203=20=C2=AB?= =?UTF-8?q?=D0=9D=D0=B5=20=D1=83=D0=BF=D0=B0=D0=B4=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS-версия: - ui.showText('Беги вперёд! Плитки исчезают!', 3) - onTick: y<-3 → respawn + 'Упал! Снова.' + sound 'lose' - broadcast 'finish' → 'Победа! Ты добежал!' + sound 'win' + confetti - скрипт плитки: onTouch → sound 'click' + game.after(1.2, delete) - скрипт финиша: onTouch → broadcast 'finish' Lua-версия (паритет): - __rbxl_show_text(...) подсказка + 'Упал!' + 'Победа!' - BindableEvent FinishReached как канал между скриптами - g3_main: Heartbeat респаун при Y<-3 + lose/win Sound - 14 g3_tile_N: Touched → click Sound + Debris:AddItem(part, 1.2) - g3_finish: Touched → ev:Fire (через fired-флаг) - На финише: __rbxl_spawn_particles('confetti', ...) над игроком --- src/community/docsGamesBuildersLua.js | 88 ++++++++++++++++++++------- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index fdd0591..09347df 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -196,36 +196,80 @@ end)`, // ═══════════════════════════════════════════════════════════════ // ИГРА 3 — «Не упади» (платформа сужается) // ═══════════════════════════════════════════════════════════════ - 'dont-fall': { - g3_main: `-- === ИГРА «НЕ УПАДИ» — главный скрипт (Lua) === + 'dont-fall': (function() { + const overrides = { + g3_main: `-- === ИГРА «НЕ УПАДИ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + local Players = game:GetService("Players") local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false -print("Удержись на платформе как можно дольше!") +__rbxl_show_text("Беги вперёд! Плитки исчезают!", 3) -local startTime = tick() -local alive = true +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 1 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 RunService.Heartbeat:Connect(function() - if not alive then return end - for _, player in ipairs(Players:GetPlayers()) do - local char = player.Character - if char then - local hrp = char:FindFirstChild("HumanoidRootPart") - if hrp and hrp.Position.Y < -5 then - alive = false - local elapsed = math.floor(tick() - startTime) - print("Ты упал! Продержался: " .. elapsed .. " сек") - task.delay(2, function() - player:LoadCharacter() - startTime = tick() - alive = true - end) - end - end + if won then return end + local char = player.Character + if not char then return end + local hrp = char:FindFirstChild("HumanoidRootPart") + if hrp and hrp.Position.Y < -3 then + player:LoadCharacter() + loseSound:Play() + __rbxl_show_text("Упал! Снова.", 1.5) end +end) + +local finishEvent = getEvent("FinishReached") +finishEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты добежал!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end)`, - }, + g3_finish: `-- === Скрипт финиш-зоны (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("FinishReached") + if ev then ev:Fire() end +end)`, + }; + // Скрипт каждой плитки — генератор (одинаковый код) + const tileScript = `-- === Скрипт исчезающей плитки (Lua) === +local Debris = game:GetService("Debris") +local part = script.Parent +local triggered = false +local clickSound = Instance.new("Sound", part) +clickSound.SoundId = "click"; clickSound.Volume = 0.6 + +part.Touched:Connect(function(hit) + if triggered then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + triggered = true + clickSound:Play() + -- через 1.2с плитка пропадает + Debris:AddItem(part, 1.2) +end)`; + for (let i = 1; i <= 14; i++) overrides['g3_tile_' + i] = tileScript; + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 4 — «Кнопка и дверь» -- 2.47.2 From 05a7eaf37184bd9a19ffb52aa50e0ffa04669146 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 13:32:42 +0300 Subject: [PATCH 104/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=204=20=C2=AB?= =?UTF-8?q?=D0=9A=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0-=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D1=88=D0=BA=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS-версия: - ui.showText('Подойди и нажми E', 4) - onMessage 'win' → showText + win sound + confetti - g4_button: onInteract (E) → click + tween двери (y:8) + showText 'Дверь открывается!' - g4_finish: onTouch → broadcast 'win' Lua-версия (паритет): - __rbxl_show_text подсказка + 'Победа!' - BindableEvent WinReached - g4_button: UserInputService.InputBegan + ProximityHint через BillboardGui ('[E] Открыть дверь' над кнопкой когда игрок в радиусе 4) E → click Sound + TweenService:Create двери (Position +6 по Y, 1.2с) + CanCollide=false + showText 'Дверь открывается!' - g4_finish: Touched → ev:Fire с fired-флагом Shim фиксы: - UserInputService.InputBegan/InputEnded теперь фейерятся на keyDown/keyUp. Передаётся InputObject с KeyCode = ССЫЛКА на Enum.KeyCode. (важно для сравнения == Enum.KeyCode.E). --- src/community/docsGamesBuildersLua.js | 112 +++++++++++++++++++++----- src/editor/engine/lua/RobloxShim.js | 23 +++++- 2 files changed, 112 insertions(+), 23 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 09347df..69b86fd 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -275,36 +275,106 @@ end)`; // ИГРА 4 — «Кнопка и дверь» // ═══════════════════════════════════════════════════════════════ 'button-door': { - g4_main: `-- === ИГРА «КНОПКА И ДВЕРЬ» — главный скрипт (Lua) === + g4_main: `-- === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} -print("Найди кнопку и открой дверь!") --- Глобальный канал для оповещения двери -getEvent("DoorOpen")`, + +__rbxl_show_text("Подойди к красной кнопке и нажми E", 4) + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + __rbxl_show_text("Победа! Дверь открыта, ты прошёл!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, g4_button: `-- === Скрипт кнопки (Lua) === +-- Висит на красной кнопке. Реагирует на E когда игрок рядом. +local UserInputService = game:GetService("UserInputService") +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local TweenService = game:GetService("TweenService") + +local part = script.Parent +local player = Players.LocalPlayer +local opened = false +local inRange = false +local hintGui = nil + +-- Подсказка над кнопкой при подходе +local function showHint() + if hintGui then return end + hintGui = Instance.new("BillboardGui", part) + hintGui.Size = UDim2.new(4, 0, 1, 0) + hintGui.StudsOffset = Vector3.new(0, 2, 0) + hintGui.AlwaysOnTop = true + local label = Instance.new("TextLabel", hintGui) + label.Size = UDim2.new(1, 0, 1, 0) + label.BackgroundTransparency = 1 + label.TextColor3 = Color3.fromRGB(255, 255, 255) + label.TextStrokeTransparency = 0 + label.TextScaled = true + label.Text = "[E] Открыть дверь" +end +local function hideHint() + if hintGui then hintGui:Destroy(); hintGui = nil end +end + +-- Каждый кадр проверяем расстояние до игрока +RunService.Heartbeat:Connect(function() + if opened then return end + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + if dist <= 4 and not inRange then + inRange = true + showHint() + elseif dist > 4 and inRange then + inRange = false + hideHint() + end +end) + +local clickSound = Instance.new("Sound", part) +clickSound.SoundId = "click"; clickSound.Volume = 0.8 + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if opened or not inRange then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + opened = true + hideHint() + clickSound:Play() + __rbxl_show_text("Дверь открывается!", 2) + part.Color = Color3.fromRGB(100, 255, 100) -- зелёная + + -- Находим дверь по имени и поднимаем её + local door = workspace:FindFirstChild("Дверь") + if door then + local goal = { Position = door.Position + Vector3.new(0, 6, 0) } + TweenService:Create(door, TweenInfo.new(1.2), goal):Play() + door.CanCollide = false + end +end)`, + g4_finish: `-- === Скрипт финиша (Lua) === local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent +local fired = false part.Touched:Connect(function(hit) + if fired then return end local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") if not h then return end - local ev = ReplicatedStorage:FindFirstChild("DoorOpen") + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") if ev then ev:Fire() end - print("Кнопка нажата!") - part.Color = Color3.fromRGB(100, 255, 100) -- зелёная -end)`, - g4_door: `-- === Скрипт двери (Lua) === -local TweenService = game:GetService("TweenService") -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local door = script.Parent -local startPos = door.Position - -local ev = ReplicatedStorage:WaitForChild("DoorOpen") -ev.Event:Connect(function() - -- Поднимаем дверь вверх - local goal = { Position = startPos + Vector3.new(0, 5, 0) } - TweenService:Create(door, TweenInfo.new(1), goal):Play() - door.CanCollide = false - print("Дверь открыта!") end)`, }, diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 7078249..90fb45a 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2083,10 +2083,29 @@ export function registerRobloxShim(lua, opts) { try { playerMouse.Button1Up.Fire(); } catch (_) {} } if (p.type === 'keyDown') { - try { playerMouse.KeyDown.Fire(String(p.key || '').toLowerCase()); } catch (_) {} + const k = String(p.key || '').toLowerCase(); + try { playerMouse.KeyDown.Fire(k); } catch (_) {} + // Также фейерим UserInputService.InputBegan с InputObject. + // KeyCode должна быть та же ссылка что и Enum.KeyCode.E, + // чтобы скрипт мог сравнивать input.KeyCode == Enum.KeyCode.E. + try { + const keyEnum = global.get('Enum')?.KeyCode || {}; + const kc = keyEnum[k.toUpperCase()] + || { Name: k.toUpperCase(), Value: k.toUpperCase() }; + const inputObj = { UserInputType: 'Keyboard', KeyCode: kc }; + uis.InputBegan.Fire(inputObj, false); + } catch (_) {} } if (p.type === 'keyUp') { - try { playerMouse.KeyUp.Fire(String(p.key || '').toLowerCase()); } catch (_) {} + const k = String(p.key || '').toLowerCase(); + try { playerMouse.KeyUp.Fire(k); } catch (_) {} + try { + const keyEnum = global.get('Enum')?.KeyCode || {}; + const kc = keyEnum[k.toUpperCase()] + || { Name: k.toUpperCase(), Value: k.toUpperCase() }; + const inputObj = { UserInputType: 'Keyboard', KeyCode: kc }; + uis.InputEnded.Fire(inputObj, false); + } catch (_) {} } }, // Tool registry (для GameRuntime: какой Tool сделать script.Parent) -- 2.47.2 From 39673452cb8755251c0e0096d3fe63aaaaef93d4 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 13:35:24 +0300 Subject: [PATCH 105/214] =?UTF-8?q?debug(g4):=20print=20=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=B8=D1=86=D0=B8=D0=B8=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D1=80=D0=B0=D1=81=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=80=D0=B0=D0=B7=20=D0=B2=20=D1=81=D0=B5=D0=BA?= =?UTF-8?q?=D1=83=D0=BD=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsGamesBuildersLua.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 69b86fd..e3afb6f 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -325,14 +325,19 @@ local function hideHint() end -- Каждый кадр проверяем расстояние до игрока -RunService.Heartbeat:Connect(function() +local _debugTick = 0 +RunService.Heartbeat:Connect(function(dt) if opened then return end local px = __rbxl_player_x() - local py = __rbxl_player_y() local pz = __rbxl_player_z() local dx = part.Position.X - px local dz = part.Position.Z - pz local dist = math.sqrt(dx*dx + dz*dz) + _debugTick = _debugTick + (dt or 0.016) + if _debugTick > 1 then + _debugTick = 0 + print("[g4] px=", px, "pz=", pz, "dist=", dist, "inRange=", inRange) + end if dist <= 4 and not inRange then inRange = true showHint() -- 2.47.2 From 4c648d139e12f42daa64d34cc565565d7b835882 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 13:39:55 +0300 Subject: [PATCH 106/214] =?UTF-8?q?fix(g4):=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=BA=D0=B0=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20E-=D0=BD?= =?UTF-8?q?=D0=B0=D0=B6=D0=B0=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: 1. Heartbeat-зов __rbxl_player_x() возвращал константу после первого касания кнопки (возможно lua-coroutine / state-issue в Heartbeat). 2. Дверь не открывалась — InputBegan E видимо не доходит или фильтр inRange всегда true. Решение: упростил — кнопка реагирует на Touched (как кнопка-педаль), без E и без проверки расстояния. Это и понятнее для урока. Текст подсказки изменён на 'Наступи на красную кнопку'. Дверь поднимается через TweenService при касании кнопки. --- src/community/docsGamesBuildersLua.js | 72 +++++++-------------------- 1 file changed, 19 insertions(+), 53 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index e3afb6f..5cf12e5 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -278,7 +278,7 @@ end)`; g4_main: `-- === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} -__rbxl_show_text("Подойди к красной кнопке и нажми E", 4) +__rbxl_show_text("Наступи на красную кнопку чтобы открыть дверь", 4) local winSound = Instance.new("Sound", workspace) winSound.SoundId = "win"; winSound.Volume = 1 @@ -293,69 +293,35 @@ winEvent.Event:Connect(function() __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end)`, g4_button: `-- === Скрипт кнопки (Lua) === --- Висит на красной кнопке. Реагирует на E когда игрок рядом. -local UserInputService = game:GetService("UserInputService") +-- Висит на красной кнопке. Срабатывает по касанию игрока (как кнопка-педаль). local Players = game:GetService("Players") -local RunService = game:GetService("RunService") local TweenService = game:GetService("TweenService") local part = script.Parent -local player = Players.LocalPlayer local opened = false -local inRange = false -local hintGui = nil --- Подсказка над кнопкой при подходе -local function showHint() - if hintGui then return end - hintGui = Instance.new("BillboardGui", part) - hintGui.Size = UDim2.new(4, 0, 1, 0) - hintGui.StudsOffset = Vector3.new(0, 2, 0) - hintGui.AlwaysOnTop = true - local label = Instance.new("TextLabel", hintGui) - label.Size = UDim2.new(1, 0, 1, 0) - label.BackgroundTransparency = 1 - label.TextColor3 = Color3.fromRGB(255, 255, 255) - label.TextStrokeTransparency = 0 - label.TextScaled = true - label.Text = "[E] Открыть дверь" -end -local function hideHint() - if hintGui then hintGui:Destroy(); hintGui = nil end -end - --- Каждый кадр проверяем расстояние до игрока -local _debugTick = 0 -RunService.Heartbeat:Connect(function(dt) - if opened then return end - local px = __rbxl_player_x() - local pz = __rbxl_player_z() - local dx = part.Position.X - px - local dz = part.Position.Z - pz - local dist = math.sqrt(dx*dx + dz*dz) - _debugTick = _debugTick + (dt or 0.016) - if _debugTick > 1 then - _debugTick = 0 - print("[g4] px=", px, "pz=", pz, "dist=", dist, "inRange=", inRange) - end - if dist <= 4 and not inRange then - inRange = true - showHint() - elseif dist > 4 and inRange then - inRange = false - hideHint() - end -end) +-- Подсказка над кнопкой +local hintGui = Instance.new("BillboardGui", part) +hintGui.Size = UDim2.new(4, 0, 1, 0) +hintGui.StudsOffset = Vector3.new(0, 2, 0) +hintGui.AlwaysOnTop = true +local label = Instance.new("TextLabel", hintGui) +label.Size = UDim2.new(1, 0, 1, 0) +label.BackgroundTransparency = 1 +label.TextColor3 = Color3.fromRGB(255, 255, 255) +label.TextStrokeTransparency = 0 +label.TextScaled = true +label.Text = "Наступи на кнопку" local clickSound = Instance.new("Sound", part) clickSound.SoundId = "click"; clickSound.Volume = 0.8 -UserInputService.InputBegan:Connect(function(input, gp) - if gp then return end - if opened or not inRange then return end - if input.KeyCode ~= Enum.KeyCode.E then return end +part.Touched:Connect(function(hit) + if opened then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end opened = true - hideHint() + hintGui:Destroy() clickSound:Play() __rbxl_show_text("Дверь открывается!", 2) part.Color = Color3.fromRGB(100, 255, 100) -- зелёная -- 2.47.2 From 5101743aeddeb46e6c6b7286bd3edcb0dc11cb9a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 13:43:38 +0300 Subject: [PATCH 107/214] =?UTF-8?q?fix(g4):=20=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D1=83=D0=BB=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82=D0=B8=D0=B5=20E?= =?UTF-8?q?=20(=D0=BA=D0=B0=D0=BA=20=D0=B2=20JS-=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Откат изменения 'наступи на кнопку'. JS-версия использует game.self.onInteract — нажатие E. Lua-версия должна вести себя так же. Подход: - Подсказка [E] Открыть дверь висит над кнопкой постоянно (пока не нажата) - UserInputService.InputBegan ловит E - Расстояние до кнопки проверяется ТОЛЬКО в момент нажатия E (не каждый кадр — это избегает багa с зависанием позиции после Touched) - Если близко (≤4) → дверь поднимается через TweenService --- src/community/docsGamesBuildersLua.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 5cf12e5..8025c28 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -278,7 +278,7 @@ end)`; g4_main: `-- === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} -__rbxl_show_text("Наступи на красную кнопку чтобы открыть дверь", 4) +__rbxl_show_text("Подойди к красной кнопке и нажми E", 4) local winSound = Instance.new("Sound", workspace) winSound.SoundId = "win"; winSound.Volume = 1 @@ -293,14 +293,14 @@ winEvent.Event:Connect(function() __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end)`, g4_button: `-- === Скрипт кнопки (Lua) === --- Висит на красной кнопке. Срабатывает по касанию игрока (как кнопка-педаль). -local Players = game:GetService("Players") +-- Висит на красной кнопке. Реагирует на E когда игрок рядом. +local UserInputService = game:GetService("UserInputService") local TweenService = game:GetService("TweenService") local part = script.Parent local opened = false --- Подсказка над кнопкой +-- Подсказка [E] всегда висит над кнопкой пока не открыта local hintGui = Instance.new("BillboardGui", part) hintGui.Size = UDim2.new(4, 0, 1, 0) hintGui.StudsOffset = Vector3.new(0, 2, 0) @@ -311,15 +311,25 @@ label.BackgroundTransparency = 1 label.TextColor3 = Color3.fromRGB(255, 255, 255) label.TextStrokeTransparency = 0 label.TextScaled = true -label.Text = "Наступи на кнопку" +label.Text = "[E] Открыть дверь" local clickSound = Instance.new("Sound", part) clickSound.SoundId = "click"; clickSound.Volume = 0.8 -part.Touched:Connect(function(hit) +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end if opened then return end - local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") - if not h then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + + -- Проверяем что игрок рядом с кнопкой (читаем позицию только сейчас, + -- НЕ каждый кадр — это надёжнее) + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + if dist > 4 then return end -- слишком далеко + opened = true hintGui:Destroy() clickSound:Play() -- 2.47.2 From efff54add222bdca2daa450333ecf8a63a17e28c Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:07:44 +0300 Subject: [PATCH 108/214] =?UTF-8?q?fix(lua):=20keydown/keyup=20=D0=B2=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=B6=D0=BD=D0=B5=D0=BC=20=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B5=20(BabylonScene=20=D1=88=D0=BB=D1=91?= =?UTF-8?q?=D1=82=20'keydown')?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BabylonScene._normalizeKey → routeGlobalEvent('keydown', {key:'e'}) → sb.sendGlobalEvent({type:'keydown', key:'e'}) → shim.fireGlobalEvent(p). Shim проверял только p.type === 'keyDown' (camelCase) — keydown (lowercase) пропускался. UserInputService.InputBegan не фейерился. Фикс: принимаем оба варианта keyDown/keydown и keyUp/keyup. --- src/editor/engine/lua/RobloxShim.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 90fb45a..a431ac5 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2082,7 +2082,7 @@ export function registerRobloxShim(lua, opts) { if (p.type === 'mouseButton1Up') { try { playerMouse.Button1Up.Fire(); } catch (_) {} } - if (p.type === 'keyDown') { + if (p.type === 'keyDown' || p.type === 'keydown') { const k = String(p.key || '').toLowerCase(); try { playerMouse.KeyDown.Fire(k); } catch (_) {} // Также фейерим UserInputService.InputBegan с InputObject. @@ -2096,7 +2096,7 @@ export function registerRobloxShim(lua, opts) { uis.InputBegan.Fire(inputObj, false); } catch (_) {} } - if (p.type === 'keyUp') { + if (p.type === 'keyUp' || p.type === 'keyup') { const k = String(p.key || '').toLowerCase(); try { playerMouse.KeyUp.Fire(k); } catch (_) {} try { -- 2.47.2 From a4f2f0800bc32fc224c98d267463fea919764d10 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:11:23 +0300 Subject: [PATCH 109/214] =?UTF-8?q?fix(g4):=20=D0=B4=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20+=20=D0=BC=D0=B5=D1=82=D0=B0=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B0=20Vector3=20(+,=20-,=20*,=20/)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: door.Position + Vector3.new(0, 6, 0) возвращало nil потому что wasmoon не создаёт метаметоды (__add) для JS-классов автоматически. Фикс: 1. В скрипте кнопки явно считаем Vector3.new(dp.X, dp.Y+6, dp.Z) без +. 2. В prelude добавил метатаблицу Vector3 для будущего использования с операторами +, -, *, /, унарный -, ==, tostring. Работает между двумя Vector3-таблицами, созданными через Vector3.new в Lua. --- src/community/docsGamesBuildersLua.js | 3 +- src/editor/engine/lua/RobloxShim.js | 63 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 8025c28..31b4eba 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -339,7 +339,8 @@ UserInputService.InputBegan:Connect(function(input, gp) -- Находим дверь по имени и поднимаем её local door = workspace:FindFirstChild("Дверь") if door then - local goal = { Position = door.Position + Vector3.new(0, 6, 0) } + local dp = door.Position + local goal = { Position = Vector3.new(dp.X, dp.Y + 6, dp.Z) } TweenService:Create(door, TweenInfo.new(1.2), goal):Play() door.CanCollide = false end diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index a431ac5..4a4aa74 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1741,6 +1741,69 @@ export function registerRobloxShim(lua, opts) { end wait = rbx_wait + -- ═══════════════════════════════════════════════════════════════ + -- Метатаблица для Vector3 — операторы +, -, *, /, унарный - + -- wasmoon не создаёт __add автоматически для JS-классов. + -- Оборачиваем Vector3.new чтобы возвращаемая таблица имела __add. + -- ═══════════════════════════════════════════════════════════════ + do + local _origV3New = Vector3.new + local v3meta = {} + local function makeV3(x, y, z) + local v = _origV3New(x or 0, y or 0, z or 0) + -- v — это userdata от wasmoon. Делаем proxy-таблицу с метой. + local t = setmetatable({}, v3meta) + rawset(t, '_v', v) + rawset(t, 'X', v.X); rawset(t, 'Y', v.Y); rawset(t, 'Z', v.Z) + rawset(t, 'Magnitude', v.Magnitude or math.sqrt(x*x+y*y+z*z)) + return t + end + local function asV3(v) + if type(v) == 'table' and rawget(v, 'X') ~= nil then return v end + if v == nil then return makeV3(0,0,0) end + return makeV3(v.X or 0, v.Y or 0, v.Z or 0) + end + v3meta.__add = function(a, b) + a = asV3(a); b = asV3(b) + return makeV3(a.X + b.X, a.Y + b.Y, a.Z + b.Z) + end + v3meta.__sub = function(a, b) + a = asV3(a); b = asV3(b) + return makeV3(a.X - b.X, a.Y - b.Y, a.Z - b.Z) + end + v3meta.__mul = function(a, b) + if type(b) == 'number' then a = asV3(a); return makeV3(a.X*b, a.Y*b, a.Z*b) end + if type(a) == 'number' then b = asV3(b); return makeV3(b.X*a, b.Y*a, b.Z*a) end + a = asV3(a); b = asV3(b) + return makeV3(a.X*b.X, a.Y*b.Y, a.Z*b.Z) + end + v3meta.__div = function(a, b) + if type(b) == 'number' then a = asV3(a); return makeV3(a.X/b, a.Y/b, a.Z/b) end + a = asV3(a); b = asV3(b) + return makeV3(a.X/b.X, a.Y/b.Y, a.Z/b.Z) + end + v3meta.__unm = function(a) + a = asV3(a); return makeV3(-a.X, -a.Y, -a.Z) + end + v3meta.__eq = function(a, b) + a = asV3(a); b = asV3(b) + return a.X == b.X and a.Y == b.Y and a.Z == b.Z + end + v3meta.__tostring = function(a) + a = asV3(a) + return string.format("%g, %g, %g", a.X, a.Y, a.Z) + end + Vector3 = setmetatable({ + new = makeV3, + zero = makeV3(0,0,0), + one = makeV3(1,1,1), + xAxis = makeV3(1,0,0), + yAxis = makeV3(0,1,0), + zAxis = makeV3(0,0,1), + FromNormalId = function() return makeV3(0,0,0) end, + }, {}) + end + -- Roblox legacy globals tick = function() return os.time() end -- секунды с epoch time = function() return os.clock() * 1000 end -- ms аптайм -- 2.47.2 From c006f58b7034f4aa57d2d3a856db6c6fd0880aa6 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:13:40 +0300 Subject: [PATCH 110/214] =?UTF-8?q?debug(g4):=20print=20=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20children=20workspace=20=D0=B8=20=D1=81=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Откатил метатаблицу Vector3 (она ломала TweenService instanceof RbxVector3). --- src/community/docsGamesBuildersLua.js | 13 +++++- src/editor/engine/lua/RobloxShim.js | 63 --------------------------- 2 files changed, 12 insertions(+), 64 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 31b4eba..340c733 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -337,12 +337,23 @@ UserInputService.InputBegan:Connect(function(input, gp) part.Color = Color3.fromRGB(100, 255, 100) -- зелёная -- Находим дверь по имени и поднимаем её + print("[g4] looking for door...") local door = workspace:FindFirstChild("Дверь") + print("[g4] door=", tostring(door)) + if not door then + -- Перебираем всех children и пишем имена для отладки + for i, child in ipairs(workspace:GetChildren()) do + print("[g4] child", i, "name=", child.Name) + end + end if door then local dp = door.Position + print("[g4] door pos=", dp.X, dp.Y, dp.Z) local goal = { Position = Vector3.new(dp.X, dp.Y + 6, dp.Z) } - TweenService:Create(door, TweenInfo.new(1.2), goal):Play() + local tween = TweenService:Create(door, TweenInfo.new(1.2), goal) + tween:Play() door.CanCollide = false + print("[g4] tween started") end end)`, g4_finish: `-- === Скрипт финиша (Lua) === diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 4a4aa74..a431ac5 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1741,69 +1741,6 @@ export function registerRobloxShim(lua, opts) { end wait = rbx_wait - -- ═══════════════════════════════════════════════════════════════ - -- Метатаблица для Vector3 — операторы +, -, *, /, унарный - - -- wasmoon не создаёт __add автоматически для JS-классов. - -- Оборачиваем Vector3.new чтобы возвращаемая таблица имела __add. - -- ═══════════════════════════════════════════════════════════════ - do - local _origV3New = Vector3.new - local v3meta = {} - local function makeV3(x, y, z) - local v = _origV3New(x or 0, y or 0, z or 0) - -- v — это userdata от wasmoon. Делаем proxy-таблицу с метой. - local t = setmetatable({}, v3meta) - rawset(t, '_v', v) - rawset(t, 'X', v.X); rawset(t, 'Y', v.Y); rawset(t, 'Z', v.Z) - rawset(t, 'Magnitude', v.Magnitude or math.sqrt(x*x+y*y+z*z)) - return t - end - local function asV3(v) - if type(v) == 'table' and rawget(v, 'X') ~= nil then return v end - if v == nil then return makeV3(0,0,0) end - return makeV3(v.X or 0, v.Y or 0, v.Z or 0) - end - v3meta.__add = function(a, b) - a = asV3(a); b = asV3(b) - return makeV3(a.X + b.X, a.Y + b.Y, a.Z + b.Z) - end - v3meta.__sub = function(a, b) - a = asV3(a); b = asV3(b) - return makeV3(a.X - b.X, a.Y - b.Y, a.Z - b.Z) - end - v3meta.__mul = function(a, b) - if type(b) == 'number' then a = asV3(a); return makeV3(a.X*b, a.Y*b, a.Z*b) end - if type(a) == 'number' then b = asV3(b); return makeV3(b.X*a, b.Y*a, b.Z*a) end - a = asV3(a); b = asV3(b) - return makeV3(a.X*b.X, a.Y*b.Y, a.Z*b.Z) - end - v3meta.__div = function(a, b) - if type(b) == 'number' then a = asV3(a); return makeV3(a.X/b, a.Y/b, a.Z/b) end - a = asV3(a); b = asV3(b) - return makeV3(a.X/b.X, a.Y/b.Y, a.Z/b.Z) - end - v3meta.__unm = function(a) - a = asV3(a); return makeV3(-a.X, -a.Y, -a.Z) - end - v3meta.__eq = function(a, b) - a = asV3(a); b = asV3(b) - return a.X == b.X and a.Y == b.Y and a.Z == b.Z - end - v3meta.__tostring = function(a) - a = asV3(a) - return string.format("%g, %g, %g", a.X, a.Y, a.Z) - end - Vector3 = setmetatable({ - new = makeV3, - zero = makeV3(0,0,0), - one = makeV3(1,1,1), - xAxis = makeV3(1,0,0), - yAxis = makeV3(0,1,0), - zAxis = makeV3(0,0,1), - FromNormalId = function() return makeV3(0,0,0) end, - }, {}) - end - -- Roblox legacy globals tick = function() return os.time() end -- секунды с epoch time = function() return os.clock() * 1000 end -- ms аптайм -- 2.47.2 From c18dfc4d56e4dfb395ec037a8ddb796df5006b24 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:17:03 +0300 Subject: [PATCH 111/214] =?UTF-8?q?fix(g4):=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=20[E]=20=D0=B2=D0=B8=D0=B4=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B4=D0=B8=D1=83=D1=81=D0=B5=204=20=D0=BE=D1=82=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heartbeat проверяет расстояние от игрока до кнопки. Управляем видимостью через label.Visible (BillboardGui в shim не управляет видимостью children, label.Visible работает напрямую через gui.update). --- src/community/docsGamesBuildersLua.js | 43 +++++++++++++-------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 340c733..c5cefd8 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -296,11 +296,14 @@ end)`, -- Висит на красной кнопке. Реагирует на E когда игрок рядом. local UserInputService = game:GetService("UserInputService") local TweenService = game:GetService("TweenService") +local RunService = game:GetService("RunService") local part = script.Parent local opened = false +local hintVisible = false --- Подсказка [E] всегда висит над кнопкой пока не открыта +-- Подсказка над кнопкой. BillboardGui в shim — generic instance, +-- управляем видимостью через label.Visible. local hintGui = Instance.new("BillboardGui", part) hintGui.Size = UDim2.new(4, 0, 1, 0) hintGui.StudsOffset = Vector3.new(0, 2, 0) @@ -312,48 +315,44 @@ label.TextColor3 = Color3.fromRGB(255, 255, 255) label.TextStrokeTransparency = 0 label.TextScaled = true label.Text = "[E] Открыть дверь" +label.Visible = false -- скрыт по умолчанию local clickSound = Instance.new("Sound", part) clickSound.SoundId = "click"; clickSound.Volume = 0.8 -UserInputService.InputBegan:Connect(function(input, gp) - if gp then return end +-- Каждый кадр проверяем расстояние до игрока. Подсказку показываем +-- только если игрок в радиусе 4 единиц. +RunService.Heartbeat:Connect(function() if opened then return end - if input.KeyCode ~= Enum.KeyCode.E then return end - - -- Проверяем что игрок рядом с кнопкой (читаем позицию только сейчас, - -- НЕ каждый кадр — это надёжнее) local px = __rbxl_player_x() local pz = __rbxl_player_z() local dx = part.Position.X - px local dz = part.Position.Z - pz local dist = math.sqrt(dx*dx + dz*dz) - if dist > 4 then return end -- слишком далеко + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + label.Visible = near + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if opened or not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end opened = true hintGui:Destroy() clickSound:Play() __rbxl_show_text("Дверь открывается!", 2) - part.Color = Color3.fromRGB(100, 255, 100) -- зелёная + part.Color = Color3.fromRGB(100, 255, 100) - -- Находим дверь по имени и поднимаем её - print("[g4] looking for door...") local door = workspace:FindFirstChild("Дверь") - print("[g4] door=", tostring(door)) - if not door then - -- Перебираем всех children и пишем имена для отладки - for i, child in ipairs(workspace:GetChildren()) do - print("[g4] child", i, "name=", child.Name) - end - end if door then local dp = door.Position - print("[g4] door pos=", dp.X, dp.Y, dp.Z) local goal = { Position = Vector3.new(dp.X, dp.Y + 6, dp.Z) } - local tween = TweenService:Create(door, TweenInfo.new(1.2), goal) - tween:Play() + TweenService:Create(door, TweenInfo.new(1.2), goal):Play() door.CanCollide = false - print("[g4] tween started") end end)`, g4_finish: `-- === Скрипт финиша (Lua) === -- 2.47.2 From 36321f0d17f21510e8dce89436823b75f35c4235 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:18:52 +0300 Subject: [PATCH 112/214] =?UTF-8?q?fix(g4):=20label.Visible=3Dfalse=20+=20?= =?UTF-8?q?Destroy=20=D0=BF=D1=80=D0=B8=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D0=B8=20E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hintGui:Destroy() не убирает gui-overlay созданный TextLabel через newGuiInstance. Делаем label.Visible=false (надёжный путь) + явный label:Destroy(). --- src/community/docsGamesBuildersLua.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index c5cefd8..b06708b 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -342,6 +342,8 @@ UserInputService.InputBegan:Connect(function(input, gp) if input.KeyCode ~= Enum.KeyCode.E then return end opened = true + label.Visible = false -- скрываем подсказку (Destroy не уничтожает GUI-overlay) + label:Destroy() hintGui:Destroy() clickSound:Play() __rbxl_show_text("Дверь открывается!", 2) -- 2.47.2 From f7074f5cd702bbb8dfb30e958c07636257a5f380 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:21:56 +0300 Subject: [PATCH 113/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=205=20=C2=AB?= =?UTF-8?q?=D0=9B=D0=B0=D0=B1=D0=B8=D1=80=D0=B8=D0=BD=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS-версия: - ui.showText('Найди выход из лабиринта!', 3) - onMessage 'win' → showText + win sound + confetti - g5_finish: onTouch → broadcast 'win' Lua-версия: - __rbxl_show_text подсказка + 'Победа!' - BindableEvent WinReached - g5_finish: Touched на финиш-зоне → ev:Fire (с fired-флагом) - На победе: confetti над игроком --- src/community/docsGamesBuildersLua.js | 29 +++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index b06708b..216ef8c 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -377,14 +377,35 @@ end)`, // ═══════════════════════════════════════════════════════════════ 'maze': { g5_main: `-- === ИГРА «ЛАБИРИНТ» — главный скрипт (Lua) === -print("Найди выход из лабиринта!")`, - g5_finish: `-- === Финиш лабиринта (Lua) === +${SNIPPET_BROADCAST} + +__rbxl_show_text("Найди выход из лабиринта!", 3) + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + __rbxl_show_text("Победа! Ты нашёл выход!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g5_finish: `-- === Скрипт финиша лабиринта (Lua) === +-- Висит на невидимой зоне над зелёным ковриком. +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent +local fired = false + part.Touched:Connect(function(hit) + if fired then return end local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") if not h then return end - print("ПОБЕДА! Ты нашёл выход!") - h.WalkSpeed = 0 + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end end)`, }, -- 2.47.2 From 8eec59af53976480f1f1fc4883b8994a6f1afaed Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:24:48 +0300 Subject: [PATCH 114/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=206=20=C2=AB?= =?UTF-8?q?=D0=A6=D0=B2=D0=B5=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS-версия: - 36 плиток 6×6, серые - ui.score = painted, ui.showText - onMessage 'paint' → score++ + pickup sound + при 36 победа+win+confetti - tile: onTouch → setColor зелёный + broadcast 'paint' Lua-версия: - ScreenGui+TextLabel 'Плитки: N / 36' счётчик - __rbxl_show_text подсказка + 'Победа!' - BindableEvent TilePainted - 36 g6_tile_N: Touched → part.Color=зелёный + ev:Fire (painted-флаг) - g6_main: painted++/label.Text/pickup Sound; при 36 — win+confetti --- src/community/docsGamesBuildersLua.js | 74 ++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 216ef8c..8fb01b4 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -412,25 +412,71 @@ end)`, // ═══════════════════════════════════════════════════════════════ // ИГРА 6 — «Угадай цвет» // ═══════════════════════════════════════════════════════════════ - 'color-tiles': { - g6_main: `-- === ИГРА «УГАДАЙ ЦВЕТ» — главный скрипт (Lua) === + 'color-tiles': (function() { + const overrides = { + g6_main: `-- === ИГРА «ЦВЕТНЫЕ ПЛИТКИ» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} -local colors = { "red", "green", "blue", "yellow" } -local target = colors[math.random(1, #colors)] -print("Встань на плитку цвета: " .. target) +local Players = game:GetService("Players") +local player = Players.LocalPlayer +local painted = 0 +local TOTAL = 36 +local won = false -local ev = getEvent("TileStepped") -ev.Event:Connect(function(color) - if color == target then - print("Верно! +1 очко") - target = colors[math.random(1, #colors)] - print("Теперь встань на: " .. target) - else - print("Неверно! Нужен " .. target) +__rbxl_show_text("Наступи на все плитки!", 3) + +-- Счётчик в правом верхнем углу +local screenGui = Instance.new("ScreenGui", player.PlayerGui) +local label = Instance.new("TextLabel", screenGui) +label.Size = UDim2.new(0, 220, 0, 50) +label.Position = UDim2.new(1, -240, 0, 20) +label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +label.BackgroundTransparency = 0.4 +label.TextColor3 = Color3.fromRGB(160, 255, 160) +label.TextScaled = true +label.Font = Enum.Font.SourceSansBold +label.Text = "Плитки: 0 / " .. TOTAL + +local pickupSound = Instance.new("Sound", workspace) +pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local paintEvent = getEvent("TilePainted") +paintEvent.Event:Connect(function() + if won then return end + painted = painted + 1 + label.Text = "Плитки: " .. painted .. " / " .. TOTAL + pickupSound:Play() + if painted >= TOTAL then + won = true + winSound:Play() + __rbxl_show_text("Победа! Все плитки раскрашены!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end end)`, - }, + }; + // Скрипт для каждой из 36 плиток — одинаковый код через генератор + const tileScript = `-- === Скрипт цветной плитки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local painted = false + +part.Touched:Connect(function(hit) + if painted then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + painted = true + part.Color = Color3.fromRGB(51, 221, 85) -- ярко-зелёный + local ev = ReplicatedStorage:FindFirstChild("TilePainted") + if ev then ev:Fire() end +end)`; + for (let i = 1; i <= 36; i++) overrides['g6_tile_' + i] = tileScript; + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 7 — «Ловишка предметов» -- 2.47.2 From 0603d922d4c191fc80006e4cca0fce2b0da8604f Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:31:17 +0300 Subject: [PATCH 115/214] =?UTF-8?q?fix(g6=20builder):=20=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D1=80=D1=83=D1=8E=20=D1=81=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D1=83=206=C3=976=20=D0=BF=D0=BB=D0=B8=D1=82=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BB=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: x:-4+c*2 → плитки на x=[-4,-2,0,2,4,6], правые края до x=6.9. Платформа grass [-6,5] (центры [-5.5, 5.5]). Плитка x=6 свешивалась. Стало: x:-5+c*2 → плитки на x=[-5,-3,-1,1,3,5], края [-5.9, 5.9]. Чётко на платформе. То же по Z. Плюс лог 'ЯЗЫК СКРИПТОВ: LUA/JS' в GameRuntime — чтобы было видно сразу что именно запущено. --- src/community/docsGamesBuilders.js | 4 +++- src/editor/engine/GameRuntime.js | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/community/docsGamesBuilders.js b/src/community/docsGamesBuilders.js index 25e8ba2..09c7915 100644 --- a/src/community/docsGamesBuilders.js +++ b/src/community/docsGamesBuilders.js @@ -748,7 +748,9 @@ function game6ColorTiles() { id, type: 'cube', name: 'Плитка_' + id, - x: -4 + c * 2, y: 1.15, z: -4 + r * 2, + // Платформа grass: x от -6 до 5 (blocks=1unit, центры [-5.5..5.5]). + // Сетка 6×6 плиток (центры через 2) центрируем на [-5..5]. + x: -5 + c * 2, y: 1.15, z: -5 + r * 2, sx: 1.8, sy: 0.3, sz: 1.8, color: '#9aa0aa', // серый — не раскрашена material: 'matte', diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index bddaa0f..f8fe371 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -348,6 +348,12 @@ export class GameRuntime { const rbxlImported = luaUserBatch.filter(s => s._rbxlImported).length; const luaWritten = luaUserCount - rbxlImported; const jsOnly = this.sandboxes.length - (this._luaUserSandbox ? 1 : 0); + // Чёткий маркер языка в логах — чтобы было видно что запущено + const lang = (luaWritten > 0 || rbxlImported > 0) + ? (jsOnly > 0 ? 'СМЕШАННЫЙ (JS+Lua)' : 'LUA') + : 'JS'; + // eslint-disable-next-line no-console + console.warn(`[GameRuntime] === ЯЗЫК СКРИПТОВ: ${lang} === (JS=${jsOnly}, Lua=${luaWritten}, rbxl=${rbxlImported})`); this._log('info', `Запущено JS-скриптов: ${jsOnly}`); if (rbxlImported > 0) { this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlImported}`); -- 2.47.2 From 8021ed6a2041e01b0484ede94eb91a73d5ad6c8c Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:34:16 +0300 Subject: [PATCH 116/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=207=20=C2=AB?= =?UTF-8?q?=D0=9F=D0=BE=D0=B9=D0=BC=D0=B0=D0=B9=20=D0=BF=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=8E=D1=89=D0=B5=D0=B5=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - ui.score, showText 'Лови падающие кубы! Нужно 15' - every(1.5): random(x,z) → spawn cube y=14, anchored=false, deleteAfter 6с - onPlayerTouch: caught[ref] флаг, +1 score, coin sound, при 15 — победа+confetti Lua (паритет): - ScreenGui+TextLabel 'Поймано: N / 15' - task.spawn + task.wait(1.5) цикл спавна - Instance.new('Part', workspace), Anchored=false (падение) - Vector3.new для Position/Size - Debris:AddItem(cube, 6) — авто-удаление - cube.Touched: caught-флаг + score++ + coin Sound + Destroy - При 15 — win Sound + showText + confetti --- src/community/docsGamesBuildersLua.js | 78 +++++++++++++++++++-------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 8fb01b4..a4d3662 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -482,36 +482,68 @@ end)`; // ИГРА 7 — «Ловишка предметов» // ═══════════════════════════════════════════════════════════════ 'catch-falling': { - g7_main: `-- === ИГРА «ЛОВИШКА ПРЕДМЕТОВ» — главный скрипт (Lua) === + g7_main: `-- === ИГРА «ПОЙМАЙ ПАДАЮЩЕЕ» — главный скрипт (Lua) === +local Players = game:GetService("Players") local Debris = game:GetService("Debris") +local player = Players.LocalPlayer local score = 0 -local function showScore() - print("Поймано: " .. score) -end -showScore() +local GOAL = 15 +local won = false --- Каждую секунду создаём падающий предмет +__rbxl_show_text("Лови падающие кубы! Нужно 15", 3) + +-- Счётчик в правом верхнем углу +local screenGui = Instance.new("ScreenGui", player.PlayerGui) +local label = Instance.new("TextLabel", screenGui) +label.Size = UDim2.new(0, 220, 0, 50) +label.Position = UDim2.new(1, -240, 0, 20) +label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +label.BackgroundTransparency = 0.4 +label.TextColor3 = Color3.fromRGB(255, 215, 0) +label.TextScaled = true +label.Font = Enum.Font.SourceSansBold +label.Text = "Поймано: 0 / " .. GOAL + +local coinSound = Instance.new("Sound", workspace) +coinSound.SoundId = "coin"; coinSound.Volume = 1 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Каждые 1.5 сек роняем куб task.spawn(function() - while true do - task.wait(1) - local ball = Instance.new("Part") - ball.Shape = Enum.PartType.Ball - ball.Size = Vector3.new(1, 1, 1) - ball.Position = Vector3.new(math.random(-8, 8), 20, math.random(-8, 8)) - ball.Color = Color3.fromRGB(255, 215, 0) - ball.Material = Enum.Material.Neon - ball.Anchored = false - ball.Parent = workspace - Debris:AddItem(ball, 10) + while not won do + task.wait(1.5) + if won then break end + local cube = Instance.new("Part", workspace) + cube.Size = Vector3.new(0.8, 0.8, 0.8) + cube.Position = Vector3.new(math.random(-6, 6), 14, math.random(-6, 6)) + cube.Color = Color3.fromRGB(255, 204, 51) + cube.Material = Enum.Material.Neon + cube.Anchored = false -- падает под действием гравитации - -- Скрипт на ловлю - ball.Touched:Connect(function(hit) + -- Куб исчезает через 6с если не поймали + Debris:AddItem(cube, 6) + + -- Ловля: при касании игроком +1 очко + local caught = false + cube.Touched:Connect(function(hit) + if caught or won then return end local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") - if h and ball.Parent then - score = score + 1 - showScore() - ball:Destroy() + if not h then return end + caught = true + score = score + 1 + label.Text = "Поймано: " .. score .. " / " .. GOAL + coinSound:Play() + cube:Destroy() + if score >= GOAL then + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты поймал 15 кубов!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end end) end -- 2.47.2 From c9acb4fb3bab5670d1a5da003ffd701da259a850 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:38:16 +0300 Subject: [PATCH 117/214] =?UTF-8?q?fix(g7):=20=D1=81=D0=BF=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=5F=5Frbxl=5Fspawn=5F?= =?UTF-8?q?part=20+=20Heartbeat=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20t?= =?UTF-8?q?ask.spawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корни: 1. task.spawn(function() task.wait() end) → 'attempt to yield across a C-call boundary' — task.spawn в shim синхронно зовёт fn из JS. Замена: накопление dt в RunService.Heartbeat → spawnCube() каждые 1.5с. 2. Instance.new('Part', workspace) с последующим .Anchored=false создавал anchored=true примитив + патч → primitiveManager не пересоздавал rigid body, куб не падал. Новый хелпер __rbxl_spawn_part(opts) шлёт sceneCreate с правильным anchored СРАЗУ — куб создаётся динамическим и падает. --- src/community/docsGamesBuildersLua.js | 78 +++++++++++++++------------ src/editor/engine/lua/RobloxShim.js | 35 ++++++++++++ 2 files changed, 79 insertions(+), 34 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index a4d3662..2166f9f 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -510,42 +510,52 @@ coinSound.SoundId = "coin"; coinSound.Volume = 1 local winSound = Instance.new("Sound", workspace) winSound.SoundId = "win"; winSound.Volume = 1 --- Каждые 1.5 сек роняем куб -task.spawn(function() - while not won do - task.wait(1.5) - if won then break end - local cube = Instance.new("Part", workspace) - cube.Size = Vector3.new(0.8, 0.8, 0.8) - cube.Position = Vector3.new(math.random(-6, 6), 14, math.random(-6, 6)) - cube.Color = Color3.fromRGB(255, 204, 51) - cube.Material = Enum.Material.Neon - cube.Anchored = false -- падает под действием гравитации +-- Каждые 1.5 сек роняем куб (через Heartbeat — task.spawn не умеет yield) +local RunService = game:GetService("RunService") +local _spawnTimer = 0 - -- Куб исчезает через 6с если не поймали - Debris:AddItem(cube, 6) +local function spawnCube() + -- Используем хелпер __rbxl_spawn_part — он сразу создаёт примитив + -- с правильными свойствами (включая anchored=false → реальная гравитация). + local cube = __rbxl_spawn_part({ + type = "cube", + x = math.random(-6, 6), y = 14, z = math.random(-6, 6), + sx = 0.8, sy = 0.8, sz = 0.8, + color = "#ffcc33", + anchored = false, -- падает + canCollide = true, + }) + if not cube then return end + Debris:AddItem(cube, 6) - -- Ловля: при касании игроком +1 очко - local caught = false - cube.Touched:Connect(function(hit) - if caught or won then return end - local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") - if not h then return end - caught = true - score = score + 1 - label.Text = "Поймано: " .. score .. " / " .. GOAL - coinSound:Play() - cube:Destroy() - if score >= GOAL then - won = true - winSound:Play() - __rbxl_show_text("Победа! Ты поймал 15 кубов!", 5) - local px = __rbxl_player_x() - local py = __rbxl_player_y() - local pz = __rbxl_player_z() - __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) - end - end) + local caught = false + cube.Touched:Connect(function(hit) + if caught or won then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + caught = true + score = score + 1 + label.Text = "Поймано: " .. score .. " / " .. GOAL + coinSound:Play() + cube:Destroy() + if score >= GOAL then + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты поймал 15 кубов!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + end + end) +end + +RunService.Heartbeat:Connect(function(dt) + if won then return end + _spawnTimer = _spawnTimer + (dt or 0.016) + if _spawnTimer >= 1.5 then + _spawnTimer = 0 + spawnCube() end end)`, }, diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index a431ac5..122086d 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1826,6 +1826,41 @@ export function registerRobloxShim(lua, opts) { count: Number(count) || 1, }); }); + // Спавн примитива (паритет с JS game.scene.spawn) — кладёт в сцену + // примитив с указанным состоянием (включая anchored/canCollide). Возвращает + // id примитива (число) для дальнейших операций. + let _nextSpawnedId = 800000 + Math.floor(Math.random() * 10000); + global.set('__rbxl_spawn_part', (opts) => { + try { + const id = _nextSpawnedId++; + const o = opts || {}; + send('sceneCreate', { + primId: id, + type: String(o.type || 'cube'), + x: +o.x || 0, y: +o.y || 0, z: +o.z || 0, + sx: +o.sx || 1, sy: +o.sy || 1, sz: +o.sz || 1, + color: o.color || '#A0A0A0', + anchored: o.anchored !== false, + canCollide: o.canCollide !== false, + }); + // Создаём Lua-side представление для скриптов + const fakePrim = { + id, name: o.name || `Spawned_${id}`, + x: +o.x || 0, y: +o.y || 0, z: +o.z || 0, + sx: +o.sx || 1, sy: +o.sy || 1, sz: +o.sz || 1, + color: o.color || '#A0A0A0', + anchored: o.anchored !== false, + canCollide: o.canCollide !== false, + }; + const part = newPart(fakePrim, send); + partById.set(id, part); + return part; + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[__rbxl_spawn_part]', e?.message || e); + return null; + } + }); // Позиция игрока для удобства — отдельные функции для x/y/z, чтобы // wasmoon не оборачивал результат в userdata-proxy. global.set('__rbxl_player_x', () => { -- 2.47.2 From 2af9b960887154643bf0dd77e2073a9b516d1690 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 17:41:20 +0300 Subject: [PATCH 118/214] =?UTF-8?q?fix(g7):=20=D1=81=D0=BF=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20unanchored=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BC=D0=B8=D1=82=D0=B8=D0=B2=D1=8B=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B8=D1=80=D1=83=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B2=20=D1=84=D0=B8=D0=B7=D0=B8=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DynamicsManager.start() собирает unanchored объекты только при входе в Play. Куб созданный из скрипта в runtime не попадал в bodies → не падал, висел в воздухе. Фикс: после pm.addInstance с anchored=false вызываем dm.registerPrimitive(data) — кладёт тело в физический мир сразу после спавна. --- src/editor/engine/rbxl-lua-integration.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index cbef7f7..224cfc9 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -150,6 +150,16 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { canCollide: payload?.canCollide !== false, }; pm.addInstance(payload?.type || 'cube', opts); + // Если unanchored — регистрируем в физике на лету, иначе он не падает. + if (opts.anchored === false) { + try { + const dm = runtime.scene3d?.dynamicsManager; + const data = pm.instances?.get?.(opts.id); + if (dm && data) dm.registerPrimitive(data); + } catch (e) { + console.warn('[sceneCreate] registerPrimitive failed', e); + } + } } catch (e) { console.error('[sceneCreate]', e); } -- 2.47.2 From 2c99a61bb0653f9922f6d37be9a6f5f8e6cdf7ed Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:02:47 +0300 Subject: [PATCH 119/214] =?UTF-8?q?debug(g7):=20=D0=BB=D0=BE=D0=B3=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20unanchored=20part=20=D0=B2=20=D1=84?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/rbxl-lua-integration.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index 224cfc9..6f0a8c5 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -155,6 +155,8 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { try { const dm = runtime.scene3d?.dynamicsManager; const data = pm.instances?.get?.(opts.id); + // eslint-disable-next-line no-console + console.warn('[sceneCreate] unanchored part', opts.id, 'dm=', !!dm, 'data=', !!data, 'enabled=', dm?.isEnabled?.()); if (dm && data) dm.registerPrimitive(data); } catch (e) { console.warn('[sceneCreate] registerPrimitive failed', e); -- 2.47.2 From fe2c1bb28b5568bb221a15e4c695a9b3b7dd4b25 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:04:14 +0300 Subject: [PATCH 120/214] =?UTF-8?q?fix(g7):=20scene3d.dynamics=20(=D0=BD?= =?UTF-8?q?=D0=B5=20dynamicsManager)=20=E2=80=94=20=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D1=8C=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/rbxl-lua-integration.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index 6f0a8c5..093b50a 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -153,11 +153,11 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { // Если unanchored — регистрируем в физике на лету, иначе он не падает. if (opts.anchored === false) { try { - const dm = runtime.scene3d?.dynamicsManager; + const dm = runtime.scene3d?.dynamics; const data = pm.instances?.get?.(opts.id); - // eslint-disable-next-line no-console - console.warn('[sceneCreate] unanchored part', opts.id, 'dm=', !!dm, 'data=', !!data, 'enabled=', dm?.isEnabled?.()); - if (dm && data) dm.registerPrimitive(data); + if (dm && data && typeof dm.registerPrimitive === 'function') { + dm.registerPrimitive(data); + } } catch (e) { console.warn('[sceneCreate] registerPrimitive failed', e); } -- 2.47.2 From 462ee62a9a20f7d97cddd26b638e7819051f1e15 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:07:25 +0300 Subject: [PATCH 121/214] =?UTF-8?q?fix(g7):=20Touched=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D1=8F=D1=85=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20AABB-check=20=D0=B2=20Heartbeat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BabylonScene._detectTouchEvents работает только для скриптов с явным target. Спавненные runtime через __rbxl_spawn_part (падающие кубы) не имеют target — Babylon их не проверяет, Touched молчит. Решение: shim.fireHeartbeat теперь сам делает AABB игрок↔part для всех part id >= 800000 (наш range спавненных). При пересечении фейерит Touched.Fire(hrp); при выходе — TouchEnded. --- src/editor/engine/lua/RobloxShim.js | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 122086d..6f92cf1 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2038,6 +2038,38 @@ export function registerRobloxShim(lua, opts) { fireHeartbeat(dt) { try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {} try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {} + // Авто-детект Touched на спавненных частях (id >= 800000): + // BabylonScene._detectTouchEvents срабатывает только для скриптов + // с явным target. Спавненные через __rbxl_spawn_part примитивы + // (падающие кубы, снаряды) Babylon не знает, поэтому делаем + // AABB-check игрок↔part прямо в shim каждый кадр. + try { + const pp = api._realPlayerPos; + if (!pp) return; + const phw = 0.4, phh = 0.9, phd = 0.4; + for (const [id, part] of partById.entries()) { + if (id < 800000) continue; + if (!part || part.Destroyed) continue; + if (!part.Touched || part.Touched.connections.length === 0) continue; + const pos = part._state?.Position; + const size = part._state?.Size; + if (!pos || !size) continue; + const hw = size.X / 2 + 0.1; + const hh = size.Y / 2 + 0.1; + const hd = size.Z / 2 + 0.1; + const overlap = + pp.x + phw > pos.X - hw && pp.x - phw < pos.X + hw && + pp.y + phh > pos.Y - hh && pp.y - phh < pos.Y + hh && + pp.z + phd > pos.Z - hd && pp.z - phd < pos.Z + hd; + if (overlap && !part.__lastTouching) { + part.__lastTouching = true; + try { part.Touched.Fire(hrp); } catch (_) {} + } else if (!overlap && part.__lastTouching) { + part.__lastTouching = false; + try { part.TouchEnded.Fire(hrp); } catch (_) {} + } + } + } catch (_) {} }, fireTargetEvent(p) { if (!p) return; -- 2.47.2 From 8295d6f0febfe8b8d327ec44a9ba9266b14ad1f0 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:09:45 +0300 Subject: [PATCH 122/214] =?UTF-8?q?fix(g7):=20=D1=81=D0=B8=D0=BD=D0=BA=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B9=20=D1=81=D0=BF=D0=B0?= =?UTF-8?q?=D0=B2=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=B9=20=D0=B2=20shim=20=D0=B4=D0=BB=D1=8F=20AABB-?= =?UTF-8?q?touched-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DynamicsManager._applyToMesh обновляет pm.instances[id].x/y/z, но Lua-shim кэширует Position в part._state.Position на момент создания. AABB-check видел кубы навечно в небе → касание не ловилось. Фикс: GameRuntime.tick собирает позиции всех спавненных динамических примитивов и шлёт в shim через api.updateSpawnedPos(id, x, y, z). Shim обновляет part._state.Position у соответствующего partById. --- src/editor/engine/GameRuntime.js | 20 ++++++++++++++++++++ src/editor/engine/lua/RobloxShim.js | 10 ++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index f8fe371..b051df0 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -972,11 +972,31 @@ export class GameRuntime { } else if (state?.player) { realPos = { x: state.player.x, y: state.player.y, z: state.player.z }; } + // Собираем актуальные позиции спавненных динамических примитивов + // (id >= 800000) — нужно для AABB-touched-check в Lua-shim, чтобы + // ловить попадание игрока в падающий куб. + let spawnedPositions = null; + try { + const pm = this.scene3d?.primitiveManager; + if (pm && pm.instances) { + for (const [id, data] of pm.instances.entries()) { + if (id < 800000 || data.anchored !== false) continue; + if (!spawnedPositions) spawnedPositions = []; + spawnedPositions.push([id, data.x, data.y, data.z]); + } + } + } catch (_) {} for (const sb of this.sandboxes) { // Обновляем реальную позицию игрока для Lua-shim if (realPos && sb.api?.updatePlayerPos) { try { sb.api.updatePlayerPos(realPos.x, realPos.y, realPos.z); } catch (_) {} } + // Синк спавненных динамических примитивов + if (spawnedPositions && sb.api?.updateSpawnedPos) { + for (const [id, x, y, z] of spawnedPositions) { + try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {} + } + } // Для скриптов с target — добавляем актуальную позицию self const stateForSb = sb.target ? { ...state, selfPosition: this._collectSelfPosition(sb.target) } diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 6f92cf1..a883530 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2185,6 +2185,16 @@ export function registerRobloxShim(lua, opts) { updatePlayerPos(x, y, z) { api._realPlayerPos = { x: +x, y: +y, z: +z }; }, + // Синхронизация позиций спавненных физических частей (падающие кубы). + // GameRuntime каждый кадр зовёт это с актуальными координатами от + // pm.instances — иначе наш AABB-touched-check считает позиции + // устаревшими (на момент создания) и не ловит касание. + updateSpawnedPos(id, x, y, z) { + const part = partById.get(Number(id)); + if (part && part._state && part._state.Position) { + part._state.Position = new RbxVector3(x, y, z); + } + }, // Доступ к ключевым объектам (для тестов и отладки) partById, localPlayer, humanoid, character, workspace, players, game, }; -- 2.47.2 From 1a174f285428fad27133268efa33147465c416b9 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:12:01 +0300 Subject: [PATCH 123/214] =?UTF-8?q?fix(g7):=20=D1=80=D0=B0=D1=81=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=B8=D0=BB=20=D1=80=D0=B0=D0=B4=D0=B8=D1=83=D1=81?= =?UTF-8?q?=20Touched=20=D0=BD=D0=B0=201.2=20=E2=80=94=20=D0=BA=D1=83?= =?UTF-8?q?=D0=B1=20=D0=BB=D0=BE=D0=B2=D0=B8=D1=82=D1=81=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D1=81=D0=B1=D0=BB=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Куб с физикой отталкивается от игрока (DynamicsManager push) и успевает отскочить до следующего кадра. Строгий AABB ловил только при защемлении в углу. Расширение SLACK=1.2 единицы ловит 'почти-контакт' — куб собирается при подходе на ~1 единицу. --- src/editor/engine/lua/RobloxShim.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index a883530..206dc56 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2039,14 +2039,19 @@ export function registerRobloxShim(lua, opts) { try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {} try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {} // Авто-детект Touched на спавненных частях (id >= 800000): - // BabylonScene._detectTouchEvents срабатывает только для скриптов - // с явным target. Спавненные через __rbxl_spawn_part примитивы - // (падающие кубы, снаряды) Babylon не знает, поэтому делаем - // AABB-check игрок↔part прямо в shim каждый кадр. + // Спавненные через __rbxl_spawn_part примитивы (падающие кубы, + // снаряды) Babylon не знает (target=null), поэтому делаем + // proximity-check игрок↔part прямо в shim каждый кадр. + // + // Используем РАСШИРЕННЫЙ радиус (не строгий AABB), потому что + // физтело куба отталкивается от игрока при контакте — куб может + // успеть отскочить ДО следующего кадра. Расширяем зону на 1.2 + // единицы, чтобы поймать "почти-контакт". try { const pp = api._realPlayerPos; if (!pp) return; const phw = 0.4, phh = 0.9, phd = 0.4; + const SLACK = 1.2; // расширение зоны касания for (const [id, part] of partById.entries()) { if (id < 800000) continue; if (!part || part.Destroyed) continue; @@ -2054,9 +2059,9 @@ export function registerRobloxShim(lua, opts) { const pos = part._state?.Position; const size = part._state?.Size; if (!pos || !size) continue; - const hw = size.X / 2 + 0.1; - const hh = size.Y / 2 + 0.1; - const hd = size.Z / 2 + 0.1; + const hw = size.X / 2 + SLACK; + const hh = size.Y / 2 + SLACK; + const hd = size.Z / 2 + SLACK; const overlap = pp.x + phw > pos.X - hw && pp.x - phw < pos.X + hw && pp.y + phh > pos.Y - hh && pp.y - phh < pos.Y + hh && -- 2.47.2 From 73bf9f5c340cb3c18a8b522d61280ea00878fb20 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:16:43 +0300 Subject: [PATCH 124/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=208=20=C2=AB?= =?UTF-8?q?=D0=91=D0=B5=D0=B3=D0=B8=20=D0=BA=20=D1=84=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=88=D1=83=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - ui.timer + showText 'Беги к зелёному финишу — на время!' - onTick: time += dt - onMessage 'finish' → 'Финиш! Время: N сек' + win + confetti - g8_finish: onTouch → broadcast 'finish' Lua (паритет): - ScreenGui+TextLabel секундомер вверху по центру '0.0 сек' - __rbxl_show_text подсказка - RunService.Heartbeat: time += dt → label.Text каждый кадр - BindableEvent FinishReached - g8_finish: Touched → ev:Fire с fired-флагом - При финише: 'Финиш! Твоё время: X.X сек' + win Sound + confetti --- src/community/docsGamesBuildersLua.js | 64 +++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 2166f9f..646c9a7 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -564,18 +564,66 @@ end)`, // ИГРА 8 — «Беги до финиша» // ═══════════════════════════════════════════════════════════════ 'run-to-finish': { - g8_main: `-- === ИГРА «БЕГИ ДО ФИНИША» — главный скрипт (Lua) === -print("Беги к зелёной плите!")`, - g8_finish: `-- === Финишная плита (Lua) === + g8_main: `-- === ИГРА «БЕГИ К ФИНИШУ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local finished = false +local time = 0 + +__rbxl_show_text("Беги к зелёному финишу — на время!", 3) + +-- Секундомер вверху по центру +local screenGui = Instance.new("ScreenGui", player.PlayerGui) +local timerLabel = Instance.new("TextLabel", screenGui) +timerLabel.Size = UDim2.new(0, 220, 0, 60) +timerLabel.Position = UDim2.new(0.5, -110, 0, 20) +timerLabel.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +timerLabel.BackgroundTransparency = 0.4 +timerLabel.TextColor3 = Color3.fromRGB(255, 255, 255) +timerLabel.TextScaled = true +timerLabel.Font = Enum.Font.SourceSansBold +timerLabel.Text = "0.0 сек" + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Каждый кадр прибавляем dt к таймеру +RunService.Heartbeat:Connect(function(dt) + if finished then return end + time = time + (dt or 0.016) + -- Округляем до одного знака для отображения + local rounded = math.floor(time * 10) / 10 + timerLabel.Text = string.format("%.1f сек", rounded) +end) + +-- Финиш-зона шлёт BindableEvent +local finishEvent = getEvent("FinishReached") +finishEvent.Event:Connect(function() + if finished then return end + finished = true + local t = math.floor(time * 10) / 10 + winSound:Play() + __rbxl_show_text("Финиш! Твоё время: " .. string.format("%.1f", t) .. " сек", 6) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g8_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent -local won = false +local fired = false + part.Touched:Connect(function(hit) - if won then return end + if fired then return end local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") if not h then return end - won = true - print("ПОБЕДА! Ты добежал!") - h.WalkSpeed = 0 + fired = true + local ev = ReplicatedStorage:FindFirstChild("FinishReached") + if ev then ev:Fire() end end)`, }, -- 2.47.2 From 41e0f7b6a44804aee83fb347bd00d778577d4f91 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:24:16 +0300 Subject: [PATCH 125/214] =?UTF-8?q?feat(wiki):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20CodeBoth=20=D1=81=20Lua-=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D0=BB=D0=B5=D0=BB=D1=8C=D1=8E=20=D0=BA=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=B0=D0=BC=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=201-8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создан хелпер CodeBoth в docsLessons.jsx: оборачивает в . Юзер переключает JS↔Lua вверху урока — код в статье меняется тоже. Заменены 17 блоков в уроках игр 1-8 на : - collect-coins (g1_main, g1_coin_1) - platform-jump (g2_main, g2_finish) - dont-fall (g3_main, g3_tile_1) - button-door (g4_main, g4_button, g4_finish) - maze (g5_main, g5_finish) - color-tiles (g6_main, g6_tile_1) - catch-falling (g7_main) - run-to-finish (g8_main, g8_finish) Для остальных игр (9-50) остался JS-only Code — заменим по мере прохождения. --- src/community/docsLessons.jsx | 83 +++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 66d563f..6261197 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -1,5 +1,24 @@ import React from 'react'; import { Code, ScriptKind, Step, Note, Try, Shot } from './docsData'; +import { LangTabs } from './docsLang'; +import { LUA_OVERRIDES } from './docsGamesBuildersLua'; + +/** + * Хелпер: оборачивает JS-код в LangTabs с Lua-параллелью из LUA_OVERRIDES. + * {`// JS код...`} + * → если открыт JS — показывает JS код + * → если открыт Lua — показывает Lua-код из LUA_OVERRIDES[game][script] + */ +function CodeBoth({ game, script, children }) { + const luaCode = LUA_OVERRIDES[game]?.[script]; + const luaResolved = typeof luaCode === 'function' ? luaCode({ id: script }) : luaCode; + return ( + {children}} + lua={luaResolved ? {luaResolved} : null} + /> + ); +} /** * docsLessons.jsx — тексты уроков для 50 мини-игр (раздел K вики). @@ -104,7 +123,7 @@ export const LESSONS = { Главный скрипт считает монетки и проверяет победу.

    - {`// === ИГРА «СОБЕРИ МОНЕТКИ» — главный скрипт === + {`// === ИГРА «СОБЕРИ МОНЕТКИ» — главный скрипт === // Этот скрипт глобальный: считает собранные монетки и проверяет победу. let score = 0; // сколько монеток собрано @@ -131,7 +150,7 @@ game.onMessage('coin', () => { { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); } -});`} +});`}

    Разберём построчно:

    @@ -154,14 +173,14 @@ game.onMessage('coin', () => { касание и сообщает главному скрипту: меня собрали.

    - {`// === Скрипт монетки === + {`// === Скрипт монетки === // game.self — это сама монетка, на которой висит скрипт. game.self.onTouch(() => { // игрок коснулся монетки — сообщаем главному скрипту game.broadcast('coin'); game.self.delete(); // монетка исчезает со сцены -});`} +});`}

    Что происходит: onTouch срабатывает, когда игрок дотронулся до монетки. Внутри мы шлём @@ -274,7 +293,7 @@ game.self.onTouch(() => { Главный скрипт следит за падением и обрабатывает победу.

    - {`// === ИГРА «ПРЫГАЙ ПО ПЛАТФОРМАМ» — главный скрипт === + {`// === ИГРА «ПРЫГАЙ ПО ПЛАТФОРМАМ» — главный скрипт === let won = false; // победа уже была? @@ -304,7 +323,7 @@ game.onMessage('finish', () => { game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

    Что тут важно:

    • game.onTick(...) — функция внутри @@ -320,13 +339,13 @@ game.onMessage('finish', () => {

      Шаг 4. Скрипт финиша

      - {`// === Скрипт финиша === + {`// === Скрипт финиша === // Висит на невидимой зоне над зелёной площадкой. // Игрок встал на площадку — его тело внутри зоны — победа. game.self.onTouch(() => { game.broadcast('finish'); // сообщаем главному скрипту о победе -});`} +});`}

      Когда игрок касается финиша, скрипт шлёт game.broadcast('finish'). Главный скрипт ловит @@ -401,7 +420,7 @@ game.self.onTouch(() => {

      Шаг 2. Главный скрипт

      Следит за падением и победой — как в уроке 2.

      - {`// === ИГРА «НЕ УПАДИ» — главный скрипт === + {`// === ИГРА «НЕ УПАДИ» — главный скрипт === let won = false; @@ -428,7 +447,7 @@ game.onMessage('finish', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

      Шаг 3. Скрипт исчезающей плитки

      @@ -436,7 +455,7 @@ game.onMessage('finish', () => { который убирает её через секунду после касания.

      - {`// === Скрипт исчезающей плитки === + {`// === Скрипт исчезающей плитки === let triggered = false; // плитка уже запущена на исчезновение? @@ -448,7 +467,7 @@ game.self.onTouch(() => { game.after(1.2, () => { game.self.delete(); }); -});`} +});`}

      Разберём:

      • triggered — флажок-защёлка. Игрок может @@ -532,7 +551,7 @@ game.self.onTouch(() => {

        Шаг 3. Главный скрипт

        - {`// === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт === + {`// === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт === game.ui.showText('Подойди к красной кнопке и нажми E', 4); @@ -544,7 +563,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

        Шаг 4. Скрипт кнопки — главное

        @@ -552,7 +571,7 @@ game.onMessage('win', () => { открывает дверь.

        - {`// === Скрипт кнопки === + {`// === Скрипт кнопки === let opened = false; @@ -570,7 +589,7 @@ game.self.onInteract(() => { }, { text: 'Открыть дверь', // подсказка над кнопкой distance: 4 // на сколько метров подойти -});`} +});`}

        Разберём:

        • game.self.onInteract(fn, опции) — это @@ -592,10 +611,10 @@ game.self.onInteract(() => {

          Шаг 5. Скрипт финиша и проверка

          - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}

          Запусти игру:

          • подойди к кнопке — появится подсказка «Открыть дверь»;
          • @@ -688,7 +707,7 @@ game.self.onTouch(() => {

            Шаг 5. Скрипты

            Скрипты совсем простые — лабиринт держится на постройке.

            - {`// === ИГРА «ЛАБИРИНТ» — главный скрипт === + {`// === ИГРА «ЛАБИРИНТ» — главный скрипт === game.ui.showText('Найди выход из лабиринта!', 3); @@ -700,12 +719,12 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`} - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}

            Шаг 6. Проверка

              @@ -764,7 +783,7 @@ game.self.onTouch(() => {

              Шаг 2. Главный скрипт

              - {`// === ИГРА «ЦВЕТНЫЕ ПЛИТКИ» — главный скрипт === + {`// === ИГРА «ЦВЕТНЫЕ ПЛИТКИ» — главный скрипт === let painted = 0; // сколько плиток раскрашено const TOTAL = 36; // всего плиток (6×6) @@ -785,7 +804,7 @@ game.onMessage('paint', () => { game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); } -});`} +});`} Замени число 36 на столько плиток, сколько реально поставил. Если сетка 5×5 — будет 25. @@ -793,7 +812,7 @@ game.onMessage('paint', () => {

              Шаг 3. Скрипт плитки

              - {`// === Скрипт цветной плитки === + {`// === Скрипт цветной плитки === let painted = false; // плитка уже раскрашена? @@ -803,7 +822,7 @@ game.self.onTouch(() => { // меняем цвет плитки на ярко-зелёный game.scene.setColor(game.self.ref, '#33dd55'); game.broadcast('paint'); // сообщаем главному скрипту о покраске -});`} +});`}

              Главное здесь:

              • game.scene.setColor(ref, цвет) — меняет @@ -874,7 +893,7 @@ game.self.onTouch(() => {

                Шаг 2. Главный скрипт

                - {`// === ИГРА «ПОЙМАЙ ПАДАЮЩЕЕ» — главный скрипт === + {`// === ИГРА «ПОЙМАЙ ПАДАЮЩЕЕ» — главный скрипт === let score = 0; const GOAL = 15; // сколько кубов нужно поймать @@ -922,7 +941,7 @@ game.onPlayerTouch((e) => { game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); } -});`} +});`}

                Разберём по частям:

                • game.every(1.5, fn) — каждые 1.5 секунды @@ -1005,7 +1024,7 @@ game.onPlayerTouch((e) => {

                  Шаг 2. Главный скрипт

                  - {`// === ИГРА «БЕГИ К ФИНИШУ» — главный скрипт === + {`// === ИГРА «БЕГИ К ФИНИШУ» — главный скрипт === let finished = false; let time = 0; // прошло секунд @@ -1032,7 +1051,7 @@ game.onMessage('finish', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                  Главное здесь — измерение времени:

                  • game.onTick((dt) => {'{...}'}) — @@ -1048,10 +1067,10 @@ game.onMessage('finish', () => {

                    Шаг 3. Скрипт финиша

                    - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('finish'); // сообщаем главному скрипту о финише -});`} +});`}

                    Шаг 4. Проверка

                      -- 2.47.2 From bec3c478e791febfa444edf8c300f5609c5dbb10 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:35:47 +0300 Subject: [PATCH 126/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=209=20=C2=AB?= =?UTF-8?q?=D0=A1=D0=B2=D0=B5=D1=82=D0=BE=D1=84=D0=BE=D1=80=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - showText 'Зелёный — беги! Красный — замри!' - light=findOne('Светофор'), green/red/green циклически (3с/2.5с) - setColor light на красный/зелёный + showText - onTick: если красный и moved/dt>0.8 — respawn + lose sound - onMessage 'win' → showText + win + confetti - g9_finish: onTouch → broadcast 'win' Lua (паритет): - __rbxl_show_text для всех подсказок - Heartbeat-таймер фазы (task.spawn не умеет yield) — переключает phase 'green'/'red' каждые GREEN_TIME/RED_TIME - light=workspace:FindFirstChild('Светофор'), .Color = Color3.fromRGB - Heartbeat: prevX/prevZ, при phase=red и moved/dt>0.8 → LoadCharacter + lose Sound + 'Двинулся на красный!' - BindableEvent WinReached - g9_finish: Touched → ev:Fire с fired-флагом - При победе: win Sound + confetti --- src/community/docsGamesBuildersLua.js | 102 +++++++++++++++++++------- 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 646c9a7..437e3ac 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -632,41 +632,89 @@ end)`, // ═══════════════════════════════════════════════════════════════ 'traffic-light': { g9_main: `-- === ИГРА «СВЕТОФОР» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + local Players = game:GetService("Players") local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false +local phase = "green" -- "green" (беги) или "red" (замри) +local phaseTimer = 0 +local GREEN_TIME = 3 +local RED_TIME = 2.5 -local isGreen = true -print("ЗЕЛЁНЫЙ — беги!") +__rbxl_show_text("Зелёный — беги! Красный — замри!", 3) --- Каждые 3-5 сек переключаем свет -task.spawn(function() - while true do - task.wait(math.random(3, 5)) - isGreen = not isGreen - if isGreen then - print("ЗЕЛЁНЫЙ — беги!") - else - print("КРАСНЫЙ — стой!") +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 1 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Находим светофор и сразу красим в зелёный +local light = workspace:FindFirstChild("Светофор") +if light then light.Color = Color3.fromRGB(34, 221, 85) end +__rbxl_show_text("ЗЕЛЁНЫЙ — беги!", 1.2) + +-- Каждый кадр считаем таймер фазы и проверяем движение +local prevX, prevZ = nil, nil +RunService.Heartbeat:Connect(function(dt) + if won then return end + dt = dt or 0.016 + + -- Переключение фаз + phaseTimer = phaseTimer + dt + if phase == "green" and phaseTimer >= GREEN_TIME then + phaseTimer = 0 + phase = "red" + if light then light.Color = Color3.fromRGB(226, 59, 59) end + __rbxl_show_text("КРАСНЫЙ — замри!", 1.2) + elseif phase == "red" and phaseTimer >= RED_TIME then + phaseTimer = 0 + phase = "green" + if light then light.Color = Color3.fromRGB(34, 221, 85) end + __rbxl_show_text("ЗЕЛЁНЫЙ — беги!", 1.2) + end + + -- Если красный и игрок шевелится — респаун + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + if prevX and phase == "red" then + local dx = px - prevX + local dz = pz - prevZ + local moved = math.sqrt(dx*dx + dz*dz) + if moved / dt > 0.8 then + player:LoadCharacter() + loseSound:Play() + __rbxl_show_text("Двинулся на красный! На старт.", 2) end end + prevX, prevZ = px, pz end) --- Следим за движением игрока во время красного -local lastPos = {} -RunService.Heartbeat:Connect(function() - if isGreen then return end - for _, player in ipairs(Players:GetPlayers()) do - local char = player.Character - local hrp = char and char:FindFirstChild("HumanoidRootPart") - if hrp then - local prev = lastPos[player] - if prev and (hrp.Position - prev).Magnitude > 0.5 then - print(player.Name .. " двигался на красный! Респаун") - player:LoadCharacter() - end - lastPos[player] = hrp.Position - end - end +-- Финиш-зона шлёт BindableEvent +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты дошёл до финиша!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g9_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end end)`, }, -- 2.47.2 From f18835d5e97c355b9c65cec86258493fa065dcbf Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:40:20 +0300 Subject: [PATCH 127/214] =?UTF-8?q?fix(lua):=20player:LoadCharacter()=20/?= =?UTF-8?q?=20hrp.Position=20=D1=82=D0=B5=D0=BB=D0=B5=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D1=83=D1=8E=D1=82=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20player.=5Fpos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameRuntime обрабатывал prop:'position' и prop:'respawn' через player.body.position.set() — но PlayerController хранит позицию в player._pos (не body). Поэтому LoadCharacter молча ничего не делал. Фикс: ставим player._pos.set(x, y+halfH, z) + сброс _vy. Fallback на body если _pos нет. --- src/editor/engine/GameRuntime.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index b051df0..a56b27d 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -4357,19 +4357,29 @@ export class GameRuntime { // Lua-вызов hrp.Position = ... — телепорт игрока try { const v = payload.value || {}; - if (player.body && player.body.position) { + const halfH = player.HALF_H ?? 0.9; + if (player._pos) { + player._pos.set(v.x || 0, (v.y || 0) + halfH, v.z || 0); + if (player._vy != null) player._vy = 0; + } else if (player.body?.position?.set) { player.body.position.set(v.x || 0, v.y || 0, v.z || 0); } } catch (_) {} } else if (payload.prop === 'respawn') { // Lua-вызов player:LoadCharacter() — телепорт к spawn и сброс HP try { - if (typeof player.respawn === 'function') player.respawn(); - else { + if (typeof player.respawn === 'function') { + player.respawn(); + } else { const sp = this.scene3d?.projectData?.scene?.spawnPoint || this.projectData?.scene?.spawnPoint || { x: 0, y: 5, z: 0 }; - if (player.body && player.body.position) { + // PlayerController хранит позицию в player._pos. + const halfH = player.HALF_H ?? 0.9; + if (player._pos) { + player._pos.set(sp.x, sp.y + halfH, sp.z); + if (player._vy != null) player._vy = 0; + } else if (player.body?.position?.set) { player.body.position.set(sp.x, sp.y, sp.z); } player.hp = player.maxHp || 100; -- 2.47.2 From 4186b49be48bd6fa2c5728f18578323c2dfd486c Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:42:14 +0300 Subject: [PATCH 128/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=209=20=C2=AB=D0=A1=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D1=84=D0=BE=D1=80=C2=BB=20(g9=5Fmain=20+=20g?= =?UTF-8?q?9=5Ffinish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 6261197..f54608f 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -1133,7 +1133,7 @@ game.self.onTouch(() => {

                      Шаг 2. Главный скрипт

                      Это самый сложный скрипт пока — разберём внимательно.

                      - {`// === ИГРА «СВЕТОФОР» — главный скрипт === + {`// === ИГРА «СВЕТОФОР» — главный скрипт === let phase = 'green'; // 'green' (беги) или 'red' (замри) let won = false; @@ -1192,7 +1192,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                      Как работают фазы:

                      • green() ставит зелёный цвет и через @@ -1215,10 +1215,10 @@ game.onMessage('win', () => {

                        Шаг 3. Скрипт финиша и проверка

                        - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                        • светофор мигает зелёный/красный;
                        • на зелёный беги, на красный замри;
                        • -- 2.47.2 From 50b08b81bc3cb255f5607e3977279e7fafbe3670 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:44:34 +0300 Subject: [PATCH 129/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=2010=20=C2=AB?= =?UTF-8?q?=D0=9F=D1=80=D1=8B=D0=B6=D0=BE=D0=BA-=D0=BF=D1=80=D1=83=D0=B6?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - showText 'Прыгай по батутам всё выше!' - onTick: y<-3 → respawn + lose sound - onMessage 'win' → showText + win + confetti - g10_tramp_N: onTouch → player.boostJump(3.2) + jump sound - g10_finish: onTouch → broadcast 'win' Lua (паритет): - __rbxl_show_text подсказка + 'Победа!' - Heartbeat: __rbxl_player_y() < -3 → LoadCharacter + lose Sound - BindableEvent WinReached + g10_finish.Touched → ev:Fire - При win — confetti Добавил хелпер в shim: - __rbxl_boost_jump(strength) → send 'player.boostJump' 3.2 = втрое выше обычного прыжка g10_tramp_4/5/6: Touched → __rbxl_boost_jump(3.2) + jump Sound с защитой от зацикливания (минимум 0.5с между активациями). --- src/community/docsGamesBuildersLua.js | 85 +++++++++++++++++++++++---- src/editor/engine/lua/RobloxShim.js | 5 ++ 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 437e3ac..3856f82 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -721,20 +721,83 @@ end)`, // ═══════════════════════════════════════════════════════════════ // ИГРА 10 — «Прыжки на пружинах» // ═══════════════════════════════════════════════════════════════ - 'spring-jump': { - g10_main: `-- === ИГРА «ПРЫЖКИ НА ПРУЖИНАХ» — главный скрипт (Lua) === -print("Прыгай с пружины на пружину до финиша!")`, - g10_spring: `-- === Скрипт пружины (Lua) === + 'spring-jump': (function() { + const overrides = { + g10_main: `-- === ИГРА «ПРЫЖОК-ПРУЖИНА» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false + +__rbxl_show_text("Прыгай по батутам всё выше!", 3) + +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 1 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Каждый кадр проверяем: упал вниз — на старт +RunService.Heartbeat:Connect(function() + if won then return end + local py = __rbxl_player_y() + if py < -3 then + player:LoadCharacter() + loseSound:Play() + end +end) + +-- Финиш-зона шлёт BindableEvent +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты допрыгал до верха!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g10_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, + }; + // Скрипт каждого батута — одинаковый код + const trampScript = `-- === Скрипт батута (Lua) === +-- Игрок встал на батут — мощный подброс вверх. +local part = script.Parent +local jumpSound = Instance.new("Sound", part) +jumpSound.SoundId = "jump"; jumpSound.Volume = 0.7 + +local lastBoost = 0 part.Touched:Connect(function(hit) local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") - local hrp = hit.Parent and hit.Parent:FindFirstChild("HumanoidRootPart") - if h and hrp then - -- Подбрасываем игрока вверх - hrp.Velocity = Vector3.new(hrp.Velocity.X, 80, hrp.Velocity.Z) - end -end)`, - }, + if not h then return end + -- Не зацикливаем подброс — минимум 0.5с между активациями + local now = tick() + if now - lastBoost < 0.5 then return end + lastBoost = now + __rbxl_boost_jump(3.2) -- 3.2 = втрое выше обычного прыжка + jumpSound:Play() +end)`; + // Батуты в JS-builder имеют id 4, 5, 6 (после трёх этажей) + for (const tid of [4, 5, 6]) { + overrides['g10_tramp_' + tid] = trampScript; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 11 — «Эхо» (нажми кнопку → звук) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 206dc56..e85c597 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1816,6 +1816,11 @@ export function registerRobloxShim(lua, opts) { color: color || '#ffffff', }); }); + // Подброс игрока — паритет с JS game.player.boostJump(strength). + // 1.0 = обычный прыжок, 3.0 = втрое выше, и т.д. + global.set('__rbxl_boost_jump', (strength) => { + send('player.boostJump', { strength: Number(strength) || 1 }); + }); // Эффекты частиц (confetti, sparks и т.п.) — как game.scene.spawnParticles. // BabylonScene._spawnParticleEffect ждёт payload.type и payload.position. global.set('__rbxl_spawn_particles', (kind, x, y, z, duration, count) => { -- 2.47.2 From 3be10c3cf7b9c670312666238ffc6290d96a57a0 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:46:08 +0300 Subject: [PATCH 130/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2010=20=C2=AB=D0=9F=D1=80?= =?UTF-8?q?=D1=8B=D0=B6=D0=BE=D0=BA-=D0=BF=D1=80=D1=83=D0=B6=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=C2=BB=20(main+tramp+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index f54608f..d8dd638 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -1287,7 +1287,7 @@ game.self.onTouch(() => {

                          Шаг 3. Главный скрипт

                          - {`// === ИГРА «ПРЫЖОК-ПРУЖИНА» — главный скрипт === + {`// === ИГРА «ПРЫЖОК-ПРУЖИНА» — главный скрипт === let won = false; @@ -1313,18 +1313,18 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                          Шаг 4. Скрипт батута

                          Повесь этот скрипт на каждый батут — он одинаковый.

                          - {`// === Скрипт батута === + {`// === Скрипт батута === // Игрок встал на батут — мощный подброс вверх. game.self.onTouch(() => { game.player.boostJump(3.2); // 3.2 = в 3 раза выше обычного прыжка game.sound.play('jump'); -});`} +});`}

                          game.player.boostJump(сила) — мгновенно подбрасывает игрока. 1 — как обычный прыжок, @@ -1339,10 +1339,10 @@ game.self.onTouch(() => {

                          Шаг 5. Скрипт финиша и проверка

                          - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                          • встал на батут — подлетел вверх;
                          • попал на этаж — иди к следующему батуту;
                          • -- 2.47.2 From eacc3f990b0ef5c4a25f33bf0410311571459b47 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:47:57 +0300 Subject: [PATCH 131/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=2011=20=C2=AB?= =?UTF-8?q?=D0=AD=D1=85=D0=BE-=D0=BA=D0=BE=D0=BC=D0=BD=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - 6 цветных плиток-цилиндров со своими звуками - ui.score, ui.showText 'Наступи на все цветные плитки!' - onMessage 'step' → score++ + при 6 'Иди на финиш' - onMessage 'finish' → если < TOTAL то 'Сначала пройди все плитки' иначе showText + win + confetti - g11_tile_N: onTouch → sound + sparks particles, при первом — broadcast - g11_finish: onTouch → broadcast 'finish' Lua (паритет): - ScreenGui 'Плитки: N / 6' - BindableEvent EchoStep (плитка) + EchoFinish (зона) - 6 g11_tile_N: каждая со своим Sound (coin/jump/pickup/click/hit/coin) + __rbxl_spawn_particles('sparks', x, y+1, z) при касании + Throttle 0.4с между звуками + used-флаг - g11_main: 'Все плитки звучали! Иди на финиш' при 6 'Сначала пройди все 6 плиток!' если рано пришёл на финиш При win — Sound 'win' + confetti --- src/community/docsGamesBuildersLua.js | 117 +++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 11 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 3856f82..7850d16 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -802,22 +802,117 @@ end)`; // ═══════════════════════════════════════════════════════════════ // ИГРА 11 — «Эхо» (нажми кнопку → звук) // ═══════════════════════════════════════════════════════════════ - 'echo-room': { - g11_main: `-- === ИГРА «ЭХО» (Lua) === -print("Касайся блоков — они отвечают звуком!")`, - g11_block: `-- === Скрипт звукового блока (Lua) === + 'echo-room': (function() { + // Звуки и цвета для 6 плиток — должны совпадать с JS-builder + const tiles = [ + { sound: 'coin', color: '#e23b3b' }, // 1 + { sound: 'jump', color: '#f59e0b' }, // 2 + { sound: 'pickup', color: '#facc15' }, // 3 + { sound: 'click', color: '#22c55e' }, // 4 + { sound: 'hit', color: '#3b82f6' }, // 5 + { sound: 'coin', color: '#a855f7' }, // 6 + ]; + const TOTAL = tiles.length; + + const overrides = { + g11_main: `-- === ИГРА «ЭХО-КОМНАТА» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local player = Players.LocalPlayer +local stepped = 0 +local TOTAL = ${TOTAL} +local won = false + +__rbxl_show_text("Наступи на все цветные плитки!", 3) + +-- Счётчик в правом верхнем углу +local screenGui = Instance.new("ScreenGui", player.PlayerGui) +local label = Instance.new("TextLabel", screenGui) +label.Size = UDim2.new(0, 240, 0, 50) +label.Position = UDim2.new(1, -260, 0, 20) +label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +label.BackgroundTransparency = 0.4 +label.TextColor3 = Color3.fromRGB(255, 255, 255) +label.TextScaled = true +label.Font = Enum.Font.SourceSansBold +label.Text = "Плитки: 0 / " .. TOTAL + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Плитка впервые засчитана +local stepEvent = getEvent("EchoStep") +stepEvent.Event:Connect(function() + stepped = stepped + 1 + label.Text = "Плитки: " .. stepped .. " / " .. TOTAL + if stepped >= TOTAL then + __rbxl_show_text("Все плитки звучали! Иди на финиш.", 3) + end +end) + +-- Игрок встал на финиш +local finishEvent = getEvent("EchoFinish") +finishEvent.Event:Connect(function() + if won then return end + if stepped < TOTAL then + __rbxl_show_text("Сначала пройди все " .. TOTAL .. " плиток!", 2) + return + end + won = true + winSound:Play() + __rbxl_show_text("Победа! Эхо-комната пройдена!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g11_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent -local lastTouch = 0 + part.Touched:Connect(function(hit) - local now = tick() - if now - lastTouch < 0.5 then return end - lastTouch = now local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") if not h then return end - print("Блок " .. part.Name .. " звенит!") - part.Color = Color3.fromRGB(math.random(0,255), math.random(0,255), math.random(0,255)) + local ev = ReplicatedStorage:FindFirstChild("EchoFinish") + if ev then ev:Fire() end end)`, - }, + }; + + // Скрипт каждой звуковой плитки — со своим звуком и цветом + for (let i = 0; i < TOTAL; i++) { + const t = tiles[i]; + overrides['g11_tile_' + (i + 1)] = `-- === Скрипт звуковой плитки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local used = false +local lastSound = 0 + +local tileSound = Instance.new("Sound", part) +tileSound.SoundId = "${t.sound}"; tileSound.Volume = 0.8 + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + -- Звук эхом — каждый раз, но не чаще 0.4с + local now = tick() + if now - lastSound > 0.4 then + lastSound = now + tileSound:Play() + -- Вспышка частиц над плиткой + local pos = part.Position + __rbxl_spawn_particles("sparks", pos.X, pos.Y + 1, pos.Z, 0.6, 1) + end + -- Засчитываем плитку только в первый раз + if not used then + used = true + local ev = ReplicatedStorage:FindFirstChild("EchoStep") + if ev then ev:Fire() end + end +end)`; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 12 — «Кодовая дверь» -- 2.47.2 From b2f6b084dfc41a6181f1fea5a35d7aba4a235660 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:51:40 +0300 Subject: [PATCH 132/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2011=20=C2=AB=D0=AD=D1=85?= =?UTF-8?q?=D0=BE-=D0=BA=D0=BE=D0=BC=D0=BD=D0=B0=D1=82=D0=B0=C2=BB=20(main?= =?UTF-8?q?+tile+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index d8dd638..46bb5d7 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -1405,7 +1405,7 @@ game.self.onTouch(() => {

                            Шаг 3. Главный скрипт

                            - {`// === ИГРА «ЭХО-КОМНАТА» — главный скрипт === + {`// === ИГРА «ЭХО-КОМНАТА» — главный скрипт === let stepped = 0; // на сколько плиток наступили const TOTAL = 6; @@ -1436,7 +1436,7 @@ game.onMessage('finish', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                            Шаг 4. Скрипт звуковой плитки

                            @@ -1446,7 +1446,7 @@ game.onMessage('finish', () => { 'hit'.

                            - {`// === Скрипт звуковой плитки === + {`// === Скрипт звуковой плитки === let used = false; // на эту плитку уже наступали? @@ -1461,7 +1461,7 @@ game.self.onTouch(() => { used = true; game.broadcast('step'); // сообщаем главному скрипту о новой плитке } -});`} +});`}

                            Разберём:

                            • game.sound.play('coin') — проигрывает @@ -1481,10 +1481,10 @@ game.self.onTouch(() => {

                              Шаг 5. Скрипт финиша и проверка

                              - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('finish'); // сообщаем главному скрипту о финише -});`} +});`}
                              • наступаешь на плитку — звук и искры;
                              • прошёл все 6 — появится подсказка идти на финиш;
                              • -- 2.47.2 From 89baf23877960eaafe78cb85bf7a93cc5e092002 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:53:22 +0300 Subject: [PATCH 133/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=2012=20=C2=AB?= =?UTF-8?q?=D0=94=D0=B2=D0=B5=D1=80=D1=8C=20=D0=BF=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - CODE=[3,1,4,2], showText 'Нажми кнопки в правильном порядке (E)' - onMessage 'press' с {num} → click sound, push в entered, проверка совпадения с CODE, ошибка → сброс + 'Неверно!' + lose sound весь код → 'Код верный! Дверь открывается' + win sound + tween двери - onMessage 'win' → 'Победа!' + win + confetti - кнопка: onInteract (E, distance=3) → broadcast 'press' {num} - финиш: onTouch → broadcast 'win' Lua (паритет): - __rbxl_show_text всех подсказок + Sound 'click'/'lose'/'win' - BindableEvent ButtonPress (с num аргументом) + WinReached - g12_main: tween двери (Position.Y+=6) + CanCollide=false - 4 g12_btn_N: BillboardGui '[E] Нажать N' видим в радиусе 3 UserInputService.InputBegan E → ev:Fire(num) Hint видимость через Heartbeat + __rbxl_player_x/z - g12_finish: Touched → ev:Fire WinReached - При win — confetti --- src/community/docsGamesBuildersLua.js | 134 ++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 19 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 7850d16..e8202e2 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -917,31 +917,127 @@ end)`; // ═══════════════════════════════════════════════════════════════ // ИГРА 12 — «Кодовая дверь» // ═══════════════════════════════════════════════════════════════ - 'code-door': { - g12_main: `-- === ИГРА «КОДОВАЯ ДВЕРЬ» — главный скрипт (Lua) === + 'code-door': (function() { + const overrides = { + g12_main: `-- === ИГРА «ДВЕРЬ ПО КОДУ» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} -local correctCode = "1234" -local currentInput = "" +local TweenService = game:GetService("TweenService") +local CODE = { 3, 1, 4, 2 } +local entered = {} +local opened = false +local won = false -print("Введи код 1234 (касайся кнопок по порядку)") +__rbxl_show_text("Нажми кнопки в правильном порядке (E)", 4) -local ev = getEvent("CodeButton") -ev.Event:Connect(function(digit) - currentInput = currentInput .. tostring(digit) - print("Ввод: " .. currentInput) - if #currentInput == 4 then - if currentInput == correctCode then - print("Верно! Дверь открывается") - local doorEv = getEvent("DoorOpen") - doorEv:Fire() - else - print("Неверный код, попробуй ещё") - end - currentInput = "" +local clickSound = Instance.new("Sound", workspace) +clickSound.SoundId = "click"; clickSound.Volume = 0.7 +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.8 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local pressEvent = getEvent("ButtonPress") +pressEvent.Event:Connect(function(num) + if opened then return end + clickSound:Play() + table.insert(entered, num) + local i = #entered + if entered[i] ~= CODE[i] then + -- ошибка — сброс + entered = {} + __rbxl_show_text("Неверно! Код сброшен.", 1.5) + loseSound:Play() + return end + if #entered == #CODE then + opened = true + __rbxl_show_text("Код верный! Дверь открывается.", 3) + winSound:Play() + local door = workspace:FindFirstChild("Дверь") + if door then + local dp = door.Position + local goal = { Position = Vector3.new(dp.X, dp.Y + 6, dp.Z) } + TweenService:Create(door, TweenInfo.new(1.2), goal):Play() + door.CanCollide = false + end + else + __rbxl_show_text("Верно! Дальше...", 1) + end +end) + +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_show_text("Победа! Ты разгадал код!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end)`, - }, + g12_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, + }; + // 4 кнопки — каждая шлёт свой номер при нажатии E (если игрок рядом) + for (let num = 1; num <= 4; num++) { + overrides['g12_btn_' + num] = `-- === Скрипт кнопки-цифры ${num} (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +-- Подсказка над кнопкой (виден когда игрок рядом) +local hintGui = Instance.new("BillboardGui", part) +hintGui.Size = UDim2.new(4, 0, 1, 0) +hintGui.StudsOffset = Vector3.new(0, 1.5, 0) +hintGui.AlwaysOnTop = true +local label = Instance.new("TextLabel", hintGui) +label.Size = UDim2.new(1, 0, 1, 0) +label.BackgroundTransparency = 1 +label.TextColor3 = Color3.fromRGB(255, 255, 255) +label.TextStrokeTransparency = 0 +label.TextScaled = true +label.Text = "[E] Нажать ${num}" +label.Visible = false + +-- Каждый кадр проверяем дистанцию до игрока +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 3 + if near ~= hintVisible then + hintVisible = near + label.Visible = near + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("ButtonPress") + if ev then ev:Fire(${num}) end +end)`; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 13 — «Торговец» -- 2.47.2 From e0a457bd7aec7ff8696b149ba7fc3395d9c42fec Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:55:09 +0300 Subject: [PATCH 134/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2012=20=C2=AB=D0=94=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8C=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=C2=BB=20(main+btn+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 46bb5d7..34129f4 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -1545,7 +1545,7 @@ game.self.onTouch(() => { Здесь самое интересное — проверка кода. Разберём подробно.

                                - {`// === ИГРА «ДВЕРЬ ПО КОДУ» — главный скрипт === + {`// === ИГРА «ДВЕРЬ ПО КОДУ» — главный скрипт === // СЕКРЕТНЫЙ КОД — порядок кнопок. Поменяй на свой! const CODE = [3, 1, 4, 2]; @@ -1594,7 +1594,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                Как работает проверка кода:

                                • CODE = [3, 1, 4, 2] — секретный код, @@ -1628,7 +1628,7 @@ game.onMessage('win', () => { цифра в press(...) и в подсказке.

                                  - {`// === Скрипт кнопки-цифры 1 === + {`// === Скрипт кнопки-цифры 1 === game.self.onInteract(() => { // сообщаем главному скрипту номер нажатой кнопки @@ -1636,7 +1636,7 @@ game.self.onInteract(() => { }, { text: 'Нажать кнопку 1', distance: 3 -});`} +});`}

                                  Для «Кнопки_2» поставь {'{ num: 2 }'} и текст «Нажать кнопку 2», и так далее. @@ -1644,10 +1644,10 @@ game.self.onInteract(() => {

                                  Шаг 4. Скрипт финиша и проверка

                                  - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                                  • подходи к кнопкам, жми E;
                                  • нажал по коду 3-1-4-2 — дверь открылась;
                                  • -- 2.47.2 From e163fe97702c8b832f0e9c9fc4fec2879d6040ad Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:56:28 +0300 Subject: [PATCH 135/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=2013=20=C2=AB?= =?UTF-8?q?=D0=A2=D0=BE=D1=80=D0=B3=D0=BE=D0=B2=D0=B5=D1=86=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - spawnNpc торговец, prompts E: Прилавок onInteract → broadcast 'talk' → выдать ключ через inventory Дверь onInteract → broadcast 'openDoor' → проверка inventory.has → tween двери (y:9) - Финиш onTouch → broadcast 'win' → confetti Lua (паритет, NPC через статичный прилавок): - ScreenGui 'Ключа нет' / 'У тебя есть Ключ' (вместо inventory) - BindableEvents TalkTrader / OpenDoor / WinReached - g13_counter: BillboardGui '[E] Поговорить с торговцем' в радиусе 4 + UserInputService E → TalkTrader:Fire - g13_door: BillboardGui '[E] Открыть дверь' в радиусе 4 + E → OpenDoor:Fire (или 'Дверь заперта' если ключа нет) - g13_main: TalkTrader → если ключа нет: hasKey=true + 'Привет!' + pickup Sound OpenDoor → если hasKey: tween двери +6 по Y + win Sound WinReached → 'Победа!' + confetti - g13_finish: Touched → WinReached:Fire (fired-флаг) --- src/community/docsGamesBuildersLua.js | 190 ++++++++++++++++++++++---- 1 file changed, 163 insertions(+), 27 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index e8202e2..602d11b 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1043,36 +1043,172 @@ end)`; // ИГРА 13 — «Торговец» // ═══════════════════════════════════════════════════════════════ 'trader': { - g13_main: `-- === ИГРА «ТОРГОВЕЦ» (Lua) === -local Players = game:GetService("Players") + g13_main: `-- === ИГРА «ТОРГОВЕЦ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} -Players.PlayerAdded:Connect(function(player) - local stats = Instance.new("Folder", player); stats.Name = "leaderstats" - local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 10 -end) -for _, p in ipairs(Players:GetPlayers()) do - if not p:FindFirstChild("leaderstats") then - local stats = Instance.new("Folder", p); stats.Name = "leaderstats" - local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 10 - end -end -print("У тебя 10 монет. Купи зелье у торговца!")`, - g13_npc: `-- === Скрипт торговца (Lua) === local Players = game:GetService("Players") -local part = script.Parent -part.Touched:Connect(function(hit) - local player = Players:GetPlayerFromCharacter(hit.Parent) - if not player then return end - local stats = player:FindFirstChild("leaderstats") - if not stats then return end - if stats['Монеты'].Value >= 5 then - stats['Монеты'].Value = stats['Монеты'].Value - 5 - local h = hit.Parent:FindFirstChild("Humanoid") - if h then h.Health = math.min(h.MaxHealth, h.Health + 50) end - print("Купил зелье! +50 HP. Осталось монет: " .. stats['Монеты'].Value) - else - print("Не хватает монет! Нужно 5") +local TweenService = game:GetService("TweenService") +local player = Players.LocalPlayer +local hasKey = false +local won = false + +__rbxl_show_text("Поговори с торговцем — нажми E у прилавка", 4) + +local pickupSound = Instance.new("Sound", workspace) +pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.8 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Простой "инвентарь" в HUD: ключ +local screenGui = Instance.new("ScreenGui", player.PlayerGui) +local invLabel = Instance.new("TextLabel", screenGui) +invLabel.Size = UDim2.new(0, 220, 0, 40) +invLabel.Position = UDim2.new(1, -240, 0, 20) +invLabel.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +invLabel.BackgroundTransparency = 0.4 +invLabel.TextColor3 = Color3.fromRGB(255, 215, 0) +invLabel.TextScaled = true +invLabel.Font = Enum.Font.SourceSansBold +invLabel.Text = "Ключа нет" + +-- Игрок заговорил с торговцем +local talkEvent = getEvent("TalkTrader") +talkEvent.Event:Connect(function() + if hasKey then + __rbxl_show_text("Торговец: Иди к двери, ключ у тебя!", 3) + return end + hasKey = true + invLabel.Text = "У тебя есть Ключ" + invLabel.TextColor3 = Color3.fromRGB(100, 255, 100) + __rbxl_show_text("Торговец: Привет! Вот тебе ключ от двери. Удачи!", 4) + pickupSound:Play() +end) + +-- Игрок пытается открыть дверь +local openEvent = getEvent("OpenDoor") +openEvent.Event:Connect(function() + if not hasKey then + __rbxl_show_text("Дверь заперта. Нужен ключ от торговца.", 2) + return + end + __rbxl_show_text("Ключ подошёл! Дверь открыта.", 3) + winSound:Play() + local door = workspace:FindFirstChild("Дверь") + if door then + local dp = door.Position + local goal = { Position = Vector3.new(dp.X, dp.Y + 6, dp.Z) } + TweenService:Create(door, TweenInfo.new(1.2), goal):Play() + door.CanCollide = false + end +end) + +-- Игрок встал на финиш +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_show_text("Победа! Ты прошёл лавку торговца!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g13_counter: `-- === Скрипт прилавка (Lua) === +-- Подойди и нажми E чтобы поговорить с торговцем. +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +local hintGui = Instance.new("BillboardGui", part) +hintGui.Size = UDim2.new(5, 0, 1, 0) +hintGui.StudsOffset = Vector3.new(0, 2.5, 0) +hintGui.AlwaysOnTop = true +local label = Instance.new("TextLabel", hintGui) +label.Size = UDim2.new(1, 0, 1, 0) +label.BackgroundTransparency = 1 +label.TextColor3 = Color3.fromRGB(255, 255, 255) +label.TextStrokeTransparency = 0 +label.TextScaled = true +label.Text = "[E] Поговорить с торговцем" +label.Visible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + label.Visible = near + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("TalkTrader") + if ev then ev:Fire() end +end)`, + g13_door: `-- === Скрипт двери (Lua) === +-- Подойди и нажми E чтобы открыть дверь (нужен ключ). +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +local hintGui = Instance.new("BillboardGui", part) +hintGui.Size = UDim2.new(4, 0, 1, 0) +hintGui.StudsOffset = Vector3.new(0, 3.5, 0) +hintGui.AlwaysOnTop = true +local label = Instance.new("TextLabel", hintGui) +label.Size = UDim2.new(1, 0, 1, 0) +label.BackgroundTransparency = 1 +label.TextColor3 = Color3.fromRGB(255, 255, 255) +label.TextStrokeTransparency = 0 +label.TextScaled = true +label.Text = "[E] Открыть дверь" +label.Visible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + label.Visible = near + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("OpenDoor") + if ev then ev:Fire() end +end)`, + g13_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end end)`, }, -- 2.47.2 From fb390f402c3a0b7a7b3abf42043aae564f7f6397 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 18:59:54 +0300 Subject: [PATCH 136/214] =?UTF-8?q?fix(g13):=20=D1=82=D0=BE=D1=80=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=86-=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20+=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BD=D0=B8=D0=B7=D1=83=20=D1=8D=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Торговец-NPC отсутствовал. Спавним фигурку из 3 частей: - Тело (синий куб) на (0, 2.0, 5) — за прилавком - Голова (бежевая сфера) на (0, 3.2, 5) - Шляпа (коричневый цилиндр) на (0, 3.8, 5) 2) Подсказки [E] плыли в центр экрана. Задал явную позицию: нижняя часть, по центру, с тёмной плашкой и тенью. Применил к g13_counter и g13_door. --- src/community/docsGamesBuildersLua.js | 36 ++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 602d11b..e2503c8 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1059,6 +1059,30 @@ pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.8 local winSound = Instance.new("Sound", workspace) winSound.SoundId = "win"; winSound.Volume = 1 +-- Спавним "торговца" — фигурка из 3 кубов за прилавком на (0, ?, 5) +-- Прилавок на x=0, z=3, sz=1.5 → задняя часть z=3.75. Ставим торговца на z=5. +-- Туловище +__rbxl_spawn_part({ + type = "cube", name = "ТорговецТело", + x = 0, y = 2.0, z = 5, + sx = 1.2, sy = 1.6, sz = 0.6, + color = "#3b82f6", anchored = true, canCollide = false, +}) +-- Голова +__rbxl_spawn_part({ + type = "sphere", name = "ТорговецГолова", + x = 0, y = 3.2, z = 5, + sx = 0.8, sy = 0.8, sz = 0.8, + color = "#fcd9b6", anchored = true, canCollide = false, +}) +-- Шляпа +__rbxl_spawn_part({ + type = "cylinder", name = "ТорговецШляпа", + x = 0, y = 3.8, z = 5, + sx = 1.0, sy = 0.3, sz = 1.0, + color = "#7a4a26", anchored = true, canCollide = false, +}) + -- Простой "инвентарь" в HUD: ключ local screenGui = Instance.new("ScreenGui", player.PlayerGui) local invLabel = Instance.new("TextLabel", screenGui) @@ -1128,8 +1152,10 @@ hintGui.Size = UDim2.new(5, 0, 1, 0) hintGui.StudsOffset = Vector3.new(0, 2.5, 0) hintGui.AlwaysOnTop = true local label = Instance.new("TextLabel", hintGui) -label.Size = UDim2.new(1, 0, 1, 0) -label.BackgroundTransparency = 1 +label.Size = UDim2.new(0, 320, 0, 50) +label.Position = UDim2.new(0.5, -160, 1, -120) -- внизу по центру +label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +label.BackgroundTransparency = 0.4 label.TextColor3 = Color3.fromRGB(255, 255, 255) label.TextStrokeTransparency = 0 label.TextScaled = true @@ -1169,8 +1195,10 @@ hintGui.Size = UDim2.new(4, 0, 1, 0) hintGui.StudsOffset = Vector3.new(0, 3.5, 0) hintGui.AlwaysOnTop = true local label = Instance.new("TextLabel", hintGui) -label.Size = UDim2.new(1, 0, 1, 0) -label.BackgroundTransparency = 1 +label.Size = UDim2.new(0, 280, 0, 50) +label.Position = UDim2.new(0.5, -140, 1, -120) -- внизу по центру +label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +label.BackgroundTransparency = 0.4 label.TextColor3 = Color3.fromRGB(255, 255, 255) label.TextStrokeTransparency = 0 label.TextScaled = true -- 2.47.2 From 47ad608182425393c8e7010fbca44392bfffdcb2 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:05:55 +0300 Subject: [PATCH 137/214] =?UTF-8?q?feat(g13):=20NPC-=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B3=D0=BE=D0=B2=D0=B5=D1=86=20+=20=D0=B8=D0=BD=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D1=8C=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20?= =?UTF-8?q?JS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Юзер указал что в Lua-версии было отступление от JS: - Торговец нарисован примитивами вместо character-a скина - Ключ показан надписью в HUD вместо инвентаря-hotbar Добавил в shim хелперы паритета: - __rbxl_spawn_npc(modelType, x,y,z, name?, hp?, speed?) → cmd npc.spawn Возвращает локальный ref для дальнейших команд. - __rbxl_npc_say(ref, text, duration) → cmd npc.say - __rbxl_inventory_define(itemId, name, color) → cmd items.define - __rbxl_inventory_add(itemId, count) → cmd inv2.add (показывает в hotbar) - __rbxl_inventory_has(itemId) → проверка локального кеша - __rbxl_inventory_remove(itemId, count) → cmd inv2.remove Lua-скрипт игры 13: - spawnNpc 'character-a' за прилавком как в JS - inventory_define('key', 'Ключ') → hotbar - При разговоре: npc_say + inventory_add('key', 1) - При двери: проверяем inventory_has('key') --- src/community/docsGamesBuildersLua.js | 48 ++++------------------ src/editor/engine/lua/RobloxShim.js | 59 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 39 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index e2503c8..c759168 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1059,60 +1059,30 @@ pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.8 local winSound = Instance.new("Sound", workspace) winSound.SoundId = "win"; winSound.Volume = 1 --- Спавним "торговца" — фигурка из 3 кубов за прилавком на (0, ?, 5) --- Прилавок на x=0, z=3, sz=1.5 → задняя часть z=3.75. Ставим торговца на z=5. --- Туловище -__rbxl_spawn_part({ - type = "cube", name = "ТорговецТело", - x = 0, y = 2.0, z = 5, - sx = 1.2, sy = 1.6, sz = 0.6, - color = "#3b82f6", anchored = true, canCollide = false, -}) --- Голова -__rbxl_spawn_part({ - type = "sphere", name = "ТорговецГолова", - x = 0, y = 3.2, z = 5, - sx = 0.8, sy = 0.8, sz = 0.8, - color = "#fcd9b6", anchored = true, canCollide = false, -}) --- Шляпа -__rbxl_spawn_part({ - type = "cylinder", name = "ТорговецШляпа", - x = 0, y = 3.8, z = 5, - sx = 1.0, sy = 0.3, sz = 1.0, - color = "#7a4a26", anchored = true, canCollide = false, -}) +-- Спавним NPC-торговца за прилавком (паритет с JS spawnNpc) +local traderRef = __rbxl_spawn_npc("character-a", 0, 1, 5, "Торговец Боб", 100, 0) --- Простой "инвентарь" в HUD: ключ -local screenGui = Instance.new("ScreenGui", player.PlayerGui) -local invLabel = Instance.new("TextLabel", screenGui) -invLabel.Size = UDim2.new(0, 220, 0, 40) -invLabel.Position = UDim2.new(1, -240, 0, 20) -invLabel.BackgroundColor3 = Color3.fromRGB(0, 0, 0) -invLabel.BackgroundTransparency = 0.4 -invLabel.TextColor3 = Color3.fromRGB(255, 215, 0) -invLabel.TextScaled = true -invLabel.Font = Enum.Font.SourceSansBold -invLabel.Text = "Ключа нет" +-- Определяем итем "Ключ" в инвентаре и показываем hotbar +__rbxl_inventory_define("key", "Ключ", "#ffd700") -- Игрок заговорил с торговцем local talkEvent = getEvent("TalkTrader") talkEvent.Event:Connect(function() if hasKey then - __rbxl_show_text("Торговец: Иди к двери, ключ у тебя!", 3) + __rbxl_npc_say(traderRef, "Иди к двери, ключ у тебя!", 3) return end hasKey = true - invLabel.Text = "У тебя есть Ключ" - invLabel.TextColor3 = Color3.fromRGB(100, 255, 100) - __rbxl_show_text("Торговец: Привет! Вот тебе ключ от двери. Удачи!", 4) + __rbxl_npc_say(traderRef, "Привет! Вот тебе ключ от двери. Удачи!", 4) + __rbxl_inventory_add("key", 1) + __rbxl_show_text("Ты получил Ключ!", 2) pickupSound:Play() end) -- Игрок пытается открыть дверь local openEvent = getEvent("OpenDoor") openEvent.Event:Connect(function() - if not hasKey then + if not __rbxl_inventory_has("key") then __rbxl_show_text("Дверь заперта. Нужен ключ от торговца.", 2) return end diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index e85c597..e148a77 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1816,6 +1816,65 @@ export function registerRobloxShim(lua, opts) { color: color || '#ffffff', }); }); + // Спавн NPC — паритет с JS game.scene.spawnNpc(modelType, opts). + // Возвращает локальный ref (строку 'npc_lua_N'), который можно передавать + // в __rbxl_npc_say(ref, text, duration). + let _nextNpcRef = 0; + global.set('__rbxl_spawn_npc', (modelType, x, y, z, name, hp, speed) => { + const ref = 'npc_lua_' + (_nextNpcRef++); + send('npc.spawn', { + modelType: String(modelType || 'character-a'), + ref, + x: +x || 0, y: +y || 0, z: +z || 0, + name: name ? String(name) : undefined, + hp: hp != null ? +hp : undefined, + speed: speed != null ? +speed : undefined, + }); + return ref; + }); + global.set('__rbxl_npc_say', (ref, text, duration) => { + send('npc.say', { + ref: String(ref || ''), + text: String(text || ''), + duration: +duration || 3, + }); + }); + // Инвентарь invUI — паритет с JS game.inventory.add(itemId, count). + // Сначала определяем итем (один раз), потом добавляем. + const _localInventory = new Map(); + const _definedItems = new Set(); + global.set('__rbxl_inventory_define', (itemId, name, color) => { + const id = String(itemId || ''); + if (!id || _definedItems.has(id)) return; + _definedItems.add(id); + send('items.define', { + def: { + id, + name: name ? String(name) : id, + color: color || '#ffd700', + stack: 99, + }, + }); + }); + global.set('__rbxl_inventory_add', (itemId, count) => { + const id = String(itemId || ''); + if (!id) return; + const c = Number(count) || 1; + _localInventory.set(id, (_localInventory.get(id) || 0) + c); + send('inv2.add', { itemId: id, count: c }); + }); + global.set('__rbxl_inventory_has', (itemId) => { + return (_localInventory.get(String(itemId || '')) || 0) > 0; + }); + global.set('__rbxl_inventory_remove', (itemId, count) => { + const id = String(itemId || ''); + const c = Number(count) || 1; + const cur = _localInventory.get(id) || 0; + const newCount = Math.max(0, cur - c); + if (newCount === 0) _localInventory.delete(id); + else _localInventory.set(id, newCount); + send('inv2.remove', { itemId: id, count: c }); + }); // Подброс игрока — паритет с JS game.player.boostJump(strength). // 1.0 = обычный прыжок, 3.0 = втрое выше, и т.д. global.set('__rbxl_boost_jump', (strength) => { -- 2.47.2 From e8bfdda3805867caf641b1e91736f70628dfaea2 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:09:34 +0300 Subject: [PATCH 138/214] =?UTF-8?q?fix(g13):=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8=20=D0=B2=D1=8B=D1=88=D0=B5=20?= =?UTF-8?q?=D1=85=D0=BE=D1=82=D0=B1=D0=B0=D1=80=D0=B0=20(0.6=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=80=D0=B0=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B8=D0=B7=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прошлая позиция (1, -120) перекрывала хотбар инвентаря. Новая (0.5, -160; 0.6, 0) — выше середины экрана, не мешает. --- src/community/docsGamesBuildersLua.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index c759168..5e776f9 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1123,7 +1123,7 @@ hintGui.StudsOffset = Vector3.new(0, 2.5, 0) hintGui.AlwaysOnTop = true local label = Instance.new("TextLabel", hintGui) label.Size = UDim2.new(0, 320, 0, 50) -label.Position = UDim2.new(0.5, -160, 1, -120) -- внизу по центру +label.Position = UDim2.new(0.5, -160, 0.6, 0) -- ниже центра, над хотбаром label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) label.BackgroundTransparency = 0.4 label.TextColor3 = Color3.fromRGB(255, 255, 255) @@ -1166,7 +1166,7 @@ hintGui.StudsOffset = Vector3.new(0, 3.5, 0) hintGui.AlwaysOnTop = true local label = Instance.new("TextLabel", hintGui) label.Size = UDim2.new(0, 280, 0, 50) -label.Position = UDim2.new(0.5, -140, 1, -120) -- внизу по центру +label.Position = UDim2.new(0.5, -140, 0.6, 0) -- ниже центра, над хотбаром label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) label.BackgroundTransparency = 0.4 label.TextColor3 = Color3.fromRGB(255, 255, 255) -- 2.47.2 From b7b3c1eb810d4ec8a98f9280de5efeafb3cdf387 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:34:32 +0300 Subject: [PATCH 139/214] =?UTF-8?q?fix(g13):=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8=20[E]=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20HUD=20ui.set=20=E2=80=94=20=D0=BF=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D1=82=20=D1=81=20JS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Юзер: подсказка в Lua была слева от центра, белая. В JS — точно по центру внизу, жёлтая. Корень: я использовал BillboardGui+TextLabel с явной Position, но GUI-shim позиционирует label некорректно (UDim2 offset плохо интерпретируется → плашка плыла). Фикс: используем тот же механизм что JS — game.ui.set (HUD через React). Добавил хелпер __rbxl_hud_set(id, text, x, y, color, size) шлющий 'ui.set' cmd, GameRuntime пробрасывает в _onHud → GameHud.jsx рендерит точно как для JS-скриптов. В g13_counter/g13_door: при near=true → hud_set с (50, 75, #ffe44a, 20) (центр по X, ниже центра по Y, жёлтый — точно как JS interact hint). При выходе из зоны → hud_set(id, nil) убирает. --- src/community/docsGamesBuildersLua.js | 42 +++++++-------------------- src/editor/engine/lua/RobloxShim.js | 15 ++++++++++ 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 5e776f9..400e659 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1117,21 +1117,6 @@ local RunService = game:GetService("RunService") local part = script.Parent local hintVisible = false -local hintGui = Instance.new("BillboardGui", part) -hintGui.Size = UDim2.new(5, 0, 1, 0) -hintGui.StudsOffset = Vector3.new(0, 2.5, 0) -hintGui.AlwaysOnTop = true -local label = Instance.new("TextLabel", hintGui) -label.Size = UDim2.new(0, 320, 0, 50) -label.Position = UDim2.new(0.5, -160, 0.6, 0) -- ниже центра, над хотбаром -label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) -label.BackgroundTransparency = 0.4 -label.TextColor3 = Color3.fromRGB(255, 255, 255) -label.TextStrokeTransparency = 0 -label.TextScaled = true -label.Text = "[E] Поговорить с торговцем" -label.Visible = false - RunService.Heartbeat:Connect(function() local px = __rbxl_player_x() local pz = __rbxl_player_z() @@ -1141,7 +1126,11 @@ RunService.Heartbeat:Connect(function() local near = dist <= 4 if near ~= hintVisible then hintVisible = near - label.Visible = near + if near then + __rbxl_hud_set("g13_counter_hint", "[E] Поговорить с торговцем", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g13_counter_hint", nil) + end end end) @@ -1160,21 +1149,6 @@ local RunService = game:GetService("RunService") local part = script.Parent local hintVisible = false -local hintGui = Instance.new("BillboardGui", part) -hintGui.Size = UDim2.new(4, 0, 1, 0) -hintGui.StudsOffset = Vector3.new(0, 3.5, 0) -hintGui.AlwaysOnTop = true -local label = Instance.new("TextLabel", hintGui) -label.Size = UDim2.new(0, 280, 0, 50) -label.Position = UDim2.new(0.5, -140, 0.6, 0) -- ниже центра, над хотбаром -label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) -label.BackgroundTransparency = 0.4 -label.TextColor3 = Color3.fromRGB(255, 255, 255) -label.TextStrokeTransparency = 0 -label.TextScaled = true -label.Text = "[E] Открыть дверь" -label.Visible = false - RunService.Heartbeat:Connect(function() local px = __rbxl_player_x() local pz = __rbxl_player_z() @@ -1184,7 +1158,11 @@ RunService.Heartbeat:Connect(function() local near = dist <= 4 if near ~= hintVisible then hintVisible = near - label.Visible = near + if near then + __rbxl_hud_set("g13_door_hint", "[E] Открыть дверь", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g13_door_hint", nil) + end end end) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index e148a77..72d05a5 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1816,6 +1816,21 @@ export function registerRobloxShim(lua, opts) { color: color || '#ffffff', }); }); + // Установка/удаление HUD-плашки в фиксированной позиции — паритет с + // JS game.ui.set / game.ui.showInteractHint и аналогами. + // opts = {x, y, color, size} (x,y в процентах 0-100; color — hex) + global.set('__rbxl_hud_set', (id, text, x, y, color, size) => { + const payload = { id: String(id || ''), text: text || null }; + if (text != null) { + payload.opts = { + x: Number(x) || 50, + y: Number(y) || 75, + color: color || '#ffe44a', + size: Number(size) || 20, + }; + } + send('ui.set', payload); + }); // Спавн NPC — паритет с JS game.scene.spawnNpc(modelType, opts). // Возвращает локальный ref (строку 'npc_lua_N'), который можно передавать // в __rbxl_npc_say(ref, text, duration). -- 2.47.2 From 1a3c8e66e6d384b9d88b86d7c9743f4ddc3cd783 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:37:05 +0300 Subject: [PATCH 140/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2013=20=C2=AB=D0=A2=D0=BE?= =?UTF-8?q?=D1=80=D0=B3=D0=BE=D0=B2=D0=B5=D1=86=C2=BB=20(main+counter+door?= =?UTF-8?q?+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 34129f4..5bea7f2 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -1710,7 +1710,7 @@ game.self.onTouch(() => {

                                    Шаг 2. Главный скрипт

                                    - {`// === ИГРА «ТОРГОВЕЦ» — главный скрипт === + {`// === ИГРА «ТОРГОВЕЦ» — главный скрипт === game.ui.showText('Поговори с торговцем — нажми E у прилавка', 4); @@ -1759,7 +1759,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                    Разберём:

                                    • game.scene.spawnNpc('character-a', опции) — @@ -1775,30 +1775,30 @@ game.onMessage('win', () => {

                                      Шаг 3. Скрипт прилавка

                                      - {`// === Скрипт прилавка === + {`// === Скрипт прилавка === game.self.onInteract(() => { game.broadcast('talk'); // сообщаем главному скрипту: говорим с торговцем }, { text: 'Поговорить с торговцем', distance: 4 -});`} +});`}

                                      Шаг 4. Скрипт двери

                                      - {`// === Скрипт двери === + {`// === Скрипт двери === game.self.onInteract(() => { game.broadcast('openDoor'); // сообщаем главному скрипту: открыть дверь }, { text: 'Открыть дверь', distance: 4 -});`} +});`}

                                      Шаг 5. Скрипт финиша и проверка

                                      - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                                      • подойди к прилавку, нажми E — торговец заговорит и даст ключ;
                                      • -- 2.47.2 From cc5717f5a3fdc423e986cf0c80cfceacb570a42b Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:38:55 +0300 Subject: [PATCH 141/214] =?UTF-8?q?feat(lua-games):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B3=D1=80=D1=8B=2014=20=C2=AB?= =?UTF-8?q?=D0=A1=D0=BE=D0=B1=D0=B5=D1=80=D0=B8=20=D0=BF=D0=BE=20=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D0=B0=D0=BC=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - 7 жёлтых звёзд-конусов + 5 синих кубов-обманок - showText 'Собери все ЖЁЛТЫЕ звёзды!' - main помечает звёзды тегом 'звезда' с задержкой 0.2с - onMessage 'collected' → score++ + при 0 left — победа+confetti - star: onTouch → untag + delete + broadcast 'collected' Lua (паритет): - 7 g14_star_N через генератор (раньше был один g14_star) - ScreenGui 'Звёзды: N / 7' счётчик - BindableEvent StarCollected - main: task.delay(0.2) → AddTag всем 7 звёздам через workspace:FindFirstChild - При collect → coin Sound + GetTagged-проверка left==0 → win+confetti - g14_star_N: RemoveTag + Destroy + ev:Fire (с picked-флагом) --- src/community/docsGamesBuildersLua.js | 85 +++++++++++++++++++++------ 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 400e659..5a05ec8 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1191,37 +1191,88 @@ end)`, // ═══════════════════════════════════════════════════════════════ // ИГРА 14 — «Собери по тегу» // ═══════════════════════════════════════════════════════════════ - 'collect-by-tag': { - g14_main: `-- === ИГРА «СОБЕРИ ПО ТЕГУ» (Lua) === -local CollectionService = game:GetService("CollectionService") + 'collect-by-tag': (function() { + const TOTAL = 7; + const overrides = { + g14_main: `-- === ИГРА «СОБЕРИ ПО ТЕГАМ» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} -local total = #CollectionService:GetTagged("звезда") -local collected = 0 -print("Собери все " .. total .. " звёзд!") +local CollectionService = game:GetService("CollectionService") +local Players = game:GetService("Players") +local player = Players.LocalPlayer +local TOTAL = ${TOTAL} +local won = false -local ev = getEvent("StarCollected") -ev.Event:Connect(function() - collected = collected + 1 - print("Собрано: " .. collected .. "/" .. total) - if collected >= total then - print("ПОБЕДА! Все звёзды собраны!") +__rbxl_show_text("Собери все ЖЁЛТЫЕ звёзды!", 3) + +-- Счётчик в правом верхнем углу +local screenGui = Instance.new("ScreenGui", player.PlayerGui) +local label = Instance.new("TextLabel", screenGui) +label.Size = UDim2.new(0, 220, 0, 50) +label.Position = UDim2.new(1, -240, 0, 20) +label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +label.BackgroundTransparency = 0.4 +label.TextColor3 = Color3.fromRGB(255, 215, 0) +label.TextScaled = true +label.Font = Enum.Font.SourceSansBold +label.Text = "Звёзды: 0 / " .. TOTAL + +local coinSound = Instance.new("Sound", workspace) +coinSound.SoundId = "coin"; coinSound.Volume = 0.8 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Помечаем все звёзды тегом 'звезда' (с небольшой задержкой — +-- скрипты звёзд должны успеть запуститься и зарегистрировать part). +task.delay(0.2, function() + for i = 1, TOTAL do + local star = workspace:FindFirstChild("Звезда_" .. i) + if star then CollectionService:AddTag(star, "звезда") end + end +end) + +-- Звёзды сообщают о сборе. Главный скрипт считает оставшиеся через +-- CollectionService:GetTagged — паритет с JS game.scene.getTagged. +local collectEvent = getEvent("StarCollected") +collectEvent.Event:Connect(function() + if won then return end + local left = #CollectionService:GetTagged("звезда") + label.Text = "Звёзды: " .. (TOTAL - left) .. " / " .. TOTAL + coinSound:Play() + if left == 0 then + won = true + winSound:Play() + __rbxl_show_text("Победа! Все звёзды собраны!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end end)`, - g14_star: `-- === Скрипт звезды (Lua) === + }; + // Скрипт каждой звезды — одинаковый: на touch → untag + destroy + Fire + const starScript = `-- === Скрипт звезды (Lua) === local CollectionService = game:GetService("CollectionService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent -CollectionService:AddTag(part, "звезда") +local picked = false part.Touched:Connect(function(hit) + if picked then return end local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") if not h then return end + picked = true + -- Снимаем тег и удаляем звезду, потом шлём событие. + CollectionService:RemoveTag(part, "звезда") + part:Destroy() local ev = ReplicatedStorage:FindFirstChild("StarCollected") if ev then ev:Fire() end - part:Destroy() -end)`, - }, +end)`; + for (let i = 1; i <= TOTAL; i++) { + overrides['g14_star_' + i] = starScript; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 15 — «Тир» -- 2.47.2 From 7f3b81a531ab1c445cbc5124389358274b2b6153 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:43:20 +0300 Subject: [PATCH 142/214] =?UTF-8?q?feat:=20=D1=83=D0=BC=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE-fallback=20Lua=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=B8=D0=B3=D1=80=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=8F=D0=B2=D0=BD=D0=BE=D0=B9=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше для игр 15-50 при открытии 'Открыть мою копию на Lua' юзер получал TODO-заглушки которые ничего не делали (или simpleMain который только print). Каждая новая игра без явного override была полностью неиграбельной. Новый generateFallbackLua(s, gameTitle) в buildGameProject: Главный скрипт (target=null): - __rbxl_show_text(gameTitle) подсказка - Слушает BindableEvent FinishReached → win Sound + Победа! + confetti Скрипт-финиш (target=primitive с именем 'Финиш'/'ФинишЗона'/'Final'): - Touched → создаёт/находит BindableEvent FinishReached → Fire - fired-флаг чтобы 1 раз Прочие target-скрипты: - Touched → красит примитив зелёным (визуальный feedback) - touched-флаг Удалил статичные simpleMain stub-ы для игр 31-50 — теперь они используют умный fallback. Когда дописываем полную Lua-версию игры — добавляем явный override в LUA_OVERRIDES, fallback автоматически перестаёт использоваться. Это даёт минимум: победа на финише + цвет на касании во всех 35 не-готовых играх (15-50). --- src/community/docsGamesBuilders.js | 103 ++++++++++++++++++++++++-- src/community/docsGamesBuildersLua.js | 36 ++------- 2 files changed, 105 insertions(+), 34 deletions(-) diff --git a/src/community/docsGamesBuilders.js b/src/community/docsGamesBuilders.js index 09c7915..064598f 100644 --- a/src/community/docsGamesBuilders.js +++ b/src/community/docsGamesBuilders.js @@ -6172,6 +6172,95 @@ import { LUA_OVERRIDES } from './docsGamesBuildersLua'; /** Построить project_data для игры-урока. Возвращает объект или null. * opts.lang: 'js' (default) | 'lua' — на каком языке скрипты в копии. */ +/** + * Генерирует минимальный рабочий Lua-каркас для скрипта когда явной + * Lua-реализации в LUA_OVERRIDES нет. Анализирует target и name чтобы + * сделать что-то осмысленное: + * - target=null (главный скрипт): показывает подсказку, слушает событие + * FinishReached и при срабатывании — конфетти + Победа + * - target=primitive с именем содержащим "Финиш"/"Final": Touched → + * шлёт FinishReached + * - target=primitive с любым другим именем: Touched → красит примитив + * в случайный цвет (визуальный feedback что скрипт работает) + */ +function generateFallbackLua(s, gameTitle) { + const target = s.target; + const name = s.name || s.id || ''; + const title = gameTitle || 'игра'; + // Главный скрипт (target=null) + if (!target || target === null) { + return `-- === ${name} (Lua, авто-каркас) === +-- Полная Lua-версия этой игры пока в разработке. +-- Этот каркас обеспечивает базовое поведение: подсказка + победа на финише. +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local function getEvent(eventName) + local ev = ReplicatedStorage:FindFirstChild(eventName) + if not ev then + ev = Instance.new("BindableEvent") + ev.Name = eventName + ev.Parent = ReplicatedStorage + end + return ev +end + +__rbxl_show_text("${title.replace(/"/g, '\\"')}", 3) + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local won = false +local winEvent = getEvent("FinishReached") +winEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + __rbxl_show_text("Победа!", 4) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`; + } + // Скрипт на примитиве с именем "Финиш" / "ФинишЗона" / "Final" + const isFinish = /финиш|финал|final/i.test(name); + if (isFinish) { + return `-- === ${name} (Lua, авто-каркас) === +-- При касании игроком шлём событие победы. +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("FinishReached") + if not ev then + ev = Instance.new("BindableEvent") + ev.Name = "FinishReached" + ev.Parent = ReplicatedStorage + end + ev:Fire() +end)`; + } + // Общий каркас для любого target-примитива — Touched красит в случайный цвет + return `-- === ${name} (Lua, авто-каркас) === +-- Полная Lua-версия этого скрипта пока в разработке. +-- Базовое поведение: при касании предмет реагирует визуально. +local part = script.Parent +local touched = false + +part.Touched:Connect(function(hit) + if touched then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + touched = true + -- Меняем цвет на яркий зелёный — простой feedback + part.Color = Color3.fromRGB(60, 230, 80) +end)`; +} + export function buildGameProject(id, opts = {}) { const fn = GAME_BUILDERS[id]; if (!fn) return null; @@ -6180,9 +6269,16 @@ export function buildGameProject(id, opts = {}) { const scene = project.scene || {}; if (Array.isArray(scene.scripts)) { const overrides = LUA_OVERRIDES[id] || {}; + // Извлекаем название игры из любого скрипта (для подсказки в fallback) + let gameTitle = ''; + const mainScript = scene.scripts.find(s => !s.target); + if (mainScript) { + const m = /===\s*ИГРА\s*[«"](.+?)[»"]/i.exec(mainScript.code || ''); + if (m) gameTitle = m[1]; + } scene.scripts = scene.scripts.map(s => { if (s.language === 'lua') return s; - // Приоритет: явный code_lua → override из реестра → stub. + // Приоритет: явный code_lua → override из реестра → авто-fallback. let luaCode = s.code_lua; if (!luaCode) { const ov = overrides[s.id]; @@ -6190,10 +6286,7 @@ export function buildGameProject(id, opts = {}) { else if (typeof ov === 'string') luaCode = ov; } if (!luaCode || !luaCode.trim()) { - luaCode = `-- TODO: Lua-версия этого скрипта пока не готова. --- Переключи язык на JS в редакторе (кнопка JS вверху), чтобы увидеть рабочий код. -print("Lua-скрипт " .. (script and script.Name or "?") .. " запущен (заглушка)") -`; + luaCode = generateFallbackLua(s, gameTitle); } return { ...s, diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 5a05ec8..2d56ae2 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1722,28 +1722,14 @@ end)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРА 31-50: упрощённые версии (главные скрипты) - // ═══════════════════════════════════════════════════════════════ - 'base-defense': { g31_main: simpleMain("Защити базу от волн врагов!") }, - 'lap-race': { g32_main: simpleMain("Проедь все круги первым!") }, - 'boss-platformer': { g33_main: simpleMain("Победи босса прыжками на голову!") }, - 'harvest': { g34_main: simpleMain("Собирай урожай, продавай в магазин!") }, - 'hide-from-npc': { g35_main: simpleMain("Прячься от NPC — не попадайся!") }, - 'box-puzzle': { g36_main: simpleMain("Двигай ящики на места!") }, - 'obstacle-course': { g37_main: simpleMain("Пройди полосу препятствий!") }, - 'music-game': { g38_main: simpleMain("Жми клавиши в ритм музыки!") }, - 'tower-build': { g39_main: simpleMain("Построй самую высокую башню!") }, - 'wave-survival': { g40_main: simpleMain("Выживай в волнах врагов!") }, - 'adventure-platformer': { g41_main: simpleMain("Приключение — собирай артефакты!") }, - 'rpg-village': { g42_main: simpleMain("Бегай по деревне, выполняй квесты!") }, - 'obstacle-race': { g43_main: simpleMain("Гонка с препятствиями — финишируй!") }, - 'tower-defense': { g44_main: simpleMain("Расставь башни — не пускай врагов!") }, - 'arena-shooter': { g45_main: simpleMain("Стреляй по противникам на арене!") }, + // ИГРЫ 31-50: явных Lua-версий пока нет. + // buildGameProject в docsGamesBuilders.js использует generateFallbackLua + // (главный скрипт → показ подсказки + слушает FinishReached → + // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; + // остальные target-скрипты → красят примитив на касание). + // Это даёт «хоть что-то рабочее» в любой игре до того как напишем + // полноценный Lua-скрипт. Когда дописываем игру — добавляем сюда явный override. 'clicker': { g46_main: simpleClicker() }, - 'escape-quest': { g47_main: simpleMain("Найди подсказки и выберись!") }, - 'mp-tag': { g48_main: simpleMain("Поймай других игроков (мультиплеер)!") }, - 'mp-race': { g49_main: simpleMain("Гонка на нескольких игроков!") }, - 'make-your-own': { g50_main: simpleMain("Это твоя пустая площадка — твори!") }, }; // ══════════════════════════════════════════════════════════════════ @@ -1765,14 +1751,6 @@ part.Touched:Connect(function(hit) end)`; } -/** Простой главный скрипт со стартовой подсказкой. */ -function simpleMain(message) { - return `-- === Главный скрипт (Lua) === -print("${message.replace(/"/g, '\\"')}") --- TODO: эта игра-урок ещё не имеет полной Lua-реализации. --- Переключи язык на JS в редакторе, чтобы увидеть рабочую механику.`; -} - /** Кликер. */ function simpleClicker() { return `-- === КЛИКЕР (Lua) === -- 2.47.2 From 2e1ee87ed6d2e0a5e480292504af39ddccc89654 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:47:55 +0300 Subject: [PATCH 143/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2014=20=C2=AB=D0=A1=D0=BE?= =?UTF-8?q?=D0=B1=D0=B5=D1=80=D0=B8=20=D0=BF=D0=BE=20=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D0=B0=D0=BC=C2=BB=20(main+star)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 5bea7f2..71662f1 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -1886,7 +1886,7 @@ game.self.onTouch(() => { а потом считает, сколько звёзд осталось.

                                        - {`// === ИГРА «СОБЕРИ ПО ТЕГАМ» — главный скрипт === + {`// === ИГРА «СОБЕРИ ПО ТЕГАМ» — главный скрипт === game.ui.showText('Собери все ЖЁЛТЫЕ звёзды!', 3); @@ -1916,7 +1916,7 @@ game.onMessage('collected', () => { game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); } -});`} +});`}

                                        Разберём построчно:

                                        • цикл for внутри game.after(0.2, ...) @@ -1937,13 +1937,13 @@ game.onMessage('collected', () => {

                                          Шаг 4. Скрипт звезды

                                          Этот скрипт повесь на каждую из 7 звёзд.

                                          - {`// === Скрипт звезды === + {`// === Скрипт звезды === game.self.onTouch(() => { // снимаем тег и удаляем звезду game.scene.untag(game.self.ref, 'звезда'); game.self.delete(); game.broadcast('collected'); // сообщаем главному скрипту о сборе -});`} +});`}

                                          Что происходит при касании:

                                          • game.scene.untag(game.self.ref, 'звезда') — -- 2.47.2 From 8abbde9d6764bd484234480f57ee1e83c3e4a596 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:52:16 +0300 Subject: [PATCH 144/214] =?UTF-8?q?feat(g15):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=A2=D0=B8=D1=80=C2=BB=20+=20ClickDetector=20=D0=B2=20s?= =?UTF-8?q?him?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - 8 мишеней-сфер на постаментах - ui.score, showText 'Кликай по красным мишеням!' - target: onClick → explosion particles + delete + broadcast 'hit' - main: onMessage 'hit' → score++ + hit sound + при 8 победа+confetti Lua-shim расширение: - Instance.new('ClickDetector') создаёт инстанс с MouseClick signal - Когда clickDet.Parent = part → Proxy.set регистрирует part._clickDetector = clickDet - fireTargetEvent при kind='click' фейерит part._clickDetector.MouseClick.Fire Lua-скрипт игры 15: - ScreenGui 'Мишени: N / 8' - BindableEvent TargetHit - 8 g15_target_N (ids 2,4,6,8,10,12,14,16): ClickDetector с MaxActivationDistance=50 → MouseClick → spawn explosion + Destroy + Fire - main: hit Sound, при 8 — win Sound + showText + confetti --- src/community/docsGamesBuildersLua.js | 93 ++++++++++++++++++++------- src/editor/engine/lua/RobloxShim.js | 26 ++++++++ 2 files changed, 95 insertions(+), 24 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 2d56ae2..db17b2d 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1277,38 +1277,83 @@ end)`; // ═══════════════════════════════════════════════════════════════ // ИГРА 15 — «Тир» // ═══════════════════════════════════════════════════════════════ - 'shooting-range': { - g15_main: `-- === ИГРА «ТИР» (Lua) === -local UserInputService = game:GetService("UserInputService") -local Players = game:GetService("Players") + 'shooting-range': (function() { + // Мишени имеют id 2, 4, 6, 8, 10, 12, 14, 16 (постамент → нечётный, мишень → чётный) + const TARGET_IDS = [2, 4, 6, 8, 10, 12, 14, 16]; + const TOTAL = TARGET_IDS.length; + const overrides = { + g15_main: `-- === ИГРА «ТИР» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} +local Players = game:GetService("Players") +local player = Players.LocalPlayer local score = 0 -print("Стреляй ЛКМ по красным шарам!") +local TOTAL = ${TOTAL} +local won = false -local ev = getEvent("TargetHit") -ev.Event:Connect(function() +__rbxl_show_text("Кликай по красным мишеням!", 3) + +-- Счётчик в правом верхнем углу +local screenGui = Instance.new("ScreenGui", player.PlayerGui) +local label = Instance.new("TextLabel", screenGui) +label.Size = UDim2.new(0, 220, 0, 50) +label.Position = UDim2.new(1, -240, 0, 20) +label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +label.BackgroundTransparency = 0.4 +label.TextColor3 = Color3.fromRGB(255, 100, 100) +label.TextScaled = true +label.Font = Enum.Font.SourceSansBold +label.Text = "Мишени: 0 / " .. TOTAL + +local hitSound = Instance.new("Sound", workspace) +hitSound.SoundId = "hit"; hitSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local hitEvent = getEvent("TargetHit") +hitEvent.Event:Connect(function() + if won then return end score = score + 1 - print("Попал! Очки: " .. score) -end) - --- ЛКМ — пускаем луч из камеры -UserInputService.InputBegan:Connect(function(input, gp) - if gp then return end - if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end - local player = Players.LocalPlayer - local mouse = player:GetMouse() - local target = mouse.Target - if target and target:GetAttribute("IsTarget") then - local hitEv = workspace:FindFirstChild("TargetHit") or ev - ev:Fire() - target:Destroy() + label.Text = "Мишени: " .. score .. " / " .. TOTAL + hitSound:Play() + if score >= TOTAL then + won = true + winSound:Play() + __rbxl_show_text("Победа! Все мишени выбиты!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end end)`, - g15_target: `-- === Скрипт мишени (Lua) === + }; + // Скрипт каждой мишени — ClickDetector + взрыв искр + сообщение + const targetScript = `-- === Скрипт мишени (Lua) === +-- Клик ЛКМ по мишени = выстрел. ClickDetector ловит клик в 3D. +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent -part:SetAttribute("IsTarget", true)`, - }, +local hit = false + +local clickDet = Instance.new("ClickDetector", part) +clickDet.MaxActivationDistance = 50 + +clickDet.MouseClick:Connect(function() + if hit then return end + hit = true + -- Взрыв искр на месте мишени + local pos = part.Position + __rbxl_spawn_particles("explosion", pos.X, pos.Y, pos.Z, 0.5, 1) + -- Сообщаем главному скрипту + local ev = ReplicatedStorage:FindFirstChild("TargetHit") + if ev then ev:Fire() end + -- Мишень исчезает + part:Destroy() +end)`; + for (const tid of TARGET_IDS) { + overrides['g15_target_' + tid] = targetScript; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 16 — «Лавовый пол» diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 72d05a5..71d7f6a 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -436,6 +436,11 @@ function newInstance(className, name) { value.Children.push(proxyRef); try { value.ChildAdded && value.ChildAdded.Fire(proxyRef); } catch (_) {} } + // Спец-регистрация для ClickDetector — чтобы клик по Part + // мог сфейерить MouseClick через fireTargetEvent. + if (t.ClassName === 'ClickDetector' && value) { + try { value._clickDetector = proxyRef; } catch (_) {} + } try { t.AncestryChanged && t.AncestryChanged.Fire(proxyRef, value); } catch (_) {} return true; } @@ -1387,6 +1392,15 @@ export function registerRobloxShim(lua, opts) { inst.Scale = new RbxVector3(1, 1, 1); inst.Offset = new RbxVector3(0, 0, 0); inst.VertexColor = new RbxVector3(1, 1, 1); + } else if (className === 'ClickDetector') { + // ClickDetector — клик по 3D-объекту (нужен Тиру и т.п.). + // Регистрация в part._clickDetector происходит автоматически + // через Proxy.set когда юзер делает clickDet.Parent = part. + inst = newInstance('ClickDetector', 'ClickDetector'); + inst.MouseClick = makeSignal(); + inst.MouseHoverEnter = makeSignal(); + inst.MouseHoverLeave = makeSignal(); + inst.MaxActivationDistance = 32; } else if (className === 'BindableEvent') { inst = newInstance('BindableEvent', 'BindableEvent'); inst.Event = makeSignal(); @@ -2164,6 +2178,18 @@ export function registerRobloxShim(lua, opts) { part.Touched.Fire(hrp); } else if (p.kind === 'untouch' || p.kind === 'untouched') { part.TouchEnded.Fire(hrp); + } else if (p.kind === 'click') { + // ClickDetector создаётся лениво — стрельба по 3D-объектам. + // Юзер делает: part.ClickDetector = Instance.new('ClickDetector', part) + // + clickDet.MouseClick:Connect(fn). Здесь фейерим сигнал. + try { + const cd = part._clickDetector; + if (cd && cd.MouseClick) cd.MouseClick.Fire(localPlayer); + } catch (_) {} + // Также фейерим Part.Clicked если есть (наш расширенный API). + try { + if (part.Clicked) part.Clicked.Fire(); + } catch (_) {} } }, fireGlobalEvent(p) { -- 2.47.2 From 12efef7ff5b780a5b3b18d4d3bf2c0e688cfc573 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:56:06 +0300 Subject: [PATCH 145/214] =?UTF-8?q?debug(g15):=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=20=D0=B2=20shim=20fireTargetEvent=20+=20click=20=E2=80=94=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B9=D1=82=D0=B8=20=D0=B3=D0=B4=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/lua/RobloxShim.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 71d7f6a..d90a0ea 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2173,6 +2173,10 @@ export function registerRobloxShim(lua, opts) { if (!p) return; const id = p.primId ?? p.target; const part = partById.get(Number(id)); + if (p.kind === 'click') { + // eslint-disable-next-line no-console + console.warn('[shim fireTargetEvent click] primId=', id, 'part=', !!part); + } if (!part) return; if (p.kind === 'touch' || p.kind === 'touched') { part.Touched.Fire(hrp); @@ -2180,13 +2184,16 @@ export function registerRobloxShim(lua, opts) { part.TouchEnded.Fire(hrp); } else if (p.kind === 'click') { // ClickDetector создаётся лениво — стрельба по 3D-объектам. - // Юзер делает: part.ClickDetector = Instance.new('ClickDetector', part) - // + clickDet.MouseClick:Connect(fn). Здесь фейерим сигнал. + // Фейерим без аргумента (передача объектов в Lua через wasmoon + // может крашить с null.then). try { const cd = part._clickDetector; - if (cd && cd.MouseClick) cd.MouseClick.Fire(localPlayer); - } catch (_) {} - // Также фейерим Part.Clicked если есть (наш расширенный API). + // eslint-disable-next-line no-console + console.warn('[shim click]', 'partId=', id, 'cd=', !!cd, 'sig=', !!cd?.MouseClick, 'conns=', cd?.MouseClick?.connections?.length); + if (cd && cd.MouseClick) cd.MouseClick.Fire(); + } catch (e) { + console.warn('[shim click err]', e?.message || e); + } try { if (part.Clicked) part.Clicked.Fire(); } catch (_) {} -- 2.47.2 From d6a874c8a0328622f95817106856b53b9406148b Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 19:59:38 +0300 Subject: [PATCH 146/214] =?UTF-8?q?fix(g15):=20=D0=BA=D0=BB=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=B2=203-=D0=BC=20=D0=BB=D0=B8=D1=86=D0=B5=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BE=D1=80=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D1=80=D0=B0,=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=86=D0=B5=D0=BD=D1=82=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В 3-м лице курсор свободный. _handlePlayClick ВСЕГДА пикал центром экрана через _pickFromCenter — независимо от того где находится курсор. Поэтому клик мышкой по мишени не срабатывал — пик шёл из центра. Фикс: проверяем document.pointerLockElement: - locked (1-е лицо) → _pickFromCenter (прицел всегда в центре) - !locked (3-е лицо) → scene.pick(clickX, clickY) по реальной позиции курсора при клике. Убрал debug-логи. --- src/editor/engine/BabylonScene.js | 25 ++++++++++++++++++++++++- src/editor/engine/lua/RobloxShim.js | 12 ++---------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index a7dc045..f16d81f 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -3271,7 +3271,30 @@ export class BabylonScene { } if (!this.gameRuntime) return; - const pick = this._pickFromCenter(); + // В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром. + // В 3-м лице (свободный курсор) — пикаем по координатам клика. + const locked = (document.pointerLockElement === this.canvas); + let pick; + if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) { + const pi = this.scene.pick(clickX, clickY, (mesh) => { + if (!mesh.isPickable) return false; + if (mesh === this._ghostMesh) return false; + if (mesh.name && mesh.name.startsWith('gridLine')) return false; + return true; + }); + if (pi?.hit) { + let m = pi.pickedMesh; + if (m?.metadata?._isBlockProto && this.blockManager) { + const proxy = this.blockManager.findProxyByPickInfo(pi); + if (proxy) m = proxy; + } + pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi }; + } else { + pick = null; + } + } else { + pick = this._pickFromCenter(); + } const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; // 1) Self-onClick — только если target есть diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index d90a0ea..e56720c 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2173,27 +2173,19 @@ export function registerRobloxShim(lua, opts) { if (!p) return; const id = p.primId ?? p.target; const part = partById.get(Number(id)); - if (p.kind === 'click') { - // eslint-disable-next-line no-console - console.warn('[shim fireTargetEvent click] primId=', id, 'part=', !!part); - } 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); } else if (p.kind === 'click') { - // ClickDetector создаётся лениво — стрельба по 3D-объектам. + // ClickDetector — стрельба по 3D-объектам. // Фейерим без аргумента (передача объектов в Lua через wasmoon // может крашить с null.then). try { const cd = part._clickDetector; - // eslint-disable-next-line no-console - console.warn('[shim click]', 'partId=', id, 'cd=', !!cd, 'sig=', !!cd?.MouseClick, 'conns=', cd?.MouseClick?.connections?.length); if (cd && cd.MouseClick) cd.MouseClick.Fire(); - } catch (e) { - console.warn('[shim click err]', e?.message || e); - } + } catch (_) {} try { if (part.Clicked) part.Clicked.Fire(); } catch (_) {} -- 2.47.2 From 662d0d06e4fcf1769ae5737a6b76a6643cc50a18 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:01:00 +0300 Subject: [PATCH 147/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2015=20=C2=AB=D0=A2=D0=B8?= =?UTF-8?q?=D1=80=C2=BB=20(main+target)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 71662f1..234ca3e 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -2034,7 +2034,7 @@ game.self.onTouch(() => {

                                            Шаг 3. Главный скрипт

                                            - {`// === ИГРА «ТИР» — главный скрипт === + {`// === ИГРА «ТИР» — главный скрипт === let score = 0; const TOTAL = 8; @@ -2055,7 +2055,7 @@ game.onMessage('hit', () => { game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); } -});`} +});`}

                                            Здесь всё знакомо:

                                            • score — счётчик попаданий;
                                            • @@ -2072,7 +2072,7 @@ game.onMessage('hit', () => { мишень-сферу.

                                              - {`// === Скрипт мишени === + {`// === Скрипт мишени === // Клик по 3D-объекту = выстрел в него. game.self.onClick(() => { @@ -2081,7 +2081,7 @@ game.self.onClick(() => { { count: 1, color: '#ff6633' }); game.self.delete(); // мишень сбита game.broadcast('hit'); // сообщаем главному скрипту о попадании -});`} +});`}

                                              Разберём:

                                              • game.self.onClick(() => {'{...}'}) — -- 2.47.2 From f0f739071a79329b7e52f4ba11b7c95a22872278 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:03:50 +0300 Subject: [PATCH 148/214] =?UTF-8?q?feat(g16):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9B=D0=B0=D0=B2=D0=B0-=D0=BF=D0=BE=D0=BB=C2=BB=20+=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BE=D1=82=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=85=D0=B8=D1=82=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - showText 'Прыгай по островкам! Лава жжёт!' - onTick: y<-2 → respawn - onMessage 'win' → showText + win + confetti - g16_lava: onTouch → damage(20) + hit sound - g16_finish: onTouch → broadcast 'win' Lua (паритет): - __rbxl_show_text + win Sound + confetti - Heartbeat: __rbxl_player_y() < -2 → LoadCharacter - BindableEvent WinReached + g16_finish.Touched → ev:Fire - g16_lava: __rbxl_damage_player(20) + hit Sound ВАЖНО: лава-зона на y=1±0.3 (верх 1.3), островки на y=1.2±0.3 (верх 1.5). AABB-зоны пересекаются → BabylonScene touch-detector с EPS=0.25 активировал лаву даже когда игрок стоит на островке. Защита: если игрок выше Y=1.35 (стоит на островке/финише) — пропускаем урон. Урон срабатывает только когда игрок реально в лаве. Shim добавил: - __rbxl_damage_player(amount) → cmd player.damage (с i-frames) --- src/community/docsGamesBuildersLua.js | 66 +++++++++++++++++++++++++-- src/editor/engine/lua/RobloxShim.js | 5 ++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index db17b2d..aefa765 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1359,13 +1359,73 @@ end)`; // ИГРА 16 — «Лавовый пол» // ═══════════════════════════════════════════════════════════════ 'lava-floor': { - g16_main: `-- === ИГРА «ЛАВОВЫЙ ПОЛ» (Lua) === -print("Прыгай по островкам, не упади в лаву!")`, + g16_main: `-- === ИГРА «ЛАВА-ПОЛ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false + +__rbxl_show_text("Прыгай по островкам! Лава жжёт!", 3) + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Если упал в озеро вниз — респаун +RunService.Heartbeat:Connect(function() + if won then return end + local py = __rbxl_player_y() + if py < -2 then + player:LoadCharacter() + end +end) + +-- Финиш сообщает о победе +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты перебрался через лаву!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, g16_lava: `-- === Скрипт лавы (Lua) === +-- Игрок коснулся лавы — урон. У damage есть защита (i-frames), +-- так что урон не каждый кадр, а раз в ~0.5 секунды. +-- ВАЖНО: лава-зона может пересекаться с островками по AABB. Проверяем +-- что игрок РЕАЛЬНО внизу (стоит в лаве), а не на островке выше. local part = script.Parent +local hitSound = Instance.new("Sound", part) +hitSound.SoundId = "hit"; hitSound.Volume = 0.7 + part.Touched:Connect(function(hit) local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") - if h then h.Health = 0 end + if not h then return end + -- Игрок на безопасной высоте (на островке/финише) — не урон. + -- Лава-зона на y=1, верх y=1.3. Островки на y=1.2 верх y=1.5. + -- Игрок СТОИТ на островке когда его _pos.y - halfH (= нижняя точка + -- капсулы) ~= верх островка. Стоит на лаве когда нижняя точка ниже 1.3. + local py = __rbxl_player_y() + if py >= 1.35 then return end -- стоит на островке/финише — пропускаем + __rbxl_damage_player(20) + hitSound:Play() +end)`, + g16_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end end)`, }, diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index e56720c..984e8ae 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1904,6 +1904,11 @@ export function registerRobloxShim(lua, opts) { else _localInventory.set(id, newCount); send('inv2.remove', { itemId: id, count: c }); }); + // Урон игроку — паритет с JS game.player.damage(amount). + // У игрока есть i-frames (~0.5с), так что урон не каждый кадр. + global.set('__rbxl_damage_player', (amount) => { + send('player.damage', { amount: Number(amount) || 0 }); + }); // Подброс игрока — паритет с JS game.player.boostJump(strength). // 1.0 = обычный прыжок, 3.0 = втрое выше, и т.д. global.set('__rbxl_boost_jump', (strength) => { -- 2.47.2 From 29791049310e733562bd80123c5a5afd624358e2 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:09:07 +0300 Subject: [PATCH 149/214] =?UTF-8?q?fix(g16):=20=D1=83=D1=80=D0=BE=D0=BD=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BA=D0=BE=D0=B3=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=20=D0=A0=D0=95=D0=90?= =?UTF-8?q?=D0=9B=D0=AC=D0=9D=D0=9E=20=D0=B2=20=D0=BB=D0=B0=D0=B2=D0=B5=20?= =?UTF-8?q?(=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=B4=20=D0=BE=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=BE=D0=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше: фильтр по Y-капсулы не работал — игрок стоит на островке на realPos.y=1.54 > порога 1.35, фильтр пропускал. Стало: проверяем где игрок по X/Z. Если над островком (любой из 6) или над финишной площадкой — пропускаем урон. Иначе — урон каждый кадр пока Touched активно (RunService.Heartbeat). У damage есть i-frames ~0.5с так что урон ~2/сек. Также: переход с Touched-разово на Heartbeat-постоянно. Раньше Touched срабатывал только при ВХОДЕ в лаву — если игрок стоит в лаве долго, TouchEnded не вызывался, и урон шёл только раз. Теперь Touched/TouchEnded выставляют inLava-флаг, Heartbeat считает урон каждый кадр. --- src/community/docsGamesBuildersLua.js | 51 ++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index aefa765..50a908d 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1396,21 +1396,56 @@ end)`, g16_lava: `-- === Скрипт лавы (Lua) === -- Игрок коснулся лавы — урон. У damage есть защита (i-frames), -- так что урон не каждый кадр, а раз в ~0.5 секунды. --- ВАЖНО: лава-зона может пересекаться с островками по AABB. Проверяем --- что игрок РЕАЛЬНО внизу (стоит в лаве), а не на островке выше. +-- ВАЖНО: проверяем что игрок НЕ над островком (по X/Z), +-- иначе урон срабатывает на островке из-за пересечения AABB. +local RunService = game:GetService("RunService") local part = script.Parent local hitSound = Instance.new("Sound", part) hitSound.SoundId = "hit"; hitSound.Volume = 0.7 +-- Координаты островков (из builder): { x, z } +local ISLES = { + {0, 5}, {3, 9}, {-2, 13}, {2, 17}, {-3, 21}, {1, 25}, +} +local ISLE_HW = 1.4 -- половина ширины островка sx=2.4/2 + небольшой запас +local FINISH_X, FINISH_Z = -0.5, 29 +local FINISH_HW, FINISH_HD = 3, 2.5 + +local function isOverSafeSpot() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + -- Островки + for _, isle in ipairs(ISLES) do + if math.abs(px - isle[1]) <= ISLE_HW + and math.abs(pz - isle[2]) <= ISLE_HW then + return true + end + end + -- Финишная площадка + if math.abs(px - FINISH_X) <= FINISH_HW + and math.abs(pz - FINISH_Z) <= FINISH_HD then + return true + end + return false +end + +-- Проверяем каждый кадр пока игрок касается лавы — наносим урон если он +-- НЕ над безопасным местом. Touched нам не нужен — лучше Heartbeat-проверка. +local inLava = false part.Touched:Connect(function(hit) local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") if not h then return end - -- Игрок на безопасной высоте (на островке/финише) — не урон. - -- Лава-зона на y=1, верх y=1.3. Островки на y=1.2 верх y=1.5. - -- Игрок СТОИТ на островке когда его _pos.y - halfH (= нижняя точка - -- капсулы) ~= верх островка. Стоит на лаве когда нижняя точка ниже 1.3. - local py = __rbxl_player_y() - if py >= 1.35 then return end -- стоит на островке/финише — пропускаем + inLava = true +end) +part.TouchEnded:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + inLava = false +end) + +RunService.Heartbeat:Connect(function() + if not inLava then return end + if isOverSafeSpot() then return end -- стоит над островком/финишем __rbxl_damage_player(20) hitSound:Play() end)`, -- 2.47.2 From 1a6a92b4314353368871a7579e594629e9c3342d Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:11:16 +0300 Subject: [PATCH 150/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2016=20=C2=AB=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0-=D0=BF=D0=BE=D0=BB=C2=BB=20(main+lava+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 234ca3e..7d783c4 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -2189,7 +2189,7 @@ game.self.onClick(() => {

                                                Шаг 4. Главный скрипт

                                                - {`// === ИГРА «ЛАВА-ПОЛ» — главный скрипт === + {`// === ИГРА «ЛАВА-ПОЛ» — главный скрипт === let won = false; @@ -2215,7 +2215,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}
                                                • game.onTick следит, не провалился ли игрок совсем низко — тогда возвращает его на старт;
                                                • @@ -2226,14 +2226,14 @@ game.onMessage('win', () => {

                                                  Шаг 5. Скрипт лавы — главное

                                                  - {`// === Скрипт лавы === + {`// === Скрипт лавы === // Игрок коснулся лавы — урон. У damage есть защита (i-frames), // так что урон не каждый кадр, а раз в ~0.5 секунды. game.self.onTouch(() => { game.player.damage(20); game.sound.play('hit'); -});`} +});`}

                                                  Разберём:

                                                  • game.player.damage(20) — отнимает у игрока @@ -2248,10 +2248,10 @@ game.self.onTouch(() => {

                                                    Шаг 6. Скрипт финиша и проверка

                                                    - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                                                    • прыгай с островка на островок;
                                                    • попал в лаву — здоровье тает, слышен звук урона;
                                                    • -- 2.47.2 From 1345f51436cf63bc6f595d5c0e048a40075880a6 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:13:52 +0300 Subject: [PATCH 151/214] =?UTF-8?q?feat(g17):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=D1=81=20JS=20=C2=AB=D0=9A=D0=BB=D1=8E=D1=87=20=D0=B8=20=D1=81?= =?UTF-8?q?=D1=83=D0=BD=D0=B4=D1=83=D0=BA=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - showText 'Найди ключ и открой сундук!' - onMessage 'takeKey' → inventory.add('Ключ') + 'Ты нашёл Ключ!' + pickup - onMessage 'openChest' → если has('Ключ'): 'Победа!' + win + confetti иначе: 'Сундук заперт. Сначала найди ключ.' - key: onTouch → broadcast 'takeKey' + delete - chest: onInteract E (distance=4) → broadcast 'openChest' Lua (паритет, юзер указал на отсутствие надписей/инвентаря/звуков): - __rbxl_show_text для всех подсказок (вместо print) - __rbxl_inventory_define('key','Ключ') → итем в hotbar инвентаря - __rbxl_inventory_add('key',1) при подборе (вместо leaderstats BoolValue) - __rbxl_inventory_has('key') проверка ключа в сундуке - Sounds 'pickup'/'win'/'lose' (раньше только print) - BindableEvents TakeKey + OpenChest - g17_key: Touched → ev:Fire + Destroy (с taken-флагом) - g17_chest: убран ошибочный Touched-handler. Heartbeat distance-check как в Торговце: при near=true → __rbxl_hud_set '[E] Открыть сундук' UserInputService E → OpenChest:Fire - При победе: confetti над игроком + win Sound --- src/community/docsGamesBuildersLua.js | 109 +++++++++++++++++++------- 1 file changed, 79 insertions(+), 30 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 50a908d..e54f241 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1468,45 +1468,94 @@ end)`, // ИГРА 17 — «Ключ от сундука» // ═══════════════════════════════════════════════════════════════ 'key-chest': { - g17_main: `-- === ИГРА «КЛЮЧ ОТ СУНДУКА» (Lua) === -local Players = game:GetService("Players") -Players.PlayerAdded:Connect(function(player) - local stats = Instance.new("Folder", player); stats.Name = "leaderstats" - local key = Instance.new("BoolValue", stats); key.Name = "Ключ" + g17_main: `-- === ИГРА «КЛЮЧ И СУНДУК» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local won = false + +__rbxl_show_text("Найди ключ и открой сундук!", 3) + +local pickupSound = Instance.new("Sound", workspace) +pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.8 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.6 + +-- Определяем итем "Ключ" в инвентаре (показывает иконку в hotbar) +__rbxl_inventory_define("key", "Ключ", "#ffd700") + +-- Игрок подобрал ключ +local takeEvent = getEvent("TakeKey") +takeEvent.Event:Connect(function() + __rbxl_inventory_add("key", 1) + __rbxl_show_text("Ты нашёл Ключ!", 2) + pickupSound:Play() end) -for _, p in ipairs(Players:GetPlayers()) do - if not p:FindFirstChild("leaderstats") then - local stats = Instance.new("Folder", p); stats.Name = "leaderstats" - local key = Instance.new("BoolValue", stats); key.Name = "Ключ" + +-- Игрок пытается открыть сундук +local openEvent = getEvent("OpenChest") +openEvent.Event:Connect(function() + if won then return end + if not __rbxl_inventory_has("key") then + __rbxl_show_text("Сундук заперт. Сначала найди ключ.", 2) + loseSound:Play() + return end -end -print("Найди ключ и открой сундук!")`, + won = true + __rbxl_show_text("Победа! Сундук открыт — там сокровище!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, g17_key: `-- === Скрипт ключа (Lua) === -local Players = game:GetService("Players") +-- При касании игроком — отправляем событие и удаляем ключ. +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent +local taken = false + part.Touched:Connect(function(hit) - local player = Players:GetPlayerFromCharacter(hit.Parent) - if not player then return end - local stats = player:FindFirstChild("leaderstats") - if stats and stats:FindFirstChild("Ключ") then - stats['Ключ'].Value = true - print("Подобрал ключ!") - part:Destroy() - end + if taken then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + taken = true + local ev = ReplicatedStorage:FindFirstChild("TakeKey") + if ev then ev:Fire() end + part:Destroy() end)`, g17_chest: `-- === Скрипт сундука (Lua) === -local Players = game:GetService("Players") +-- Подойти и нажать E чтобы открыть. +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") local part = script.Parent -part.Touched:Connect(function(hit) - local player = Players:GetPlayerFromCharacter(hit.Parent) - if not player then return end - local stats = player:FindFirstChild("leaderstats") - if stats and stats['Ключ'] and stats['Ключ'].Value then - print("Сундук открыт! ПОБЕДА!") - part.Color = Color3.fromRGB(255, 215, 0) - else - print("Нужен ключ!") +local hintVisible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g17_chest_hint", "[E] Открыть сундук", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g17_chest_hint", nil) + end end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("OpenChest") + if ev then ev:Fire() end end)`, }, -- 2.47.2 From b5ba62cca81cbd52cd6149178e27502df83e2bd7 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:15:54 +0300 Subject: [PATCH 152/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2017=20=C2=AB=D0=9A=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=20=D0=B8=20=D1=81=D1=83=D0=BD=D0=B4=D1=83=D0=BA?= =?UTF-8?q?=C2=BB=20(main+key+chest)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 7d783c4..ba15c17 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -2321,7 +2321,7 @@ game.self.onTouch(() => {

                                                      Шаг 3. Главный скрипт

                                                      - {`// === ИГРА «КЛЮЧ И СУНДУК» — главный скрипт === + {`// === ИГРА «КЛЮЧ И СУНДУК» — главный скрипт === game.ui.showText('Найди ключ и открой сундук!', 3); @@ -2346,7 +2346,7 @@ game.onMessage('openChest', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                                      Здесь два обработчика сообщений:

                                                      • game.onMessage('takeKey', ...) — игрок @@ -2370,11 +2370,11 @@ game.onMessage('openChest', () => {

                                                        Шаг 4. Скрипт ключа

                                                        - {`// === Скрипт ключа === + {`// === Скрипт ключа === game.self.onTouch(() => { game.broadcast('takeKey'); // сообщаем главному скрипту: ключ найден game.self.delete(); // ключ подобран -});`} +});`}

                                                        Игрок коснулся ключа — скрипт шлёт сообщение game.broadcast('takeKey') (главный скрипт @@ -2384,10 +2384,10 @@ game.self.onTouch(() => {

                                                        Шаг 5. Скрипт сундука

                                                        - {`// === Скрипт сундука === + {`// === Скрипт сундука === game.self.onInteract(() => { game.broadcast('openChest'); // сообщаем главному скрипту: открыть сундук -}, { text: 'Открыть сундук', distance: 4 });`} +}, { text: 'Открыть сундук', distance: 4 });`}

                                                        Когда игрок подходит ближе чем на 4 метра, над сундуком появляется подсказка «Открыть сундук». Нажатие -- 2.47.2 From 430b9eddcd7c7027a63d36f59bcb849eed16d486 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:22:16 +0300 Subject: [PATCH 153/214] =?UTF-8?q?feat:=20=D0=B8=D0=B3=D1=80=D0=B0=2018?= =?UTF-8?q?=20=C2=AB=D0=9A=D0=B0=D1=87=D0=B5=D0=BB=D0=B8=C2=BB=20Lua=20+?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B0=20=D0=B2=D1=8B=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Игра 18: - Падало на WaitForChild + task.spawn + Completed:Wait (yield-across-C). - Полный переписан: Heartbeat-таймер раскачивает swing по синусу с амплитудой 4 и периодом 2.8с (вместо TweenService). - task.delay 0.2с чтобы дождаться появления свинга в scene. - Vector3.new напрямую (без оператора + который не работает). - BindableEvent WinReached + g18_finish.Touched → ev:Fire. - Полный паритет: showText, win Sound, confetti, respawn при y<-3. Модалка выхода: - Заменил window.confirm в KubikonEditor.handleBack на ConfirmModal. - 3 варианта: Сохранить и выйти / Выйти без сохранения / Отмена. - ConfirmModal расширен onCancel prop (отделяет 'cancel-кнопка' от 'клик мимо/Escape'). --- src/community/docsGamesBuildersLua.js | 82 +++++++++++++++++++++------ src/editor/ConfirmModal.jsx | 6 +- src/editor/KubikonEditor.jsx | 28 +++++++-- 3 files changed, 93 insertions(+), 23 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index e54f241..9809525 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1563,25 +1563,75 @@ end)`, // ИГРА 18 — «Качели» // ═══════════════════════════════════════════════════════════════ 'swing': { - g18_main: `-- === ИГРА «КАЧЕЛИ» (Lua) === -local TweenService = game:GetService("TweenService") -local swing = workspace:WaitForChild("Качели") -local startPos = swing.Position + g18_main: `-- === ИГРА «КАЧЕЛИ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} --- Качаем туда-сюда бесконечно -task.spawn(function() - while true do - local up = TweenService:Create(swing, - TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut), - { Position = startPos + Vector3.new(0, 0, 5) }) - up:Play(); up.Completed:Wait() - local down = TweenService:Create(swing, - TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut), - { Position = startPos + Vector3.new(0, 0, -5) }) - down:Play(); down.Completed:Wait() +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false + +__rbxl_show_text("Запрыгни на качели и прокатись!", 3) + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Раскачиваем качели туда-сюда через изменение Position.Z. +-- WaitForChild может зависнуть — берём напрямую с задержкой. +local swing = nil +local startZ = 0 + +task.delay(0.2, function() + swing = workspace:FindFirstChild("Качели") + if swing then + startZ = swing.Position.Z end end) -print("Запрыгни на качающуюся платформу!")`, + +local elapsed = 0 +RunService.Heartbeat:Connect(function(dt) + if won then return end + if not swing then return end + elapsed = elapsed + (dt or 0.016) + -- Синусоидальное качание с амплитудой 4 и периодом ~2.8 сек + local amp = 4 + local period = 2.8 + local offsetZ = amp * math.sin(elapsed * 2 * math.pi / period) + local pos = swing.Position + swing.Position = Vector3.new(pos.X, pos.Y, startZ + offsetZ) + + -- Если упал — респаун + local py = __rbxl_player_y() + if py < -3 then + player:LoadCharacter() + end +end) + +-- Финиш сообщает о победе +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты перебрался на качелях!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g18_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/editor/ConfirmModal.jsx b/src/editor/ConfirmModal.jsx index e1e12b3..2a4284c 100644 --- a/src/editor/ConfirmModal.jsx +++ b/src/editor/ConfirmModal.jsx @@ -25,8 +25,12 @@ export default function ConfirmModal({ cancelLabel = 'Отмена', confirmTone = 'primary', // 'primary' | 'danger' onConfirm, + onCancel, // если задан — вызывается при клике на «cancel» вместо тихого закрытия onClose, }) { + const handleCancel = () => { + try { onCancel?.(); } finally { onClose?.(); } + }; const confirmBtnRef = useRef(null); useEffect(() => { @@ -119,7 +123,7 @@ export default function ConfirmModal({ gap: 8, }}>

    ); }; -- 2.47.2 From 98e92b23a72bff72466947085f0dc0cf5ada61a5 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:24:48 +0300 Subject: [PATCH 154/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2018=20=C2=AB=D0=9A=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=D0=BB=D0=B8=C2=BB=20(main+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index ba15c17..1bac624 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -2471,7 +2471,7 @@ game.self.onInteract(() => { на петлю и раскачивает.

    - {`// === ИГРА «КАЧЕЛИ» — главный скрипт === + {`// === ИГРА «КАЧЕЛИ» — главный скрипт === let won = false; @@ -2512,7 +2512,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

    Разберём по частям:

    • game.after(0.2, ...) — ждём 0.2 секунды @@ -2541,10 +2541,10 @@ game.onMessage('win', () => { победу.

      - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
      • качели сами качаются туда-сюда;
      • запрыгни на них с возвышенности, поймай момент;
      • -- 2.47.2 From c489a31854340ea9119c7be4eb76dd83ae11fb8a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:26:19 +0300 Subject: [PATCH 155/214] =?UTF-8?q?feat(g19):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9B=D0=B8=D1=84=D1=82=C2=BB=20=E2=80=94=20Heartbeat=20?= =?UTF-8?q?yo-yo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Игра 19 падала на WaitForChild + Vector3 + (оператор не поддержан). Lua паритет с JS: - showText 'Встань на синий лифт — он повезёт наверх' - task.delay 0.2c для FindFirstChild('Лифт') (WaitForChild может зависать) - Heartbeat yo-yo по Y: startY (1) ↔ TOP_Y (12.3) с периодом 7с (3.5с вверх + 3.5с вниз через треугольную функцию) - Vector3.new напрямую (без +) - Респаун при y<-3 - BindableEvent WinReached + g19_finish.Touched → ev:Fire - При победе: showText 'Победа!' + win Sound + confetti --- src/community/docsGamesBuildersLua.js | 85 +++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 9809525..60b17da 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1638,21 +1638,82 @@ end)`, // ИГРА 19 — «Лифт» // ═══════════════════════════════════════════════════════════════ 'elevator': { - g19_main: `-- === ИГРА «ЛИФТ» (Lua) === -local TweenService = game:GetService("TweenService") -local elevator = workspace:WaitForChild("Лифт") -local startPos = elevator.Position -local topPos = startPos + Vector3.new(0, 10, 0) + g19_main: `-- === ИГРА «ЛИФТ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} -local goingUp = true -elevator.Touched:Connect(function(hit) +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false + +__rbxl_show_text("Встань на синий лифт — он повезёт наверх", 3) + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Лифт ездит вверх-вниз. WaitForChild зависает, поэтому FindFirstChild +-- с задержкой через task.delay. +local lift = nil +local startY = 1 +local TOP_Y = 12.3 +local PERIOD = 7 -- полный цикл вниз→вверх→вниз (3.5с вверх + 3.5с вниз) +local elapsed = 0 + +task.delay(0.2, function() + lift = workspace:FindFirstChild("Лифт") + if lift then startY = lift.Position.Y end +end) + +RunService.Heartbeat:Connect(function(dt) + if won then return end + dt = dt or 0.016 + -- Лифт двигается + if lift then + elapsed = elapsed + dt + -- Yo-yo: 0..PERIOD/2 — вверх, PERIOD/2..PERIOD — вниз + local t = (elapsed % PERIOD) / PERIOD + local k + if t < 0.5 then + k = t * 2 -- 0..1 + else + k = (1 - t) * 2 -- 1..0 + end + local y = startY + (TOP_Y - startY) * k + local pos = lift.Position + lift.Position = Vector3.new(pos.X, y, pos.Z) + end + -- Падение + local py = __rbxl_player_y() + if py < -3 then + player:LoadCharacter() + end +end) + +-- Финиш +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты поднялся на лифте!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g19_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") if not h then return end - local goal = { Position = goingUp and topPos or startPos } - TweenService:Create(elevator, TweenInfo.new(3), goal):Play() - goingUp = not goingUp -end) -print("Встань на лифт — он повезёт тебя наверх!")`, + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, }, // ═══════════════════════════════════════════════════════════════ -- 2.47.2 From 3cdbbc50491eb70d7836c0306a60075cd6dc6f98 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:28:37 +0300 Subject: [PATCH 156/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2019=20=C2=AB=D0=9B=D0=B8?= =?UTF-8?q?=D1=84=D1=82=C2=BB=20(main+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 1bac624..afed009 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -2619,7 +2619,7 @@ game.self.onTouch(() => {

        Шаг 3. Главный скрипт

        - {`// === ИГРА «ЛИФТ» — главный скрипт === + {`// === ИГРА «ЛИФТ» — главный скрипт === let won = false; @@ -2653,7 +2653,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

        Разберём твин лифта:

        • game.after(0.2, ...) — ждём 0.2 секунды: @@ -2680,10 +2680,10 @@ game.onMessage('win', () => { из разных «песочниц» общаются.

          - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
          • лифт ездит вверх-вниз сам по себе;
          • встань на него внизу и дождись верха;
          • -- 2.47.2 From 8a3405e34ad5268c3b8fd028cfb7bc0ee07c8ded Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:34:20 +0300 Subject: [PATCH 157/214] =?UTF-8?q?feat(g20):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=98=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B0=D0=B4=20?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D0=B3=D0=B0=D0=BC=D0=B8=C2=BB=20+=20shim=20N?= =?UTF-8?q?PC=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - 3 NPC (Гоблин, Скелет, Орк) через spawnNpc('character-b') - setLabel над каждым с HP, обновляется при уроне - ЛКМ → бьём ближайшего врага в радиусе 4 (damage 30) - onDeath → clearLabel + hit sound + при 0 врагов — победа+confetti Расширения shim/runtime: - __rbxl_spawn_npc уже было, добавил api._localToRealNpc Map - __rbxl_npc_damage(ref, amount) → cmd 'npc.damage' - __rbxl_set_label(ref, text, color, height) → cmd 'scene.setLabel' - __rbxl_clear_label(ref) → cmd 'scene.clearLabel' - __rbxl_npc_on_death(ref, fn) — регистрирует cb. shim слушает global event 'npcDeath' (resolveTweenTarget теперь поддерживает kind='npc') и зовёт зарегистрированные cb с подходящим ref (local или real). - GameRuntime.npc.spawn.then синхронизирует _localToRealNpc в Lua-sb. Lua-скрипт игры 20 (паритет): - showText 'Победи всех врагов! Кликай по ним' - 3 спавна с метками HP над головой - UserInputService.InputBegan MouseButton1 → ближайший враг в r=4 → -30hp - На смерть: clearLabel + при 0 — Победа + win Sound + confetti --- src/community/docsGamesBuildersLua.js | 81 +++++++++++++++++++++------ src/editor/engine/GameRuntime.js | 31 ++++++++++ src/editor/engine/lua/RobloxShim.js | 47 ++++++++++++++++ 3 files changed, 142 insertions(+), 17 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 60b17da..a42d6f6 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1720,26 +1720,73 @@ end)`, // ИГРА 20 — «Имена врагов» // ═══════════════════════════════════════════════════════════════ 'enemy-names': { - g20_main: `-- === ИГРА «ИМЕНА ВРАГОВ» (Lua) === -local function addLabel(part, text, color) - local bb = Instance.new("BillboardGui", part) - bb.Size = UDim2.new(4, 0, 1, 0) - bb.StudsOffset = Vector3.new(0, 2.5, 0) - bb.AlwaysOnTop = true - local label = Instance.new("TextLabel", bb) - label.Size = UDim2.new(1, 0, 1, 0) - label.BackgroundTransparency = 1 - label.Text = text - label.TextColor3 = color or Color3.new(1, 1, 1) - label.TextScaled = true + g20_main: `-- === ИГРА «ИМЕНА НАД ВРАГАМИ» — главный скрипт (Lua) === +local UserInputService = game:GetService("UserInputService") + +__rbxl_show_text("Победи всех врагов! Кликай по ним", 3) + +local hitSound = Instance.new("Sound", workspace) +hitSound.SoundId = "hit"; hitSound.Volume = 0.6 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Данные врагов: имя, позиция, HP +local enemies = { + { name = "Гоблин", x = -5, z = 3, hp = 60, maxHp = 60, ref = nil }, + { name = "Скелет", x = 4, z = 5, hp = 80, maxHp = 80, ref = nil }, + { name = "Орк", x = 0, z = 8, hp = 100, maxHp = 100, ref = nil }, +} + +local alive = #enemies +local won = false + +-- Спавним всех врагов и метки над ними +for i, e in ipairs(enemies) do + e.ref = __rbxl_spawn_npc("character-b", e.x, 1, e.z, e.name, e.hp, 0) + __rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 3) + -- Callback на смерть NPC + __rbxl_npc_on_death(e.ref, function() + if e._dead then return end + e._dead = true + __rbxl_clear_label(e.ref) + alive = alive - 1 + hitSound:Play() + if alive <= 0 and not won then + won = true + winSound:Play() + __rbxl_show_text("Победа! Все враги повержены!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + end + end) end -for _, child in ipairs(workspace:GetChildren()) do - if child:IsA("BasePart") and child.Name:match("^Враг") then - addLabel(child, child.Name, Color3.fromRGB(255, 80, 80)) +-- Клик по сцене — бьём ближайшего врага в радиусе 4 +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end + if won then return end + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + for _, e in ipairs(enemies) do + if not e._dead and e.hp > 0 then + local dx = e.x - px + local dz = e.z - pz + local dist = math.sqrt(dx*dx + dz*dz) + if dist < 4 then + e.hp = e.hp - 30 + if e.hp < 0 then e.hp = 0 end + __rbxl_npc_damage(e.ref, 30) + __rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 3) + __rbxl_spawn_particles("sparks", e.x, 2, e.z, 0.4, 1) + hitSound:Play() + break -- бьём только одного за клик + end + end end -end -print("Над врагами появились имена")`, +end)`, }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index a56b27d..b58be19 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -1558,6 +1558,30 @@ export class GameRuntime { const d = tryGet(this.scene3d?.modelManager); if (d) return { kind: 'model', data: d }; } + // NPC — для setLabel/clearLabel над NPC. + if (kind === 'npc' || kind == null) { + const nm = this.scene3d?.npcManager; + if (nm && nm.npcs) { + let npc = nm.npcs.get(rawId); + if (!npc) { + const n = Number(rawId); + if (Number.isFinite(n)) npc = nm.npcs.get(n); + } + if (npc) { + // Возвращаем npc в формате 'tween-target' с mesh-ссылкой. + return { + kind: 'npc', + data: { + mesh: npc.rootMesh || npc.mesh || npc.rootNode || npc, + rootMesh: npc.rootMesh || npc.rootNode, + x: npc.x ?? npc.position?.x ?? 0, + y: npc.y ?? npc.position?.y ?? 0, + z: npc.z ?? npc.position?.z ?? 0, + }, + }; + } + } + } const um = tryGet(this.scene3d?.userModelManager); if (um) return { kind: 'userModel', data: um }; return null; @@ -2125,6 +2149,13 @@ export class GameRuntime { // после spawnNpc (follow/moveTo/say) — они ждали // резолва ref в очереди. this._flushPendingNpcCmds(payload.ref, npcId); + // Также сообщаем Lua-sandbox-ам маппинг, чтобы + // npc.onDeath по локальному ref находил npcId. + for (const sb of this.sandboxes) { + if (sb.api?._localToRealNpc) { + try { sb.api._localToRealNpc.set(payload.ref, 'npc:' + npcId); } catch (_) {} + } + } } // Сообщаем воркеру маппинг localRef → npcId, чтобы // npc.onDeath по локальному ref находил правильного NPC. diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 984e8ae..cd86def 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1849,6 +1849,7 @@ export function registerRobloxShim(lua, opts) { // Возвращает локальный ref (строку 'npc_lua_N'), который можно передавать // в __rbxl_npc_say(ref, text, duration). let _nextNpcRef = 0; + api._localToRealNpc = new Map(); global.set('__rbxl_spawn_npc', (modelType, x, y, z, name, hp, speed) => { const ref = 'npc_lua_' + (_nextNpcRef++); send('npc.spawn', { @@ -1868,6 +1869,33 @@ export function registerRobloxShim(lua, opts) { duration: +duration || 3, }); }); + global.set('__rbxl_npc_damage', (ref, amount) => { + send('npc.damage', { + ref: String(ref || ''), + amount: +amount || 0, + }); + }); + // Метка с именем/HP над NPC или примитивом — паритет с JS scene.setLabel. + global.set('__rbxl_set_label', (ref, text, color, height) => { + send('scene.setLabel', { + ref: String(ref || ''), + text: String(text || ''), + opts: { + color: color || '#ff5555', + height: Number(height) || 3, + }, + }); + }); + global.set('__rbxl_clear_label', (ref) => { + send('scene.clearLabel', { ref: String(ref || '') }); + }); + // Регистрация коллбэка onDeath для NPC. GameRuntime шлёт globalEvent + // 'npcDeath' с {ref} при смерти. Shim фильтрует по ref и зовёт. + const _npcDeathCbs = new Map(); // ref → fn + global.set('__rbxl_npc_on_death', (ref, fn) => { + if (typeof fn === 'function') _npcDeathCbs.set(String(ref || ''), fn); + }); + api._npcDeathCbs = _npcDeathCbs; // Инвентарь invUI — паритет с JS game.inventory.add(itemId, count). // Сначала определяем итем (один раз), потом добавляем. const _localInventory = new Map(); @@ -2214,6 +2242,25 @@ export function registerRobloxShim(lua, opts) { if (part && humanoid.Touched) humanoid.Touched.Fire(part); } } + // NPC погиб — фейерим registered cb для конкретного локального ref. + if (p.type === 'npcDeath' && p.npcId != null) { + const realRef = 'npc:' + p.npcId; + // Ищем локальный ref по реальному + let localRef = null; + if (api._localToRealNpc) { + for (const [k, v] of api._localToRealNpc.entries()) { + if (v === realRef) { localRef = k; break; } + } + } + // Вызываем все cb с подходящим ref + if (_npcDeathCbs.size > 0) { + for (const [ref, fn] of _npcDeathCbs.entries()) { + if (ref === realRef || ref === localRef) { + _pendingHandlerQueue.push({ fn, args: [] }); + } + } + } + } // GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id} if (p.type === 'guiClick') { const ref = p.localId || p.id; -- 2.47.2 From b83c9bb75c863910f0b5b0ca8f3813b17e99c477 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:38:49 +0300 Subject: [PATCH 158/214] =?UTF-8?q?fix(g20):=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20api.setNpcLo?= =?UTF-8?q?calRef=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D1=8F=D0=BC=D0=BE=D0=B3=D0=BE=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lua-runtime FATAL: 'Cannot access api before initialization' — я обращался к api._localToRealNpc на 1852 до const api = на 2027. Фикс: - _localToRealNpc объявил как const до api (доступен в closures) - api.setNpcLocalRef(localRef, realRef) — публичный метод - GameRuntime: sb.api.setNpcLocalRef?.(ref, 'npc:'+npcId) - В fireGlobalEvent npcDeath используем _localToRealNpc напрямую --- src/editor/engine/GameRuntime.js | 2 +- src/editor/engine/lua/RobloxShim.js | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index b58be19..fb01928 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -2153,7 +2153,7 @@ export class GameRuntime { // npc.onDeath по локальному ref находил npcId. for (const sb of this.sandboxes) { if (sb.api?._localToRealNpc) { - try { sb.api._localToRealNpc.set(payload.ref, 'npc:' + npcId); } catch (_) {} + try { sb.api.setNpcLocalRef?.(payload.ref, 'npc:' + npcId); } catch (_) {} } } } diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index cd86def..7a52f66 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1849,7 +1849,7 @@ export function registerRobloxShim(lua, opts) { // Возвращает локальный ref (строку 'npc_lua_N'), который можно передавать // в __rbxl_npc_say(ref, text, duration). let _nextNpcRef = 0; - api._localToRealNpc = new Map(); + const _localToRealNpc = new Map(); global.set('__rbxl_spawn_npc', (modelType, x, y, z, name, hp, speed) => { const ref = 'npc_lua_' + (_nextNpcRef++); send('npc.spawn', { @@ -1895,7 +1895,6 @@ export function registerRobloxShim(lua, opts) { global.set('__rbxl_npc_on_death', (ref, fn) => { if (typeof fn === 'function') _npcDeathCbs.set(String(ref || ''), fn); }); - api._npcDeathCbs = _npcDeathCbs; // Инвентарь invUI — паритет с JS game.inventory.add(itemId, count). // Сначала определяем итем (один раз), потом добавляем. const _localInventory = new Map(); @@ -2026,6 +2025,11 @@ export function registerRobloxShim(lua, opts) { // вроде __rbxl_player_pos и updatePlayerPos могли его видеть. const api = { _realPlayerPos: null, + // GameRuntime зовёт после npc.spawn-резолва: маппинг локального + // ref ('npc_lua_N') на реальный ('npc:'). Нужно для npcDeath. + setNpcLocalRef(localRef, realRef) { + _localToRealNpc.set(String(localRef), String(realRef)); + }, onSceneSnapshot(snap) { try { const prims = snap?.primitives || []; @@ -2247,10 +2251,8 @@ export function registerRobloxShim(lua, opts) { const realRef = 'npc:' + p.npcId; // Ищем локальный ref по реальному let localRef = null; - if (api._localToRealNpc) { - for (const [k, v] of api._localToRealNpc.entries()) { - if (v === realRef) { localRef = k; break; } - } + for (const [k, v] of _localToRealNpc.entries()) { + if (v === realRef) { localRef = k; break; } } // Вызываем все cb с подходящим ref if (_npcDeathCbs.size > 0) { -- 2.47.2 From 1be06343ece424e01eb6b27e3b56696b9a34998f Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:41:09 +0300 Subject: [PATCH 159/214] =?UTF-8?q?fix(g20):=20=D0=BE=D1=82=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20setLabel=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20NPC=20=D0=B4=D0=BE=20npcSpawned-=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=BE=D0=BB=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setLabel сразу после spawnNpc возвращал null от _resolveTweenTarget (маппинг localRef→realId ещё не записан). Метки молча терялись. Фикс: если ref начинается с npc_lua_ — откладываем через _npcCmd (re-вызов после npcSpawned). Иначе retry через 0.3с. --- src/editor/engine/GameRuntime.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index fb01928..3027a93 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -3615,16 +3615,28 @@ export class GameRuntime { const ref = payload?.ref; const text = payload?.text; if (typeof ref !== 'string') return; - // ленивое создание менеджера меток if (!this.scene3d._labelManager) { this.scene3d._labelManager = new LabelManager(this.scene3d.scene); } const lm = this.scene3d._labelManager; - // резолвим меш объекта (примитив или модель) + const applyLabel = () => { + const tgt = this._resolveTweenTarget(ref); + const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode); + if (mesh) { + lm.setLabel(ref, mesh, text, payload?.opts || {}); + } + }; + // Если NPC ещё не зарезолвлен — откладываем через _npcCmd + // (или просто несколько попыток с retry). const tgt = this._resolveTweenTarget(ref); - const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode); - if (mesh) { - lm.setLabel(ref, mesh, text, payload?.opts || {}); + if (tgt) { + applyLabel(); + } else if (typeof ref === 'string' && ref.startsWith('npc_lua_')) { + // NPC ещё спавнится — откладываем + this._npcCmd(ref, () => applyLabel()); + } else { + // Retry через 0.3с (для primitive после sceneCreate) + setTimeout(applyLabel, 300); } } catch (e) { console.warn('[GameRuntime] scene.setLabel failed', e); -- 2.47.2 From 65de311d597da62c3946208c0df147053bd8d7a9 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:53:01 +0300 Subject: [PATCH 160/214] =?UTF-8?q?debug(g20):=20=D0=BB=D0=BE=D0=B3=20npc.?= =?UTF-8?q?spawn/damage/setLabel=20=D0=B2=20LuaSharedSandbox=20onCommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/engine/GameRuntime.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 3027a93..7138dec 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -326,6 +326,10 @@ export class GameRuntime { } } catch (_) {} } else { + if (cmd === 'npc.spawn' || cmd === 'npc.damage' || cmd === 'scene.setLabel' || cmd === 'scene.clearLabel') { + // eslint-disable-next-line no-console + console.warn('[Lua onCommand]', cmd, JSON.stringify(payload).slice(0, 200)); + } this._handleCommand(null, cmd, payload); } }); -- 2.47.2 From 56c35273efc431d09176bc93db04cb6d35a221ad Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 20:59:30 +0300 Subject: [PATCH 161/214] =?UTF-8?q?fix(g20):=20=5FnpcCmd=20=D0=B6=D0=B4?= =?UTF-8?q?=D1=91=D1=82=20npc=5Flua=5F=20=D0=BF=D1=80=D0=B5=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=20+=20sb.api.setNpcLocalRef?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _npcCmd проверял только индекс 'npc:_local_' (для JS-воркера), ref от Lua-shim 'npc_lua_0' пропускался — отложенные setLabel/damage терялись. Также: проверка sb.api?._localToRealNpc была false после рефакторинга (перенёс в local closure). Замена на sb.api?.setNpcLocalRef — публичный метод. Убрал debug-логи. --- src/editor/engine/GameRuntime.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 7138dec..400a1b9 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -326,10 +326,6 @@ export class GameRuntime { } } catch (_) {} } else { - if (cmd === 'npc.spawn' || cmd === 'npc.damage' || cmd === 'scene.setLabel' || cmd === 'scene.clearLabel') { - // eslint-disable-next-line no-console - console.warn('[Lua onCommand]', cmd, JSON.stringify(payload).slice(0, 200)); - } this._handleCommand(null, cmd, payload); } }); @@ -1497,7 +1493,8 @@ export class GameRuntime { const nid = this._resolveNpcId(ref); if (nid != null) { fn(nid); return; } // ещё не резолвится — откладываем (только для локальных ref NPC) - if (typeof ref === 'string' && ref.indexOf('npc:_local_') === 0) { + if (typeof ref === 'string' + && (ref.indexOf('npc:_local_') === 0 || ref.startsWith('npc_lua_'))) { if (!this._pendingNpcCmds) this._pendingNpcCmds = new Map(); if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []); this._pendingNpcCmds.get(ref).push(fn); @@ -2156,8 +2153,8 @@ export class GameRuntime { // Также сообщаем Lua-sandbox-ам маппинг, чтобы // npc.onDeath по локальному ref находил npcId. for (const sb of this.sandboxes) { - if (sb.api?._localToRealNpc) { - try { sb.api.setNpcLocalRef?.(payload.ref, 'npc:' + npcId); } catch (_) {} + if (sb.api?.setNpcLocalRef) { + try { sb.api.setNpcLocalRef(payload.ref, 'npc:' + npcId); } catch (_) {} } } } -- 2.47.2 From 4644a332e43a09105977c1a6e2f5a5f130482e7e Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:01:58 +0300 Subject: [PATCH 162/214] =?UTF-8?q?fix(g20):=20=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=BA=20npc.data.rootMesh=20(=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Метки рисовались в куче в (0,0,0) — anchorMesh был сам npc (объект, не mesh). LabelManager делал plane.parent = anchorMesh, но позиция не наследовалась. Фикс: _resolveTweenTarget для 'npc' возвращает data.mesh = npc.data.rootMesh (реальная модель NPC). --- src/editor/engine/GameRuntime.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 400a1b9..6f199fd 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -1569,15 +1569,17 @@ export class GameRuntime { if (Number.isFinite(n)) npc = nm.npcs.get(n); } if (npc) { - // Возвращаем npc в формате 'tween-target' с mesh-ссылкой. + // У NPC реальный mesh лежит в npc.data.rootMesh (модель). + const mesh = npc.data?.rootMesh || npc.data?.rootNode + || npc.rootMesh || npc.rootNode || null; return { kind: 'npc', data: { - mesh: npc.rootMesh || npc.mesh || npc.rootNode || npc, - rootMesh: npc.rootMesh || npc.rootNode, - x: npc.x ?? npc.position?.x ?? 0, - y: npc.y ?? npc.position?.y ?? 0, - z: npc.z ?? npc.position?.z ?? 0, + mesh, + rootMesh: mesh, + x: npc.x ?? 0, + y: npc.y ?? 0, + z: npc.z ?? 0, }, }; } -- 2.47.2 From 8c32e80f9f73c539b8aa909aaaabcd7ff847d5cd Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:05:16 +0300 Subject: [PATCH 163/214] =?UTF-8?q?fix(g20):=20UserInputService.InputBegan?= =?UTF-8?q?=20=D1=84=D0=B5=D0=B9=D0=B5=D1=80=D0=B8=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=20mouseButton1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше при ЛКМ фейерился только playerMouse.Button1Down.Fire(), а UserInputService.InputBegan/InputEnded — нет. Lua-скрипт игры 20 ловил клик через InputBegan + UserInputType.MouseButton1 → нет события → урон не наносился. Фикс: при mouseButton1Down/Up фейерим InputBegan/InputEnded с InputObject {UserInputType=Enum.UserInputType.MouseButton1} — ссылка на тот же объект, что Lua использует в сравнении. --- src/editor/engine/lua/RobloxShim.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 7a52f66..702bd02 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2308,9 +2308,28 @@ export function registerRobloxShim(lua, opts) { playerMouse.Hit.p = playerMouse.Hit.Position; } try { playerMouse.Button1Down.Fire(); } catch (_) {} + // Также фейерим UserInputService.InputBegan с UserInputType.MouseButton1 + try { + const uitEnum = global.get('Enum')?.UserInputType || {}; + const inputObj = { + UserInputType: uitEnum.MouseButton1 + || { Name: 'MouseButton1', Value: 'MouseButton1' }, + KeyCode: { Name: 'Unknown', Value: 'Unknown' }, + }; + uis.InputBegan.Fire(inputObj, false); + } catch (_) {} } if (p.type === 'mouseButton1Up') { try { playerMouse.Button1Up.Fire(); } catch (_) {} + try { + const uitEnum = global.get('Enum')?.UserInputType || {}; + const inputObj = { + UserInputType: uitEnum.MouseButton1 + || { Name: 'MouseButton1', Value: 'MouseButton1' }, + KeyCode: { Name: 'Unknown', Value: 'Unknown' }, + }; + uis.InputEnded.Fire(inputObj, false); + } catch (_) {} } if (p.type === 'keyDown' || p.type === 'keydown') { const k = String(p.key || '').toLowerCase(); -- 2.47.2 From 6aaab1a3f1c6639d6980d8ce27e3b0af6a676b53 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:07:47 +0300 Subject: [PATCH 164/214] =?UTF-8?q?fix(g20):=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D0=BC=20'click'=20(?= =?UTF-8?q?=D0=BE=D1=82=20BabylonScene)=20=D0=BA=D0=B0=D0=BA=20mouseButton?= =?UTF-8?q?1Down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BabylonScene routeGlobalEvent('click') при ЛКМ — это и есть событие мыши, но shim ждал только 'mouseButton1Down' (от плеера). Фикс: мапим p.type === 'click' тоже на mouseButton1Down branch. Внутри уже фейерим Mouse.Button1Down + UserInputService.InputBegan с UserInputType.MouseButton1. --- src/editor/engine/lua/RobloxShim.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 702bd02..600b0ed 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2302,7 +2302,9 @@ export function registerRobloxShim(lua, opts) { try { equippedTool.Deactivated.Fire(); } catch (_) {} } // Mouse-события из плеера: клики, движение, клавиши при equipped Tool - if (p.type === 'mouseButton1Down') { + // BabylonScene шлёт глобальный 'click' при ЛКМ — это эквивалент + // mouseButton1Down. Мапим в наши handler-ы. + if (p.type === 'click' || 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; -- 2.47.2 From 9eebbd302eaa15fdf11329a8f253a8ab58497788 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:10:22 +0300 Subject: [PATCH 165/214] =?UTF-8?q?feat(g20):=20=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=20=D0=BF=D0=BE=20NPC=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20pick?= =?UTF-8?q?=20(=D0=BA=D0=B0=D0=BA=20=D0=A2=D0=B8=D1=80=20ClickDetector)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше: проверка дистанции до врага при ЛКМ через InputBegan + MouseButton1. Никак не работало из-за десятка причин. Теперь как в Тире: - BabylonScene._meshToTarget теперь возвращает {kind:'npc', id:N} для меша с metadata.npcId. - routeGlobalEvent('click', {target}) — этим уже шлёт в Lua-shim с target. - Shim добавлен __rbxl_npc_on_click(ref, fn) — регистрация callback'а. В fireGlobalEvent при type='click'+target.kind='npc' резолвим локальный ref и фейерим cb. - В скрипте игры 20 регистрируем callback на каждого врага. Клик ЛКМ по NPC (raycast попадает в мешa NPC) → callback → урон. --- src/community/docsGamesBuildersLua.js | 39 +++++++++++---------------- src/editor/engine/BabylonScene.js | 1 + src/editor/engine/lua/RobloxShim.js | 18 +++++++++++++ 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index a42d6f6..6461412 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1763,30 +1763,21 @@ for i, e in ipairs(enemies) do end) end --- Клик по сцене — бьём ближайшего врага в радиусе 4 -UserInputService.InputBegan:Connect(function(input, gp) - if gp then return end - if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end - if won then return end - local px = __rbxl_player_x() - local pz = __rbxl_player_z() - for _, e in ipairs(enemies) do - if not e._dead and e.hp > 0 then - local dx = e.x - px - local dz = e.z - pz - local dist = math.sqrt(dx*dx + dz*dz) - if dist < 4 then - e.hp = e.hp - 30 - if e.hp < 0 then e.hp = 0 end - __rbxl_npc_damage(e.ref, 30) - __rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 3) - __rbxl_spawn_particles("sparks", e.x, 2, e.z, 0.4, 1) - hitSound:Play() - break -- бьём только одного за клик - end - end - end -end)`, +-- Клик по конкретному NPC (как в Тире — pick по 3D-объекту). +-- BabylonScene выполняет raycast при ЛКМ и шлёт click с target=NPC. +-- Регистрируем callback для каждого врага по его локальному ref. +for _, e in ipairs(enemies) do + local enemy = e -- замыкание + __rbxl_npc_on_click(enemy.ref, function() + if enemy._dead or won then return end + enemy.hp = enemy.hp - 30 + if enemy.hp < 0 then enemy.hp = 0 end + __rbxl_npc_damage(enemy.ref, 30) + __rbxl_set_label(enemy.ref, enemy.name .. " HP: " .. enemy.hp, "#ff5555", 3) + __rbxl_spawn_particles("sparks", enemy.x, 2, enemy.z, 0.4, 1) + hitSound:Play() + end) +end`, }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index f16d81f..ec00bd8 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -3035,6 +3035,7 @@ export class BabylonScene { if (md.isBlock) { return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; } + if (md.npcId != null) return { kind: 'npc', id: md.npcId }; if (md.isModel) return { kind: 'model', id: md.instanceId }; if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; return null; diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 600b0ed..b10a99f 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1895,6 +1895,10 @@ export function registerRobloxShim(lua, opts) { global.set('__rbxl_npc_on_death', (ref, fn) => { if (typeof fn === 'function') _npcDeathCbs.set(String(ref || ''), fn); }); + const _npcClickCbs = new Map(); // localRef → fn + global.set('__rbxl_npc_on_click', (ref, fn) => { + if (typeof fn === 'function') _npcClickCbs.set(String(ref || ''), fn); + }); // Инвентарь invUI — паритет с JS game.inventory.add(itemId, count). // Сначала определяем итем (один раз), потом добавляем. const _localInventory = new Map(); @@ -2302,6 +2306,20 @@ export function registerRobloxShim(lua, opts) { try { equippedTool.Deactivated.Fire(); } catch (_) {} } // Mouse-события из плеера: клики, движение, клавиши при equipped Tool + // BabylonScene шлёт глобальный 'click' при ЛКМ. Если в payload + // target — это попадание по 3D-объекту. Для NPC фейерим cb. + if (p.type === 'click' && p.target && p.target.kind === 'npc' && p.target.id != null) { + const realRef = 'npc:' + p.target.id; + let localRef = null; + for (const [k, v] of _localToRealNpc.entries()) { + if (v === realRef) { localRef = k; break; } + } + for (const [ref, fn] of _npcClickCbs.entries()) { + if (ref === realRef || ref === localRef) { + _pendingHandlerQueue.push({ fn, args: [] }); + } + } + } // BabylonScene шлёт глобальный 'click' при ЛКМ — это эквивалент // mouseButton1Down. Мапим в наши handler-ы. if (p.type === 'click' || p.type === 'mouseButton1Down') { -- 2.47.2 From 7595668b03237b4eae232deec6ae0e574fac0456 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:13:38 +0300 Subject: [PATCH 166/214] =?UTF-8?q?fix(g20):=20=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D1=83=20HP=20=D0=BF=D0=BE=D0=B4=D0=BD=D1=8F=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=81=203=20=D0=B4=D0=BE=204=20=E2=80=94=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20health-bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health-bar NPC рендерится на y+2.4 при уроне. Метка height=3 была слишком близко — health-bar заходил поверх текста. Высота 4 даёт зазор в ~1.6 над health-bar. --- src/community/docsGamesBuildersLua.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 6461412..56f2a95 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1743,7 +1743,7 @@ local won = false -- Спавним всех врагов и метки над ними for i, e in ipairs(enemies) do e.ref = __rbxl_spawn_npc("character-b", e.x, 1, e.z, e.name, e.hp, 0) - __rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 3) + __rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 4) -- Callback на смерть NPC __rbxl_npc_on_death(e.ref, function() if e._dead then return end @@ -1773,7 +1773,7 @@ for _, e in ipairs(enemies) do enemy.hp = enemy.hp - 30 if enemy.hp < 0 then enemy.hp = 0 end __rbxl_npc_damage(enemy.ref, 30) - __rbxl_set_label(enemy.ref, enemy.name .. " HP: " .. enemy.hp, "#ff5555", 3) + __rbxl_set_label(enemy.ref, enemy.name .. " HP: " .. enemy.hp, "#ff5555", 4) __rbxl_spawn_particles("sparks", enemy.x, 2, enemy.z, 0.4, 1) hitSound:Play() end) -- 2.47.2 From 4320c6adeb7db2d7b12e2080306aba768fbad05d Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:17:16 +0300 Subject: [PATCH 167/214] =?UTF-8?q?fix(g20):=20=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8C=D1=88=D0=B8=D0=BB=20=D0=B7=D0=B0=D0=B7=D0=BE=D1=80=D1=8B?= =?UTF-8?q?=20=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D0=B8=20health-bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LabelManager использует opts.height как ГАП от верха AABB до плашки, а не абсолютную высоту. Передавал 3 — метка летела далеко вверх. Стало 0.3 — небольшой зазор над головой. Health-bar опустил с y+2.4 до y+1.9 — ближе к голове. --- src/community/docsGamesBuildersLua.js | 4 ++-- src/editor/engine/NpcManager.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 56f2a95..59b57d8 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1743,7 +1743,7 @@ local won = false -- Спавним всех врагов и метки над ними for i, e in ipairs(enemies) do e.ref = __rbxl_spawn_npc("character-b", e.x, 1, e.z, e.name, e.hp, 0) - __rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 4) + __rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 0.3) -- Callback на смерть NPC __rbxl_npc_on_death(e.ref, function() if e._dead then return end @@ -1773,7 +1773,7 @@ for _, e in ipairs(enemies) do enemy.hp = enemy.hp - 30 if enemy.hp < 0 then enemy.hp = 0 end __rbxl_npc_damage(enemy.ref, 30) - __rbxl_set_label(enemy.ref, enemy.name .. " HP: " .. enemy.hp, "#ff5555", 4) + __rbxl_set_label(enemy.ref, enemy.name .. " HP: " .. enemy.hp, "#ff5555", 0.3) __rbxl_spawn_particles("sparks", enemy.x, 2, enemy.z, 0.4, 1) hitSound:Play() end) diff --git a/src/editor/engine/NpcManager.js b/src/editor/engine/NpcManager.js index f452f81..60aa471 100644 --- a/src/editor/engine/NpcManager.js +++ b/src/editor/engine/NpcManager.js @@ -470,7 +470,7 @@ export class NpcManager { const show = npc.hp < npc.maxHp; hb.anchor.setEnabled(show); if (show) { - hb.anchor.position.set(npc.x, npc.y + 2.4, npc.z); + hb.anchor.position.set(npc.x, npc.y + 1.9, npc.z); const pct = Math.max(0, Math.min(1, npc.hp / npc.maxHp)); hb.fill.scaling.x = pct; hb.fill.position.x = -(1 - pct) * hb.barWidth / 2; -- 2.47.2 From 58fbc9d6e6e8329c1efba83336fd09b4a13d882f Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:18:50 +0300 Subject: [PATCH 168/214] =?UTF-8?q?fix(g20):=20=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20height=202.5=20=E2=80=94=20=D0=BD=D0=B0=D0=B4=20health?= =?UTF-8?q?-bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsGamesBuildersLua.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 59b57d8..ef08d8c 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1743,7 +1743,7 @@ local won = false -- Спавним всех врагов и метки над ними for i, e in ipairs(enemies) do e.ref = __rbxl_spawn_npc("character-b", e.x, 1, e.z, e.name, e.hp, 0) - __rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 0.3) + __rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 2.5) -- Callback на смерть NPC __rbxl_npc_on_death(e.ref, function() if e._dead then return end @@ -1773,7 +1773,7 @@ for _, e in ipairs(enemies) do enemy.hp = enemy.hp - 30 if enemy.hp < 0 then enemy.hp = 0 end __rbxl_npc_damage(enemy.ref, 30) - __rbxl_set_label(enemy.ref, enemy.name .. " HP: " .. enemy.hp, "#ff5555", 0.3) + __rbxl_set_label(enemy.ref, enemy.name .. " HP: " .. enemy.hp, "#ff5555", 2.5) __rbxl_spawn_particles("sparks", enemy.x, 2, enemy.z, 0.4, 1) hitSound:Play() end) -- 2.47.2 From d7478fe3114c14c59d73c6a2e0ef529dc89c3d2c Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:20:38 +0300 Subject: [PATCH 169/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2020=20=C2=AB=D0=98=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B0=D0=B4=20=D0=B2=D1=80=D0=B0?= =?UTF-8?q?=D0=B3=D0=B0=D0=BC=D0=B8=C2=BB=20(g20=5Fmain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index afed009..7bc5c45 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -2747,7 +2747,7 @@ game.self.onTouch(() => { над ними метки и обрабатывает удары.

            - {`// === ИГРА «ИМЕНА НАД ВРАГАМИ» — главный скрипт === + {`// === ИГРА «ИМЕНА НАД ВРАГАМИ» — главный скрипт === game.ui.showText('Победи всех врагов! Кликай по ним', 3); @@ -2806,7 +2806,7 @@ enemyData.forEach((d) => { game.scene.spawnParticles('sparks', npc.position, { duration: 0.4 }); } }); -});`} +});`}

            Разберём по частям. Сначала про создание врагов:

            • enemyData — список врагов: у каждого имя, -- 2.47.2 From ea1308d5394445518fac0bfff8ecbcf7689c90c1 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:23:53 +0300 Subject: [PATCH 170/214] =?UTF-8?q?feat(g21):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9F=D1=80=D0=B5=D1=81=D0=BB=D0=B5=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=C2=BB=20+=20npc.follow/stop/pos?= =?UTF-8?q?=20=D0=B2=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - spawnNpc 'Охотник' speed=4 follow('player') - onTick: dist(player,enemy) < 1.6 → respawn + 'Пойман!' + lose - onMessage 'win' → enemy.stop() + 'Победа!' + win + confetti - g21_finish: onTouch → broadcast 'win' Lua (паритет): - __rbxl_show_text + Sounds - __rbxl_spawn_npc('character-b', ..., 'Охотник', 100, 4) - __rbxl_npc_follow(ref, 'player') — велим NPC следовать за игроком - Heartbeat: __rbxl_npc_x/z для расстояния, при <1.6 → LoadCharacter + 'Пойман!' + lose Sound (с throttle 2с) - BindableEvent WinReached + g21_finish.Touched → ev:Fire - При победе: npc_stop + showText + win + confetti Shim хелперы: - __rbxl_npc_follow(ref, target='player') - __rbxl_npc_stop(ref) - __rbxl_npc_x/y/z(ref) — позиция NPC - api.updateNpcPos(localRef, x, y, z) — GameRuntime синкает каждый кадр GameRuntime.tick собирает позиции всех NPC из npcManager.npcs через _localToReal и шлёт sb.api.updateNpcPos. --- src/community/docsGamesBuildersLua.js | 80 +++++++++++++++++++++------ src/editor/engine/GameRuntime.js | 20 +++++++ src/editor/engine/lua/RobloxShim.js | 25 +++++++++ 3 files changed, 109 insertions(+), 16 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index ef08d8c..b2e26fc 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1784,28 +1784,76 @@ end`, // ИГРА 21 — «Догонялки» // ═══════════════════════════════════════════════════════════════ 'chaser': { - g21_main: `-- === ИГРА «ДОГОНЯЛКИ» (Lua) === + g21_main: `-- === ИГРА «ПРЕСЛЕДОВАТЕЛЬ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + local Players = game:GetService("Players") local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false -local enemy = workspace:WaitForChild("Догонщик") +__rbxl_show_text("Убегай от врага! Добеги до укрытия!", 3) -RunService.Heartbeat:Connect(function(dt) - local player = Players:GetPlayers()[1] - if not player or not player.Character then return end - local target = player.Character:FindFirstChild("HumanoidRootPart") - if not target then return end - -- Двигаемся в сторону игрока со скоростью 5 - local dir = (target.Position - enemy.Position) - if dir.Magnitude > 1 then - enemy.Position = enemy.Position + dir.Unit * 5 * dt - else - -- Поймал - local h = player.Character:FindFirstChild("Humanoid") - if h then h:TakeDamage(10) end +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Спавним NPC-преследователя (speed=4, follow за игроком) +local enemyRef = __rbxl_spawn_npc("character-b", 0, 1, -3, "Охотник", 100, 4) +-- Велим NPC следовать за игроком +__rbxl_npc_follow(enemyRef, "player") + +-- Каждый кадр проверяем — не догнал ли враг +local lastCaughtTime = 0 +RunService.Heartbeat:Connect(function() + if won then return end + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local ex = __rbxl_npc_x(enemyRef) + local ez = __rbxl_npc_z(enemyRef) + -- Если позиции NPC ещё не пришли (ex=0,ez=0 = до спавна) — пропускаем + if ex == 0 and ez == 0 then return end + local dx = px - ex + local dz = pz - ez + local dist = math.sqrt(dx*dx + dz*dz) + if dist < 1.6 then + local now = tick() + if now - lastCaughtTime > 2 then + lastCaughtTime = now + player:LoadCharacter() + __rbxl_show_text("Пойман! Беги снова!", 2) + loseSound:Play() + end end end) -print("Убегай от догонщика!")`, + +-- Финиш сообщает о победе +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_npc_stop(enemyRef) + winSound:Play() + __rbxl_show_text("Победа! Ты убежал от врага!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g21_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 6f199fd..8bc1bbd 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -986,6 +986,20 @@ export class GameRuntime { } } } catch (_) {} + // Собираем позиции NPC для Lua-shim + const npcPositions = []; + try { + const nm = this.scene3d?.npcManager; + if (nm && nm.npcs && this._localToReal) { + // localRef ('npc_lua_N') → реальный 'npc:' → npc + for (const [localRef, realRef] of this._localToReal.entries()) { + if (typeof realRef !== 'string' || !realRef.startsWith('npc:')) continue; + const npcId = Number(realRef.slice(4)); + const npc = nm.npcs.get(npcId); + if (npc) npcPositions.push([localRef, npc.x, npc.y, npc.z]); + } + } + } catch (_) {} for (const sb of this.sandboxes) { // Обновляем реальную позицию игрока для Lua-shim if (realPos && sb.api?.updatePlayerPos) { @@ -997,6 +1011,12 @@ export class GameRuntime { try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {} } } + // Синк позиций NPC + if (npcPositions.length > 0 && sb.api?.updateNpcPos) { + for (const [ref, x, y, z] of npcPositions) { + try { sb.api.updateNpcPos(ref, x, y, z); } catch (_) {} + } + } // Для скриптов с target — добавляем актуальную позицию self const stateForSb = sb.target ? { ...state, selfPosition: this._collectSelfPosition(sb.target) } diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index b10a99f..369c713 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1869,6 +1869,27 @@ export function registerRobloxShim(lua, opts) { duration: +duration || 3, }); }); + global.set('__rbxl_npc_follow', (ref, targetRef) => { + send('npc.follow', { + ref: String(ref || ''), + target: String(targetRef || 'player'), + }); + }); + global.set('__rbxl_npc_stop', (ref) => { + send('npc.stop', { ref: String(ref || '') }); + }); + // Позиция NPC — резолвится через GameRuntime по локальному ref. + // GameRuntime в tick шлёт api.updateNpcPos(localRef, x, y, z). + const _npcPositions = new Map(); // localRef → {x,y,z} + global.set('__rbxl_npc_pos', (ref) => { + const p = _npcPositions.get(String(ref || '')); + if (!p) return { x: 0, y: 0, z: 0, ok: false }; + return { x: p.x, y: p.y, z: p.z, ok: true }; + }); + // Отдельные x/y/z — обходим wasmoon userdata-proxy. + global.set('__rbxl_npc_x', (ref) => (_npcPositions.get(String(ref || ''))?.x ?? 0)); + global.set('__rbxl_npc_y', (ref) => (_npcPositions.get(String(ref || ''))?.y ?? 0)); + global.set('__rbxl_npc_z', (ref) => (_npcPositions.get(String(ref || ''))?.z ?? 0)); global.set('__rbxl_npc_damage', (ref, amount) => { send('npc.damage', { ref: String(ref || ''), @@ -2034,6 +2055,10 @@ export function registerRobloxShim(lua, opts) { setNpcLocalRef(localRef, realRef) { _localToRealNpc.set(String(localRef), String(realRef)); }, + // GameRuntime каждый кадр обновляет позиции NPC для Lua-скриптов. + updateNpcPos(localRef, x, y, z) { + _npcPositions.set(String(localRef), { x: +x, y: +y, z: +z }); + }, onSceneSnapshot(snap) { try { const prims = snap?.primitives || []; -- 2.47.2 From cbc4b87643b1c2f80e0b00dc8f345e727e8baea2 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:25:52 +0300 Subject: [PATCH 171/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2021=20=C2=AB=D0=9F=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B5=D0=B4=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=C2=BB=20(main+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 7bc5c45..e3a034a 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -2910,7 +2910,7 @@ enemyData.forEach((d) => { не догнал ли он игрока.

              - {`// === ИГРА «ПРЕСЛЕДОВАТЕЛЬ» — главный скрипт === + {`// === ИГРА «ПРЕСЛЕДОВАТЕЛЬ» — главный скрипт === let won = false; @@ -2952,7 +2952,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

              Разберём построчно:

              • game.scene.spawnNpc('character-b', опции) — @@ -2976,10 +2976,10 @@ game.onMessage('win', () => {

                Шаг 3. Скрипт финиша

                - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}

                Когда игрок касается финиша, скрипт шлёт сообщение game.broadcast('win'). Главный скрипт ловит -- 2.47.2 From 59769932e5e63a850cb76c6797bb5f44f23f5baa Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:29:32 +0300 Subject: [PATCH 172/214] =?UTF-8?q?feat(g22):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=97=D0=BE=D0=BD=D0=B0=20=D0=BE=D0=BF=D0=B0=D1=81=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - showText 'Пробеги через красную зону к финишу!' - every(0.6): если inZone — damage(12) + hit - onMessage 'zone-enter' → inZone=true + 'Опасно! Беги быстрее!' - onMessage 'zone-leave' → inZone=false - onMessage 'win' → 'Победа!' + win + confetti - g22_zone: onTouch → 'zone-enter', onUntouch → 'zone-leave' - g22_heal: onTouch → heal(60) + '+60 HP' + pickup + delete - g22_finish: onTouch → 'win' Lua (паритет): - __rbxl_show_text + Sounds - BindableEvents ZoneEnter/ZoneLeave/WinReached - Heartbeat-таймер 0.6с для урона пока inZone - g22_zone: Touched/TouchEnded → ev:Fire - g22_heal: Touched → __rbxl_heal_player(60) + pickup Sound + Destroy - g22_finish: Touched → WinReached:Fire Shim добавил __rbxl_heal_player(amount) → cmd 'player.heal'. --- src/community/docsGamesBuildersLua.js | 104 ++++++++++++++++++++++---- src/editor/engine/lua/RobloxShim.js | 4 + 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index b2e26fc..765cdd3 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1860,28 +1860,104 @@ end)`, // ИГРА 22 — «Опасная зона» // ═══════════════════════════════════════════════════════════════ 'danger-zone': { - g22_main: `-- === ИГРА «ОПАСНАЯ ЗОНА» (Lua) === -print("Не стой в красной зоне!")`, - g22_zone: `-- === Скрипт опасной зоны (Lua) === + g22_main: `-- === ИГРА «ЗОНА ОПАСНОСТИ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local RunService = game:GetService("RunService") +local inZone = false +local won = false +local damageTimer = 0 + +__rbxl_show_text("Пробеги через красную зону к финишу!", 3) + +local hitSound = Instance.new("Sound", workspace) +hitSound.SoundId = "hit"; hitSound.Volume = 0.6 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Слушатели событий зоны +local enterEvent = getEvent("ZoneEnter") +enterEvent.Event:Connect(function() + inZone = true + __rbxl_show_text("Опасно! Беги быстрее!", 1.5) +end) +local leaveEvent = getEvent("ZoneLeave") +leaveEvent.Event:Connect(function() + inZone = false +end) + +-- Урон каждые 0.6с пока игрок в зоне +RunService.Heartbeat:Connect(function(dt) + if won then return end + if not inZone then return end + damageTimer = damageTimer + (dt or 0.016) + if damageTimer >= 0.6 then + damageTimer = 0 + __rbxl_damage_player(12) + hitSound:Play() + end +end) + +-- Финиш сообщает о победе +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты прошёл зону опасности!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g22_zone: `-- === Скрипт зоны опасности (Lua) === +-- Touched при входе, TouchEnded при выходе. +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent -local insiders = {} part.Touched:Connect(function(hit) local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") - if h then insiders[h] = true end + if not h then return end + local ev = ReplicatedStorage:FindFirstChild("ZoneEnter") + if ev then ev:Fire() end end) part.TouchEnded:Connect(function(hit) local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") - if h then insiders[h] = nil end -end) + if not h then return end + local ev = ReplicatedStorage:FindFirstChild("ZoneLeave") + if ev then ev:Fire() end +end)`, + g22_heal: `-- === Скрипт аптечки (Lua) === +local part = script.Parent +local taken = false --- Урон каждые 0.5 сек пока стоят -while true do - task.wait(0.5) - for h in pairs(insiders) do - if h.Parent then h:TakeDamage(5) end - end -end`, +part.Touched:Connect(function(hit) + if taken then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + taken = true + -- Лечим на 60 HP (через damage с отрицательным значением неудобно; + -- используем напрямую player.Health прибавлением). + __rbxl_heal_player(60) + __rbxl_show_text("+60 HP", 1.5) + local pickupSound = Instance.new("Sound", part) + pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.8 + pickupSound:Play() + part:Destroy() +end)`, + g22_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 369c713..ffc1dfd 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1961,6 +1961,10 @@ export function registerRobloxShim(lua, opts) { global.set('__rbxl_damage_player', (amount) => { send('player.damage', { amount: Number(amount) || 0 }); }); + // Лечение игрока — паритет с JS game.player.heal(amount). + global.set('__rbxl_heal_player', (amount) => { + send('player.heal', { amount: Number(amount) || 0 }); + }); // Подброс игрока — паритет с JS game.player.boostJump(strength). // 1.0 = обычный прыжок, 3.0 = втрое выше, и т.д. global.set('__rbxl_boost_jump', (strength) => { -- 2.47.2 From 791cf2cde52f2d4bee3613eb6b7df034e2970364 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:31:22 +0300 Subject: [PATCH 173/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2022=20=C2=AB=D0=97=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=C2=BB=20(main+zone+heal+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index e3a034a..699a68d 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -3053,7 +3053,7 @@ game.self.onTouch(() => {

                Шаг 2. Главный скрипт

                - {`// === ИГРА «ЗОНА ОПАСНОСТИ» — главный скрипт === + {`// === ИГРА «ЗОНА ОПАСНОСТИ» — главный скрипт === let inZone = false; // игрок сейчас в красной зоне? let won = false; @@ -3084,7 +3084,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                Разберём:

                • inZone — флажок «игрок внутри красной @@ -3105,7 +3105,7 @@ game.onMessage('win', () => {

                  Шаг 3. Скрипт зоны опасности

                  - {`// === Скрипт зоны опасности === + {`// === Скрипт зоны опасности === // onTouch — игрок вошёл, onUntouch — вышел. game.self.onTouch(() => { @@ -3113,7 +3113,7 @@ game.self.onTouch(() => { }); game.self.onUntouch(() => { game.broadcast('zone-leave'); -});`} +});`}

                  Здесь важна пара событий: onTouch срабатывает, когда игрок входит в зону, а @@ -3123,13 +3123,13 @@ game.self.onUntouch(() => {

                  Шаг 4. Скрипт аптечки

                  - {`// === Скрипт аптечки === + {`// === Скрипт аптечки === game.self.onTouch(() => { game.player.heal(60); game.ui.showText('+60 HP', 1.5); game.sound.play('pickup'); game.self.delete(); -});`} +});`}

                  game.player.heal(60) — добавляет игроку 60 единиц здоровья. Аптечку взяли один раз — @@ -3138,10 +3138,10 @@ game.self.onTouch(() => {

                  Шаг 5. Скрипт финиша и проверка

                  - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
                  • подбери зелёную аптечку — +60 HP;
                  • в красной зоне здоровье тает каждые 0.6 секунды;
                  • -- 2.47.2 From eb04da63485868faada066b7eb0448e490b6b177 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:33:12 +0300 Subject: [PATCH 174/214] =?UTF-8?q?feat(g23):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9F=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - showText 'Дёрни рычаги в нужном порядке (E)' - onMessage 'lever' с {num} → click sound + pressed.push + проверка совпадения с ORDER=[2,3,1]. Ошибка → reset + 'Неверно!' + lose Полная последовательность → 'Верно! Дверь открыта' + win + tween двери - onMessage 'win' → 'Победа!' + win + confetti - лычаги: onInteract E (distance=3) → broadcast 'lever' {num} - финиш: onTouch → broadcast 'win' Lua (паритет): - __rbxl_show_text + Sounds - BindableEvents LeverPulled (с num аргументом) + WinReached - g23_main: проверка порядка + tween двери (Position.Y+6) + CanCollide=false - 3 g23_lever_N: Heartbeat distance-check (3), __rbxl_hud_set '[E] Дёрнуть рычаг N' в нижней части экрана. UserInputService.InputBegan E → LeverPulled:Fire(n) - g23_finish: Touched → WinReached:Fire (fired-флаг) --- src/community/docsGamesBuildersLua.js | 117 +++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 11 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 765cdd3..95335a3 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -1963,22 +1963,117 @@ end)`, // ═══════════════════════════════════════════════════════════════ // ИГРА 23 — «3 переключателя» // ═══════════════════════════════════════════════════════════════ - 'switches': { - g23_main: `-- === ИГРА «3 ПЕРЕКЛЮЧАТЕЛЯ» (Lua) === + 'switches': (function() { + const overrides = { + g23_main: `-- === ИГРА «ПЕРЕКЛЮЧАТЕЛИ» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} -local activated = {false, false, false} -print("Активируй все 3 переключателя!") +local TweenService = game:GetService("TweenService") +local ORDER = { 2, 3, 1 } -- правильный порядок рычагов +local pressed = {} +local opened = false +local won = false -local ev = getEvent("SwitchToggled") -ev.Event:Connect(function(idx) - activated[idx] = true - print("Переключатель " .. idx .. " активирован") - if activated[1] and activated[2] and activated[3] then - print("ПОБЕДА! Все 3 активированы!") +__rbxl_show_text("Дёрни рычаги в нужном порядке (E)", 4) + +local clickSound = Instance.new("Sound", workspace) +clickSound.SoundId = "click"; clickSound.Volume = 0.7 +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Рычаги шлют ev:Fire(num) с номером +local leverEvent = getEvent("LeverPulled") +leverEvent.Event:Connect(function(num) + if opened then return end + clickSound:Play() + table.insert(pressed, num) + local i = #pressed + if pressed[i] ~= ORDER[i] then + pressed = {} + __rbxl_show_text("Неверно! Рычаги сброшены.", 1.5) + loseSound:Play() + return end + if #pressed == #ORDER then + opened = true + __rbxl_show_text("Верно! Дверь открыта.", 3) + winSound:Play() + local door = workspace:FindFirstChild("Дверь") + if door then + local dp = door.Position + local goal = { Position = Vector3.new(dp.X, dp.Y + 6, dp.Z) } + TweenService:Create(door, TweenInfo.new(1.2), goal):Play() + door.CanCollide = false + end + else + __rbxl_show_text("Так держать!", 1) + end +end) + +-- Финиш +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_show_text("Победа! Ты разгадал порядок!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end)`, - }, + g23_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, + }; + // 3 рычага — каждый ждёт E когда игрок рядом, шлёт свой номер + for (let n = 1; n <= 3; n++) { + overrides['g23_lever_' + n] = `-- === Скрипт рычага ${n} (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 3 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g23_lever_${n}_hint", "[E] Дёрнуть рычаг ${n}", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g23_lever_${n}_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("LeverPulled") + if ev then ev:Fire(${n}) end +end)`; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 24 — «Падающий мост» -- 2.47.2 From 745e50703d8a5adc49d78953dab77be6e4e50d5e Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:34:59 +0300 Subject: [PATCH 175/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2023=20=C2=AB=D0=9F=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=C2=BB=20(main+lever+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 699a68d..d689946 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -3209,7 +3209,7 @@ game.self.onTouch(() => { каждое нажатие.

                    - {`// === ИГРА «ПЕРЕКЛЮЧАТЕЛИ» — главный скрипт === + {`// === ИГРА «ПЕРЕКЛЮЧАТЕЛИ» — главный скрипт === // правильный порядок рычагов const ORDER = [2, 3, 1]; @@ -3249,7 +3249,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                    Самое интересное — проверка порядка:

                    • ORDER = [2, 3, 1] — секретный порядок: @@ -3277,10 +3277,10 @@ game.onMessage('win', () => { Вот скрипт первого рычага:

                      - {`// === Скрипт рычага 1 === + {`// === Скрипт рычага 1 === game.self.onInteract(() => { game.broadcast('lever', { num: 1 }); -}, { text: 'Дёрнуть рычаг 1', distance: 3 });`} +}, { text: 'Дёрнуть рычаг 1', distance: 3 });`}

                      Рычаг шлёт сообщение game.broadcast('lever', {'{ num: 1 }'}): имя сообщения — 'lever', а второй кусок — @@ -3297,10 +3297,10 @@ game.self.onInteract(() => {

                      Шаг 4. Скрипт финиша и проверка

                      - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
                      • подойди к рычагу — появится подсказка «Дёрнуть рычаг»;
                      • дёргай E в верном -- 2.47.2 From 5aec627b179f30cadb95e5f7c8f1ffbf5b3c40bb Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:37:05 +0300 Subject: [PATCH 176/214] =?UTF-8?q?feat(g24):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9F=D0=B0=D0=B4=D0=B0=D1=8E=D1=89=D0=B8=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - showText 'Беги по мосту — доски рушатся!' - onTick: p.y < -3 → respawn + 'Упал в пропасть! Снова.' + lose - onMessage 'win' → 'Победа!' + win + confetti - 18 досок: onTouch → click sound + delete через 1с - финиш: onTouch → broadcast 'win' Lua (паритет): - __rbxl_show_text + Sounds - Heartbeat: player_y < -3 → LoadCharacter + lose - BindableEvent WinReached - g24_plank_1..18: единый скрипт (IIFE генерит 18 ключей): Touched → click Sound + Debris:AddItem(part, 1) - g24_finish: Touched → WinReached:Fire (fired-флаг) --- src/community/docsGamesBuildersLua.js | 86 +++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 95335a3..44438a3 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2078,24 +2078,84 @@ end)`; // ═══════════════════════════════════════════════════════════════ // ИГРА 24 — «Падающий мост» // ═══════════════════════════════════════════════════════════════ - 'falling-bridge': { - g24_main: `-- === ИГРА «ПАДАЮЩИЙ МОСТ» (Lua) === -print("Беги по мосту — плиты падают!")`, - g24_plate: `-- === Скрипт падающей плиты (Lua) === + 'falling-bridge': (function() { + const TILES = 18; + const overrides = { + g24_main: `-- === ИГРА «ПАДАЮЩИЙ МОСТ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false + +__rbxl_show_text("Беги по мосту — доски рушатся!", 3) + +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Респаун при падении в пропасть +RunService.Heartbeat:Connect(function() + if won then return end + local py = __rbxl_player_y() + if py < -3 then + player:LoadCharacter() + __rbxl_show_text("Упал в пропасть! Снова.", 1.5) + loseSound:Play() + end +end) + +-- Финиш сообщает о победе +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + winSound:Play() + __rbxl_show_text("Победа! Ты перебежал мост!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g24_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent -local fell = false +local fired = false + part.Touched:Connect(function(hit) - if fell then return end + if fired then return end local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") if not h then return end - fell = true - task.delay(0.5, function() - part.Anchored = false - part.CanCollide = false - game:GetService("Debris"):AddItem(part, 3) - end) + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end end)`, - }, + }; + // 18 досок — каждая при касании играет click и исчезает через 1с + const plankScript = `-- === Скрипт доски моста (Lua) === +local Debris = game:GetService("Debris") +local part = script.Parent +local cracking = false + +local clickSound = Instance.new("Sound", part) +clickSound.SoundId = "click"; clickSound.Volume = 0.5 + +part.Touched:Connect(function(hit) + if cracking then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + cracking = true + clickSound:Play() + -- через 1 секунду доска пропадает + Debris:AddItem(part, 1) +end)`; + for (let i = 1; i <= TILES; i++) { + overrides['g24_plank_' + i] = plankScript; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 25 — «Облёт камеры» -- 2.47.2 From 2a39fc2b998b0dd9af27960efbbbfbd2bbc319a3 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:38:24 +0300 Subject: [PATCH 177/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2024=20=C2=AB=D0=9F=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=8E=D1=89=D0=B8=D0=B9=20=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=C2=BB=20(main+plank+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index d689946..1b092d8 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -3366,7 +3366,7 @@ game.self.onTouch(() => {

                        Шаг 2. Главный скрипт

                        - {`// === ИГРА «ПАДАЮЩИЙ МОСТ» — главный скрипт === + {`// === ИГРА «ПАДАЮЩИЙ МОСТ» — главный скрипт === let won = false; @@ -3392,7 +3392,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                        Главный скрипт game.onTick каждый кадр следит за высотой игрока: упал ниже -3 — провалился @@ -3405,14 +3405,14 @@ game.onMessage('win', () => {

                        Шаг 3. Скрипт доски

                        Этот скрипт вешается на каждую доску моста.

                        - {`// === Скрипт доски моста === + {`// === Скрипт доски моста === let cracking = false; game.self.onTouch(() => { if (cracking) return; cracking = true; game.sound.play('click'); game.after(1, () => { game.self.delete(); }); -});`} +});`}

                        Разберём:

                        • cracking — флажок-защёлка. Игрок может @@ -3433,10 +3433,10 @@ game.self.onTouch(() => {

                          Шаг 4. Скрипт финиша и проверка

                          - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
                          • встал на доску — щелчок, через секунду она рушится;
                          • стоишь на месте — проваливаешься, респаун на старте;
                          • -- 2.47.2 From d4b84cf73d216637eb897909e72d1cc8db9d4928 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:41:02 +0300 Subject: [PATCH 178/214] =?UTF-8?q?feat(g25):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9A=D0=B0=D0=BC=D0=B5=D1=80=D0=B0-=D0=BE=D0=B1=D0=BB?= =?UTF-8?q?=D1=91=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - camera.cutscene 4 точки + segDuration=1.8 - onCutsceneDone → showText 'Вперёд, к зелёному финишу!' - onMessage 'win' → 'Победа!' + win + confetti - finish: onTouch → broadcast 'win' Lua (паритет): - __rbxl_camera_cutscene('x1,y1,z1, x2,y2,z2, ...', segDuration) Парсит CSV → отдаёт в GameRuntime cmd 'camera.cutscene'. - __rbxl_on_cutscene_done(fn) — регистрация cb. В fireGlobalEvent при p.type='cutsceneDone' фейерим все cb. - BindableEvent WinReached - g25_finish: Touched → WinReached:Fire (fired-флаг) CSV вместо массива объектов — wasmoon через C-boundary плохо отдаёт массивы таблиц. --- src/community/docsGamesBuildersLua.js | 54 +++++++++++++++++++-------- src/editor/engine/lua/RobloxShim.js | 24 ++++++++++++ 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 44438a3..ff98dc6 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2161,24 +2161,48 @@ end)`; // ИГРА 25 — «Облёт камеры» // ═══════════════════════════════════════════════════════════════ 'flyby-camera': { - g25_main: `-- === ИГРА «ОБЛЁТ КАМЕРЫ» (Lua) === -local TweenService = game:GetService("TweenService") -local camera = workspace.CurrentCamera -camera.CameraType = Enum.CameraType.Scriptable + g25_main: `-- === ИГРА «КАМЕРА-ОБЛЁТ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} -local points = { - CFrame.new(Vector3.new(0, 20, -30), Vector3.new(0, 5, 0)), - CFrame.new(Vector3.new(20, 15, 0), Vector3.new(0, 5, 0)), - CFrame.new(Vector3.new(0, 25, 30), Vector3.new(0, 5, 0)), -} +local won = false -for _, cf in ipairs(points) do - local tween = TweenService:Create(camera, TweenInfo.new(2), { CFrame = cf }) - tween:Play(); tween.Completed:Wait() -end +-- При старте — облёт уровня камерой по точкам +-- (точки x,y,z через запятую; segDuration — длительность одного отрезка) +__rbxl_camera_cutscene("0,18,-10, 12,12,8, -12,12,18, 0,10,28", 1.8) -camera.CameraType = Enum.CameraType.Custom -print("Облёт окончен — теперь играй!")`, +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Когда облёт закончился — отдаём камеру игроку и пишем подсказку +__rbxl_on_cutscene_done(function() + __rbxl_show_text("Вперёд, к зелёному финишу!", 3) +end) + +-- Финиш сообщает о победе +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_show_text("Победа! Уровень пройден!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g25_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index ffc1dfd..00af0d4 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1965,6 +1965,24 @@ export function registerRobloxShim(lua, opts) { global.set('__rbxl_heal_player', (amount) => { send('player.heal', { amount: Number(amount) || 0 }); }); + // Камера-облёт — паритет с JS game.camera.cutscene(points, opts). + // pointsFlat: x1,y1,z1,x2,y2,z2,... — потому что массив объектов + // в wasmoon через C-boundary неудобен. + global.set('__rbxl_camera_cutscene', (pointsFlat, segDuration) => { + const arr = String(pointsFlat || '').split(',').map((s) => Number(s) || 0); + const points = []; + for (let i = 0; i + 2 < arr.length; i += 3) { + points.push({ x: arr[i], y: arr[i + 1], z: arr[i + 2] }); + } + send('camera.cutscene', { + points, + segDuration: Number(segDuration) || 1.5, + }); + }); + const _cutsceneDoneCbs = []; + global.set('__rbxl_on_cutscene_done', (fn) => { + if (typeof fn === 'function') _cutsceneDoneCbs.push(fn); + }); // Подброс игрока — паритет с JS game.player.boostJump(strength). // 1.0 = обычный прыжок, 3.0 = втрое выше, и т.д. global.set('__rbxl_boost_jump', (strength) => { @@ -2279,6 +2297,12 @@ export function registerRobloxShim(lua, opts) { if (part && humanoid.Touched) humanoid.Touched.Fire(part); } } + // Cutscene камеры закончилась — фейерим зарегистрированные cb. + if (p.type === 'cutsceneDone') { + for (const fn of _cutsceneDoneCbs) { + _pendingHandlerQueue.push({ fn, args: [] }); + } + } // NPC погиб — фейерим registered cb для конкретного локального ref. if (p.type === 'npcDeath' && p.npcId != null) { const realRef = 'npc:' + p.npcId; -- 2.47.2 From 189a23ff7ceeed5aaff7de6597b4f50c95b2bf2a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:44:14 +0300 Subject: [PATCH 179/214] =?UTF-8?q?fix(g25):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D1=91=D0=BC=20lookAt-=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=8C=20=D0=BA=20=D1=84=D0=B8=D0=BD=D0=B8=D1=88=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cameraCutscene без lookAt держит угол постоянным (setTarget не зовётся). Добавил 3-й arg в __rbxl_camera_cutscene и 4 lookAt-точки указывающие вдоль пути — камера плавно поворачивается к финишу. --- src/community/docsGamesBuildersLua.js | 11 ++++++++--- src/editor/engine/lua/RobloxShim.js | 22 +++++++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index ff98dc6..7f14797 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2166,9 +2166,14 @@ ${SNIPPET_BROADCAST} local won = false --- При старте — облёт уровня камерой по точкам --- (точки x,y,z через запятую; segDuration — длительность одного отрезка) -__rbxl_camera_cutscene("0,18,-10, 12,12,8, -12,12,18, 0,10,28", 1.8) +-- При старте — облёт уровня камерой по точкам. +-- 1-й arg — путь камеры (4 точки x,y,z), +-- 2-й — длительность одного отрезка, +-- 3-й — куда камера смотрит в каждой точке (тоже 4 точки). +__rbxl_camera_cutscene( + "0,18,-10, 12,12,8, -12,12,18, 0,10,28", 1.8, + "0,2,8, 0,2,14, 0,2,20, 0,2,27" +) local winSound = Instance.new("Sound", workspace) winSound.SoundId = "win"; winSound.Volume = 1 diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 00af0d4..5480426 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1966,16 +1966,20 @@ export function registerRobloxShim(lua, opts) { send('player.heal', { amount: Number(amount) || 0 }); }); // Камера-облёт — паритет с JS game.camera.cutscene(points, opts). - // pointsFlat: x1,y1,z1,x2,y2,z2,... — потому что массив объектов - // в wasmoon через C-boundary неудобен. - global.set('__rbxl_camera_cutscene', (pointsFlat, segDuration) => { - const arr = String(pointsFlat || '').split(',').map((s) => Number(s) || 0); - const points = []; - for (let i = 0; i + 2 < arr.length; i += 3) { - points.push({ x: arr[i], y: arr[i + 1], z: arr[i + 2] }); - } + // pointsFlat/lookAtFlat: x1,y1,z1,x2,y2,z2,... — потому что массив + // объектов в wasmoon через C-boundary неудобен. + global.set('__rbxl_camera_cutscene', (pointsFlat, segDuration, lookAtFlat) => { + const parse = (s) => { + const out = []; + const arr = String(s || '').split(',').map((v) => Number(v) || 0); + for (let i = 0; i + 2 < arr.length; i += 3) { + out.push({ x: arr[i], y: arr[i + 1], z: arr[i + 2] }); + } + return out; + }; send('camera.cutscene', { - points, + points: parse(pointsFlat), + lookAt: lookAtFlat ? parse(lookAtFlat) : [], segDuration: Number(segDuration) || 1.5, }); }); -- 2.47.2 From bc1214e60039a75716786af0fda1ce65da61ea7e Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:45:14 +0300 Subject: [PATCH 180/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2025=20=C2=AB=D0=9A=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=B0-=D0=BE=D0=B1=D0=BB=D1=91=D1=82=C2=BB?= =?UTF-8?q?=20(main+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 1b092d8..f0aa22a 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -3501,7 +3501,7 @@ game.self.onTouch(() => { закончится.

                            - {`// === ИГРА «КАМЕРА-ОБЛЁТ» — главный скрипт === + {`// === ИГРА «КАМЕРА-ОБЛЁТ» — главный скрипт === let won = false; @@ -3528,7 +3528,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                            Разберём:

                            • game.camera.cutscene([точки], опции) — @@ -3556,10 +3556,10 @@ game.onMessage('win', () => { из разных «песочниц» общаются между собой.

                              - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
                              • с началом игры камера облетает уровень;
                              • облёт закончился — управление вернулось, появилась -- 2.47.2 From 2c324fa5762afd6ae882fb5fb8fe8aff83613c6f Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:47:20 +0300 Subject: [PATCH 181/214] =?UTF-8?q?feat(g26):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9C=D0=B0=D0=B3=D0=BD=D0=B8=D1=82=20=D0=BC=D0=BE=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - showText 'Подходи к монеткам — они притянутся!' - ui.score=0 → каждая собранная монета +1 - onMessage 'coin' → score++ + 'coin' sound, при score>=TOTAL победа + confetti - 8 монет: onTick → dist<6 → tween к игроку (0.5с), dist<1.2 → delete + broadcast Lua (паритет): - __rbxl_show_text + Sounds - __rbxl_score_set(N) — паритет с game.ui.score=N (ui.set id=__score) - BindableEvent CoinCollected - 8 g26_coin_N: Heartbeat → dist<6 TweenService к Vector3(px,py+1,pz), dist<1.2 → ev:Fire + Destroy Shim добавил __rbxl_score_set(value). --- src/community/docsGamesBuildersLua.js | 97 ++++++++++++++++++--------- src/editor/engine/lua/RobloxShim.js | 5 ++ 2 files changed, 72 insertions(+), 30 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 7f14797..278f5cb 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2213,41 +2213,78 @@ end)`, // ═══════════════════════════════════════════════════════════════ // ИГРА 26 — «Магнит монет» // ═══════════════════════════════════════════════════════════════ - 'coin-magnet': { - g26_main: `-- === ИГРА «МАГНИТ МОНЕТ» (Lua) === -local Players = game:GetService("Players") -local RunService = game:GetService("RunService") -local CollectionService = game:GetService("CollectionService") + 'coin-magnet': (function() { + const TOTAL = 8; + const overrides = { + g26_main: `-- === ИГРА «МАГНИТ МОНЕТ» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} +local TOTAL = ${TOTAL} local score = 0 -local ev = getEvent("CoinCollected") -ev.Event:Connect(function() - score = score + 1 - print("Собрано: " .. score) -end) -RunService.Heartbeat:Connect(function(dt) - local player = Players:GetPlayers()[1] - if not player or not player.Character then return end - local hrp = player.Character:FindFirstChild("HumanoidRootPart") - if not hrp then return end - for _, coin in ipairs(CollectionService:GetTagged("magnetcoin")) do - local dist = (coin.Position - hrp.Position).Magnitude - if dist < 8 then - coin.Position = coin.Position + (hrp.Position - coin.Position).Unit * 20 * dt - if dist < 1 then - ev:Fire() - coin:Destroy() - end - end +__rbxl_score_set(0) +__rbxl_show_text("Подходи к монеткам — они притянутся!", 3) + +local coinSound = Instance.new("Sound", workspace) +coinSound.SoundId = "coin"; coinSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Монетки шлют ev:Fire() при сборе +local coinEvent = getEvent("CoinCollected") +coinEvent.Event:Connect(function() + score = score + 1 + __rbxl_score_set(score) + coinSound:Play() + if score >= TOTAL then + winSound:Play() + __rbxl_show_text("Победа! Все монетки собраны!", 5) + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) end -end) -print("Монетки сами летят к тебе!")`, - g26_coin: `-- === Скрипт магнит-монетки (Lua) === -local CollectionService = game:GetService("CollectionService") -CollectionService:AddTag(script.Parent, "magnetcoin")`, - }, +end)`, + }; + // 8 монеток — каждая со своим Heartbeat: при dist<6 летит к игроку, при dist<1.2 собрана + const coinScript = `-- === Скрипт магнитной монетки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local TweenService = game:GetService("TweenService") +local part = script.Parent +local flying = false +local taken = false + +RunService.Heartbeat:Connect(function() + if taken then return end + local cp = part.Position + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + -- ждём пока позиция игрока придёт + if px == 0 and py == 0 and pz == 0 then return end + local dx = px - cp.X + local dz = pz - cp.Z + local dist = math.sqrt(dx*dx + dz*dz) + + if dist < 1.2 then + taken = true + local ev = ReplicatedStorage:FindFirstChild("CoinCollected") + if ev then ev:Fire() end + part:Destroy() + return + end + if not flying and dist < 6 then + flying = true + local goal = { Position = Vector3.new(px, py + 1, pz) } + TweenService:Create(part, TweenInfo.new(0.5), goal):Play() + end +end)`; + for (let i = 1; i <= TOTAL; i++) { + overrides['g26_coin_' + i] = coinScript; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 27 — «Двойной прыжок» diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 5480426..20f8b2f 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1965,6 +1965,11 @@ export function registerRobloxShim(lua, opts) { global.set('__rbxl_heal_player', (amount) => { send('player.heal', { amount: Number(amount) || 0 }); }); + // Счёт в углу — паритет с JS game.ui.score = N. null → скрыть. + global.set('__rbxl_score_set', (value) => { + const text = value == null ? null : ('Очки: ' + value); + send('ui.set', { id: '__score', text }); + }); // Камера-облёт — паритет с JS game.camera.cutscene(points, opts). // pointsFlat/lookAtFlat: x1,y1,z1,x2,y2,z2,... — потому что массив // объектов в wasmoon через C-boundary неудобен. -- 2.47.2 From 19c475f1325bdb4be78547bfc0d15b61e254938a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:48:31 +0300 Subject: [PATCH 182/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2026=20=C2=AB=D0=9C=D0=B0?= =?UTF-8?q?=D0=B3=D0=BD=D0=B8=D1=82=20=D0=BC=D0=BE=D0=BD=D0=B5=D1=82=C2=BB?= =?UTF-8?q?=20(main+coin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index f0aa22a..9bc10ec 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -3621,7 +3621,7 @@ game.self.onTouch(() => {

                                Шаг 2. Главный скрипт

                                - {`// === ИГРА «МАГНИТ МОНЕТ» — главный скрипт === + {`// === ИГРА «МАГНИТ МОНЕТ» — главный скрипт === let score = 0; const TOTAL = 8; @@ -3641,7 +3641,7 @@ game.onMessage('coin', () => { game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); } -});`} +});`}

                                Главный скрипт простой — он только считает собранные монетки и проверяет победу. Каждая монетка работает @@ -3654,7 +3654,7 @@ game.onMessage('coin', () => {

                                Шаг 3. Скрипт магнитной монетки

                                Этот скрипт вешается на каждую монетку.

                                - {`// === Скрипт магнитной монетки === + {`// === Скрипт магнитной монетки === let flying = false; // монетка уже летит к игроку? let taken = false; @@ -3681,7 +3681,7 @@ game.onTick(() => { { x: p.x, y: p.y + 1, z: p.z }, { duration: 0.5, easing: 'ease' }); } -});`} +});`}

                                Разберём по частям:

                                • первые кадры game.self.position и -- 2.47.2 From 17417b1b3367138f1196681d4cd9bc003d088ef6 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:50:08 +0300 Subject: [PATCH 183/214] =?UTF-8?q?feat(g27):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=94=D0=B2=D0=BE=D0=B9=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D1=8B=D0=B6=D0=BE=D0=BA=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - player.setDoubleJump(true) - showText 'Жми Space ДВАЖДЫ — двойной прыжок!' - onTick: y<-3 → respawn + lose - onMessage 'win' → 'Победа!' + win + confetti - финиш: onTouch → broadcast 'win' Lua (паритет): - __rbxl_set_double_jump(true) — паритет с player.setDoubleJump - __rbxl_show_text + Sounds - Heartbeat: player_y < -3 → LoadCharacter + lose - BindableEvent WinReached - g27_finish: Touched → WinReached:Fire (fired-флаг) Shim добавил __rbxl_set_double_jump(bool) → cmd 'player.setDoubleJump'. --- src/community/docsGamesBuildersLua.js | 75 +++++++++++++++++---------- src/editor/engine/lua/RobloxShim.js | 4 ++ 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 278f5cb..bfce9b1 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2290,39 +2290,58 @@ end)`; // ИГРА 27 — «Двойной прыжок» // ═══════════════════════════════════════════════════════════════ 'double-jump': { - g27_main: `-- === ИГРА «ДВОЙНОЙ ПРЫЖОК» (Lua) === + g27_main: `-- === ИГРА «ДВОЙНОЙ ПРЫЖОК» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + local Players = game:GetService("Players") -local UserInputService = game:GetService("UserInputService") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false -local function setupDoubleJump(player) - local jumpsLeft = 2 - local char = player.Character or player.CharacterAdded:Wait() - local h = char:WaitForChild("Humanoid") +-- Включаем игроку двойной прыжок — теперь можно прыгнуть ещё раз в воздухе +__rbxl_set_double_jump(true) +__rbxl_show_text("Жми Space ДВАЖДЫ — двойной прыжок!", 4) - -- Восстанавливаем прыжки при касании земли - h.StateChanged:Connect(function(_, newState) - if newState == Enum.HumanoidStateType.Landed then - jumpsLeft = 2 - end - end) +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 - UserInputService.InputBegan:Connect(function(input, gp) - if gp then return end - if input.KeyCode == Enum.KeyCode.Space and jumpsLeft > 0 then - jumpsLeft = jumpsLeft - 1 - if jumpsLeft == 1 then - local hrp = char:FindFirstChild("HumanoidRootPart") - if hrp then - hrp.Velocity = Vector3.new(hrp.Velocity.X, 50, hrp.Velocity.Z) - end - end - end - end) -end +-- Респаун при падении в пропасть +RunService.Heartbeat:Connect(function() + if won then return end + local py = __rbxl_player_y() + if py < -3 then + player:LoadCharacter() + loseSound:Play() + end +end) -Players.PlayerAdded:Connect(setupDoubleJump) -for _, p in ipairs(Players:GetPlayers()) do setupDoubleJump(p) end -print("Жми Space дважды — двойной прыжок!")`, +-- Финиш сообщает о победе +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_show_text("Победа! Двойной прыжок освоен!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g27_finish: `-- === Скрипт финиш-зоны (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 20f8b2f..7f9a77f 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1970,6 +1970,10 @@ export function registerRobloxShim(lua, opts) { const text = value == null ? null : ('Очки: ' + value); send('ui.set', { id: '__score', text }); }); + // Двойной прыжок — паритет с JS game.player.setDoubleJump(bool). + global.set('__rbxl_set_double_jump', (enabled) => { + send('player.setDoubleJump', { enabled: !!enabled }); + }); // Камера-облёт — паритет с JS game.camera.cutscene(points, opts). // pointsFlat/lookAtFlat: x1,y1,z1,x2,y2,z2,... — потому что массив // объектов в wasmoon через C-boundary неудобен. -- 2.47.2 From b9473cca5038de5a10ee417dc7b5998f1aaaf458 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:51:37 +0300 Subject: [PATCH 184/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2027=20=C2=AB=D0=94=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D1=8B=D0=B6=D0=BE?= =?UTF-8?q?=D0=BA=C2=BB=20(main+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 9bc10ec..a2fbdca 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -3772,7 +3772,7 @@ game.onTick(() => {

                                  Шаг 2. Главный скрипт

                                  - {`// === ИГРА «ДВОЙНОЙ ПРЫЖОК» — главный скрипт === + {`// === ИГРА «ДВОЙНОЙ ПРЫЖОК» — главный скрипт === let won = false; @@ -3800,7 +3800,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                  Главное здесь:

                                  • game.player.setDoubleJump(true) — даёт @@ -3825,10 +3825,10 @@ game.onMessage('win', () => { из разных «песочниц» общаются.

                                    - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
                                    • прыгай Space, в воздухе жми ещё раз — второй прыжок;
                                    • -- 2.47.2 From 095a79cab42d9a6d9bf99970d585c71158609c15 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:54:09 +0300 Subject: [PATCH 185/214] =?UTF-8?q?feat(g28):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9F=D1=80=D0=B8=D0=B7=D1=80=D0=B0=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=81=D1=82=D0=B5=D0=BD=D1=8B=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - showText 'Кликай по фиолетовым стенам — пройди сквозь!' - onMessage 'win' → 'Победа!' + win + confetti - 4 стены: onClick → passThrough + opacity=0.25 + click sound + showText - финиш: onTouch → broadcast 'win' Lua (паритет): - __rbxl_show_text + Sounds - BindableEvent WinReached - 4 g28_wall_N: part.Clicked → CanCollide=false + Transparency=0.75 + click Sound + showText 'Стена стала призрачной!' - g28_finish: Touched → WinReached:Fire (fired-флаг) --- src/community/docsGamesBuildersLua.js | 66 ++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index bfce9b1..dc5babc 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2347,14 +2347,66 @@ end)`, // ═══════════════════════════════════════════════════════════════ // ИГРА 28 — «Призрачные стены» // ═══════════════════════════════════════════════════════════════ - 'ghost-walls': { - g28_main: `-- === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» (Lua) === -print("Некоторые стены — призрачные. Найди проход!")`, - g28_ghost: `-- === Скрипт призрачной стены (Lua) === + 'ghost-walls': (function() { + const WALL_IDS = [1, 2, 3, 4]; + const overrides = { + g28_main: `-- === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local won = false + +__rbxl_show_text("Кликай по фиолетовым стенам — пройди сквозь!", 4) + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Финиш сообщает о победе +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_show_text("Победа! Ты прошёл сквозь все стены!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g28_finish: `-- === Скрипт финиш-зоны (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") local part = script.Parent -part.CanCollide = false -part.Transparency = 0.5`, - }, +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end +end)`, + }; + // 4 фиолетовые стены — клик делает стену проходимой и полупрозрачной + const wallScript = `-- === Скрипт призрачной стены (Lua) === +local part = script.Parent +local ghost = false + +local clickSound = Instance.new("Sound", part) +clickSound.SoundId = "click"; clickSound.Volume = 0.7 + +part.Clicked:Connect(function() + if ghost then return end + ghost = true + part.CanCollide = false + part.Transparency = 0.75 + clickSound:Play() + __rbxl_show_text("Стена стала призрачной!", 1.5) +end)`; + for (const wid of WALL_IDS) { + overrides['g28_wall_' + wid] = wallScript; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 29 — «Магазин» -- 2.47.2 From a72101a29aa9a0a99987a4239846eb54679a8032 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:57:38 +0300 Subject: [PATCH 186/214] =?UTF-8?q?fix(g28):=20ClickDetector=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20part.Clicked=20(=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20=D0=B2=20=D0=A2=D0=B8=D1=80=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit part.Clicked не существует — был fallback try/catch с молчаливым падением. ClickDetector + MouseClick — рабочий путь (используется в игре 15 «Тир»). --- src/community/docsGamesBuildersLua.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index dc5babc..38d91d2 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2386,7 +2386,7 @@ part.Touched:Connect(function(hit) if ev then ev:Fire() end end)`, }; - // 4 фиолетовые стены — клик делает стену проходимой и полупрозрачной + // 4 фиолетовые стены — клик через ClickDetector делает стену проходимой const wallScript = `-- === Скрипт призрачной стены (Lua) === local part = script.Parent local ghost = false @@ -2394,7 +2394,11 @@ local ghost = false local clickSound = Instance.new("Sound", part) clickSound.SoundId = "click"; clickSound.Volume = 0.7 -part.Clicked:Connect(function() +-- ClickDetector — даёт стене кликабельность (как в игре «Тир») +local cd = Instance.new("ClickDetector") +cd.Parent = part + +cd.MouseClick:Connect(function() if ghost then return end ghost = true part.CanCollide = false -- 2.47.2 From ed23ec782c0b8586e1ef1830c213bf2c03dd48db Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 21:58:44 +0300 Subject: [PATCH 187/214] =?UTF-8?q?docs:=20CodeBoth=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=BA=D0=B0=2028=20=C2=AB=D0=9F=D1=80?= =?UTF-8?q?=D0=B8=D0=B7=D1=80=D0=B0=D1=87=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=C2=BB=20(main+wall+finish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsLessons.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index a2fbdca..fea3530 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -3892,7 +3892,7 @@ game.self.onTouch(() => {

                                      Шаг 2. Главный скрипт

                                      - {`// === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» — главный скрипт === + {`// === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» — главный скрипт === game.ui.showText('Кликай по фиолетовым стенам — пройди сквозь!', 4); @@ -3904,7 +3904,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                      Главный скрипт только обрабатывает победу — он ловит сообщение 'win' от финиша через @@ -3915,7 +3915,7 @@ game.onMessage('win', () => {

                                      Шаг 3. Скрипт призрачной стены

                                      Этот скрипт вешается на каждую стену.

                                      - {`// === Скрипт призрачной стены === + {`// === Скрипт призрачной стены === let ghost = false; game.self.onClick(() => { @@ -3926,7 +3926,7 @@ game.self.onClick(() => { game.scene.setOpacity(game.self.ref, 0.25); game.sound.play('click'); game.ui.showText('Стена стала призрачной!', 1.5); -});`} +});`}

                                      Разберём:

                                      • game.self.onClick(fn) — функция внутри -- 2.47.2 From facd6aa837a9b3abbe9c0557dd800a0ff1a4a9a9 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:00:35 +0300 Subject: [PATCH 188/214] =?UTF-8?q?feat(g29):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9C=D0=B0=D0=B3=D0=B0=D0=B7=D0=B8=D0=BD=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: - 7 монеток onTouch → broadcast coin → score++, coin sound - прилавок onInteract E (4) → broadcast buy → coins>=5? покупка ключа + inventory.add('Ключ') + 'Куплен!' + win sound; иначе lose+'Мало!' - дверь onInteract E (4) → broadcast open-door → hasKey? tween y+6; иначе 'Дверь заперта' - финиш → broadcast win → 'Победа!' + confetti Lua (паритет): - BindableEvents CoinCollected/BuyKey/OpenDoor/WinReached - __rbxl_score_set, __rbxl_show_text, __rbxl_inventory_define/add - 7 g29_coin_N: Touched → CoinCollected:Fire + Destroy - g29_shop, g29_door: Heartbeat distance-check (4) + __rbxl_hud_set '[E] Купить ключ (5 монет)' / '[E] Открыть дверь' + InputBegan E - g29_main обрабатывает все события, при покупке: TweenService двери Position.Y+6 + CanCollide=false - g29_finish: Touched → WinReached:Fire (fired-флаг) --- src/community/docsGamesBuildersLua.js | 209 ++++++++++++++++++++++---- 1 file changed, 177 insertions(+), 32 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 38d91d2..f115b63 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2415,42 +2415,187 @@ end)`; // ═══════════════════════════════════════════════════════════════ // ИГРА 29 — «Магазин» // ═══════════════════════════════════════════════════════════════ - 'shop': { - g29_main: `-- === ИГРА «МАГАЗИН» (Lua) === -local Players = game:GetService("Players") -Players.PlayerAdded:Connect(function(player) - local stats = Instance.new("Folder", player); stats.Name = "leaderstats" - local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 20 -end) -for _, p in ipairs(Players:GetPlayers()) do - if not p:FindFirstChild("leaderstats") then - local stats = Instance.new("Folder", p); stats.Name = "leaderstats" - local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 20 - end -end -print("У тебя 20 монет — купи что-нибудь!")`, - g29_item: `-- === Скрипт товара (Lua) === -local Players = game:GetService("Players") -local part = script.Parent -local price = part:GetAttribute("Price") or 5 + 'shop': (function() { + const COIN_IDS = [1, 2, 3, 4, 5, 6, 7]; + const overrides = { + g29_main: `-- === ИГРА «МАГАЗИН» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local TweenService = game:GetService("TweenService") +local PRICE = 5 +local coins = 0 local bought = false +local hasKey = false +local doorOpen = false +local won = false + +__rbxl_score_set(0) +__rbxl_show_text("Собери монетки и купи ключ у продавца!", 4) + +local coinSound = Instance.new("Sound", workspace) +coinSound.SoundId = "coin"; coinSound.Volume = 0.7 +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Сбор монетки +local coinEvent = getEvent("CoinCollected") +coinEvent.Event:Connect(function() + coins = coins + 1 + __rbxl_score_set(coins) + coinSound:Play() +end) + +-- Покупка ключа у прилавка +local buyEvent = getEvent("BuyKey") +buyEvent.Event:Connect(function() + if bought then + __rbxl_show_text("Ключ уже куплен, иди к двери!", 2) + return + end + if coins < PRICE then + __rbxl_show_text("Мало монет! Нужно " .. PRICE .. ", есть " .. coins, 2) + loseSound:Play() + return + end + bought = true + hasKey = true + coins = coins - PRICE + __rbxl_score_set(coins) + __rbxl_inventory_define("key", "Ключ", "#ffd700") + __rbxl_inventory_add("key", 1) + __rbxl_show_text("Куплен Ключ! Открой дверь.", 3) + winSound:Play() +end) + +-- Открытие двери +local doorEvent = getEvent("OpenDoor") +doorEvent.Event:Connect(function() + if doorOpen then return end + if not hasKey then + __rbxl_show_text("Дверь заперта. Купи ключ в магазине.", 2) + return + end + doorOpen = true + local door = workspace:FindFirstChild("Дверь") + if door then + local dp = door.Position + local goal = { Position = Vector3.new(dp.X, dp.Y + 6, dp.Z) } + TweenService:Create(door, TweenInfo.new(1.2), goal):Play() + door.CanCollide = false + end + __rbxl_show_text("Дверь открыта!", 2) +end) + +-- Победа +local winEvent = getEvent("WinReached") +winEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_show_text("Победа! Ты прошёл магазин!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g29_shop: `-- === Скрипт прилавка (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g29_shop_hint", "[E] Купить ключ (5 монет)", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g29_shop_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("BuyKey") + if ev then ev:Fire() end +end)`, + g29_door: `-- === Скрипт двери (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g29_door_hint", "[E] Открыть дверь", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g29_door_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("OpenDoor") + if ev then ev:Fire() end +end)`, + g29_finish: `-- === Скрипт финиш-зоны (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false part.Touched:Connect(function(hit) - if bought then return end - local player = Players:GetPlayerFromCharacter(hit.Parent) - if not player then return end - local stats = player:FindFirstChild("leaderstats") - if not stats then return end - if stats['Монеты'].Value >= price then - stats['Монеты'].Value = stats['Монеты'].Value - price - bought = true - print("Куплено! Цена: " .. price) - part.Transparency = 0.7 - else - print("Не хватает! Нужно " .. price .. " монет") - end + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("WinReached") + if ev then ev:Fire() end end)`, - }, + }; + // 7 монеток — Touched → CoinCollected:Fire + Destroy + const coinScript = `-- === Скрипт монетки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local taken = false + +part.Touched:Connect(function(hit) + if taken then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + taken = true + local ev = ReplicatedStorage:FindFirstChild("CoinCollected") + if ev then ev:Fire() end + part:Destroy() +end)`; + for (const cid of COIN_IDS) { + overrides['g29_coin_' + cid] = coinScript; + } + return overrides; + })(), // ═══════════════════════════════════════════════════════════════ // ИГРА 30 — «Квесты» -- 2.47.2 From 5186ee3b7034bbecb778bad318e2bd699dce6851 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:03:06 +0300 Subject: [PATCH 189/214] =?UTF-8?q?feat(g29):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20NPC-=D0=BF=D1=80=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D1=86=20=D0=B7=D0=B0=20=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=BE=D0=BC=20=D1=81=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20'=D0=9F=D1=80=D0=BE=D0=B4=D0=B0=D0=B2?= =?UTF-8?q?=D0=B5=D1=86'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/community/docsGamesBuildersLua.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index f115b63..259533c 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2507,6 +2507,12 @@ local RunService = game:GetService("RunService") local part = script.Parent local hintVisible = false +-- Спавним продавца ЗА прилавком (z=5 → z=6) +local sellerRef = __rbxl_spawn_npc("character-a", 0, 1.6, 6, "Продавец", 100, 0) +task.delay(0.3, function() + __rbxl_set_label(sellerRef, "Продавец", "#ffe44a", 2.5) +end) + RunService.Heartbeat:Connect(function() local px = __rbxl_player_x() local pz = __rbxl_player_z() -- 2.47.2 From f69df55e3b656c5fa96347160f163f3b63798c6c Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:06:27 +0300 Subject: [PATCH 190/214] =?UTF-8?q?docs:=20CodeBoth=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=2029=20+=20feat(g30):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9A=D0=B2=D0=B5=D1=81=D1=82=20=D1=81=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g29 docs: 5 скриптов под CodeBoth (main, coin_1, shop, door, finish). g30 паритет: - stage 0→1→2→3→4 (talk/coin/flag/talk) - setObjective через __rbxl_hud_set 50,8 цвет/размер - NPC 'Старейшина' spawnNpc + npc_say на этапах - BindableEvents Talk/CoinDone/FlagDone - g30_npc: Heartbeat distance-check (4) + '[E] Поговорить' + InputBegan E - g30_coin: Touched → CoinDone:Fire + Destroy (taken-флаг) - g30_flag: Touched → FlagDone:Fire (fired-флаг) - На stage 3: showText + confetti + win sound --- src/community/docsGamesBuildersLua.js | 133 +++++++++++++++++++++++--- src/community/docsLessons.jsx | 16 ++-- 2 files changed, 126 insertions(+), 23 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 259533c..84cfddd 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2607,26 +2607,129 @@ end)`; // ИГРА 30 — «Квесты» // ═══════════════════════════════════════════════════════════════ 'quest-tasks': { - g30_main: `-- === ИГРА «КВЕСТЫ» (Lua) === + g30_main: `-- === ИГРА «КВЕСТ С ЗАДАНИЯМИ» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} -local quests = { - { name = "Собери 5 ягод", goal = 5, current = 0 }, - { name = "Победи врага", goal = 1, current = 0 }, - { name = "Дойди до башни", goal = 1, current = 0 }, -} +local stage = 0 -- 0=не начат, 1=собрать монетку, 2=дойти до флага, 3=вернуться, 4=готово -print("Квесты:") -for i, q in ipairs(quests) do print(" " .. i .. ". " .. q.name) end +local function setObjective(text, color) + __rbxl_hud_set("objective", "ЦЕЛЬ: " .. text, 50, 8, color or "#ffe066", 24) +end +setObjective("подойди к квестодателю и нажми E") -local ev = getEvent("QuestProgress") -ev.Event:Connect(function(idx, amount) - quests[idx].current = quests[idx].current + (amount or 1) - local q = quests[idx] - print(q.name .. ": " .. q.current .. "/" .. q.goal) - if q.current >= q.goal then - print("Квест выполнен: " .. q.name) +-- Спавним NPC рядом с тумбой (NPC = квестодатель) +local npcRef = __rbxl_spawn_npc("character-a", 1.5, 1, 2, "Старейшина", 100, 0) + +local coinSound = Instance.new("Sound", workspace) +coinSound.SoundId = "coin"; coinSound.Volume = 0.7 +local pickupSound = Instance.new("Sound", workspace) +pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Поговорить с NPC +local talkEvent = getEvent("Talk") +talkEvent.Event:Connect(function() + if stage == 0 then + stage = 1 + __rbxl_npc_say(npcRef, "Задание 1: найди жёлтую монетку!", 4) + setObjective("собери жёлтую монетку (слева)") + elseif stage == 3 then + stage = 4 + __rbxl_npc_say(npcRef, "Молодец! Квест выполнен!", 4) + __rbxl_hud_set("objective", "КВЕСТ ПРОЙДЕН!", 50, 8, "#22dd55", 26) + __rbxl_show_text("Победа! Квест пройден!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + elseif stage == 4 then + __rbxl_npc_say(npcRef, "Спасибо, герой!", 3) + elseif stage == 1 then + __rbxl_npc_say(npcRef, "Ты ещё не собрал монетку!", 3) + elseif stage == 2 then + __rbxl_npc_say(npcRef, "Сначала дойди до синего флага!", 3) end +end) + +-- Монетка собрана +local coinEvent = getEvent("CoinDone") +coinEvent.Event:Connect(function() + if stage ~= 1 then return end + stage = 2 + coinSound:Play() + __rbxl_npc_say(npcRef, "Отлично! Теперь дойди до синего флага.", 4) + __rbxl_show_text("Монетка собрана!", 2) + setObjective("дойди до синего флага (справа)") +end) + +-- Флаг достигнут +local flagEvent = getEvent("FlagDone") +flagEvent.Event:Connect(function() + if stage ~= 2 then return end + stage = 3 + pickupSound:Play() + __rbxl_show_text("Флаг найден!", 2) + setObjective("вернись к квестодателю и нажми E") +end)`, + g30_npc: `-- === Скрипт квестодателя (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g30_npc_hint", "[E] Поговорить", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g30_npc_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("Talk") + if ev then ev:Fire() end +end)`, + g30_coin: `-- === Скрипт квест-монетки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local taken = false + +part.Touched:Connect(function(hit) + if taken then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + taken = true + local ev = ReplicatedStorage:FindFirstChild("CoinDone") + if ev then ev:Fire() end + part:Destroy() +end)`, + g30_flag: `-- === Скрипт квест-флага (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("FlagDone") + if ev then ev:Fire() end end)`, }, diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index fea3530..80ff32e 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -4019,7 +4019,7 @@ game.self.onTouch(() => { продаёт ключ, открывает дверь.

                                        - {`// === ИГРА «МАГАЗИН» — главный скрипт === + {`// === ИГРА «МАГАЗИН» — главный скрипт === let coins = 0; const PRICE = 5; // ключ стоит 5 монет @@ -4070,7 +4070,7 @@ game.onMessage('win', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                        Каждый объект магазина — монетка, прилавок, дверь, финиш — работает в своей «песочнице» и не видит переменные @@ -4100,23 +4100,23 @@ game.onMessage('win', () => {

                                        Шаг 3. Скрипт монетки

                                        - {`// === Скрипт монетки === + {`// === Скрипт монетки === game.self.onTouch(() => { game.broadcast('coin'); game.self.delete(); -});`} +});`}

                                        Шаг 4. Скрипт прилавка и двери

                                        - {`// === Скрипт прилавка === + {`// === Скрипт прилавка === game.self.onInteract(() => { game.broadcast('buy'); -}, { text: 'Купить ключ (5 монет)', distance: 4 });`} +}, { text: 'Купить ключ (5 монет)', distance: 4 });`} - {`// === Скрипт двери === + {`// === Скрипт двери === game.self.onInteract(() => { game.broadcast('open-door'); -}, { text: 'Открыть дверь', distance: 4 });`} +}, { text: 'Открыть дверь', distance: 4 });`}

                                        И прилавок, и дверь — это объекты с взаимодействием по E. Подошёл к прилавку и нажал -- 2.47.2 From 901c249c297f3dc92e177f80b60885e4349b71b7 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:11:15 +0300 Subject: [PATCH 191/214] =?UTF-8?q?docs(29)=20+=20feat(g31):=20=C2=AB?= =?UTF-8?q?=D0=97=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=B1=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g30 docs CodeBoth 4 скрипта. g31: - killed counter (GOAL=12) + leaked (MAX_LEAK=5) - Heartbeat spawn враг каждые 2с в (random(-8,8), 1, 38), spawn 'character-b' speed 2.5 - task.delay 0.3 npc.moveTo(0, 2) — к базе - __rbxl_npc_on_click(ref, fn) → шлёт ref в общий BindableEvent - При клике главный скрипт проверяет dist<5, наносит урон - Каждые 0.4с проверка прорыва (z<4) → leaked++ + lose sound - 12 убитых → 'Победа!' + confetti - 5 прорывов → 'База разрушена!' Shim: __rbxl_npc_moveto/__rbxl_npc_remove. --- src/community/docsGamesBuildersLua.js | 124 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 16 ++-- src/editor/engine/lua/RobloxShim.js | 9 ++ 3 files changed, 140 insertions(+), 9 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 84cfddd..8ab55dc 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2734,7 +2734,129 @@ end)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 31-50: явных Lua-версий пока нет. + // ИГРА 31 — «Защита базы» + // ═══════════════════════════════════════════════════════════════ + 'base-defense': { + g31_main: `-- === ИГРА «ЗАЩИТА БАЗЫ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") + +local killed = 0 +local leaked = 0 +local total = 0 +local over = false +local GOAL = 12 +local MAX_LEAK = 5 + +__rbxl_score_set(0) +__rbxl_show_text("Защити базу! Кликай по врагам", 3) + +local hitSound = Instance.new("Sound", workspace) +hitSound.SoundId = "hit"; hitSound.Volume = 0.6 +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Все живые враги: { ref, dead } +local enemies = {} + +-- Клик по NPC (target.kind=npc) — наносим урон ближайшему в радиусе 5 +local clickEvent = getEvent("EnemyClicked") +clickEvent.Event:Connect(function(localRef) + if over then return end + for _, e in ipairs(enemies) do + if not e.dead and e.ref == localRef then + -- Проверка расстояния (в радиусе 5) + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local ex = __rbxl_npc_x(e.ref) + local ez = __rbxl_npc_z(e.ref) + local dx = px - ex + local dz = pz - ez + local dist = math.sqrt(dx*dx + dz*dz) + if dist < 5 then + e.dead = true + __rbxl_spawn_particles("explosion", ex, 2, ez, 0.4, 1) + __rbxl_npc_remove(e.ref) + hitSound:Play() + killed = killed + 1 + __rbxl_score_set(killed) + if killed >= GOAL and not over then + over = true + winSound:Play() + __rbxl_show_text("Победа! База защищена!", 5) + local px2 = __rbxl_player_x() + local py2 = __rbxl_player_y() + local pz2 = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px2, py2 + 3, pz2, 3, 3) + end + end + return + end + end +end) + +-- Регистрируем общий callback на клик по NPC — он шлёт ref в общий event +-- (фейерим один раз — при появлении каждого врага зовём __rbxl_npc_on_click) + +-- Спавн врага каждые 2 секунды +local spawnTimer = 0 +RunService.Heartbeat:Connect(function(dt) + if over then return end + if total >= GOAL + MAX_LEAK then return end + spawnTimer = spawnTimer + dt + if spawnTimer >= 2 then + spawnTimer = 0 + total = total + 1 + local x = math.random(-8, 8) + local ref = __rbxl_spawn_npc("character-b", x, 1, 38, "Враг", 30, 2.5) + local e = { ref = ref, dead = false } + table.insert(enemies, e) + -- Отложим moveTo пока NPC создастся + task.delay(0.3, function() + __rbxl_npc_moveto(ref, 0, 2) + end) + -- Клик по этому NPC → шлём в общий event + __rbxl_npc_on_click(ref, function() + local ev = game:GetService("ReplicatedStorage"):FindFirstChild("EnemyClicked") + if ev then ev:Fire(ref) end + end) + end +end) + +-- Проверка прорыва каждые 0.4с +local leakTimer = 0 +RunService.Heartbeat:Connect(function(dt) + if over then return end + leakTimer = leakTimer + dt + if leakTimer < 0.4 then return end + leakTimer = 0 + for _, e in ipairs(enemies) do + if not e.dead then + local ez = __rbxl_npc_z(e.ref) + local ex = __rbxl_npc_x(e.ref) + -- ez=0 ex=0 пока NPC не зарезолвлен — пропускаем + if not (ex == 0 and ez == 0) and ez < 4 then + e.dead = true + __rbxl_npc_remove(e.ref) + leaked = leaked + 1 + loseSound:Play() + __rbxl_show_text("Враг прорвался! (" .. leaked .. "/" .. MAX_LEAK .. ")", 2) + if leaked >= MAX_LEAK and not over then + over = true + __rbxl_show_text("База разрушена! Поражение.", 5) + end + end + end + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 32-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 80ff32e..bdb1036 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -4197,7 +4197,7 @@ game.self.onTouch(() => { по заданиям.

                                        - {`// === ИГРА «КВЕСТ С ЗАДАНИЯМИ» — главный скрипт === + {`// === ИГРА «КВЕСТ С ЗАДАНИЯМИ» — главный скрипт === // этап квеста: 0=не начат, 1=собрать монетку, 2=дойти до флага, // 3=вернуться к NPC, 4=готово @@ -4245,7 +4245,7 @@ game.onMessage('flag-done', () => { stage = 3; game.sound.play('pickup'); game.ui.showText('Квест: вернись к квестодателю', 3); -});`} +});`}

                                        Главное здесь — переменная stage:

                                        • stage хранит, на каком шаге квеста @@ -4275,23 +4275,23 @@ game.onMessage('flag-done', () => {

                                          Шаг 3. Скрипт квестодателя

                                          - {`// === Скрипт квестодателя === + {`// === Скрипт квестодателя === game.self.onInteract(() => { game.broadcast('talk'); -}, { text: 'Поговорить', distance: 4 });`} +}, { text: 'Поговорить', distance: 4 });`}

                                          Шаг 4. Скрипты монетки и флага

                                          - {`// === Скрипт квест-монетки === + {`// === Скрипт квест-монетки === game.self.onTouch(() => { game.broadcast('coin-done'); game.self.delete(); -});`} +});`} - {`// === Скрипт квест-флага === + {`// === Скрипт квест-флага === game.self.onTouch(() => { game.broadcast('flag-done'); -});`} +});`}

                                          Шаг 5. Проверка

                                            diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 7f9a77f..9ed5698 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1878,6 +1878,15 @@ export function registerRobloxShim(lua, opts) { global.set('__rbxl_npc_stop', (ref) => { send('npc.stop', { ref: String(ref || '') }); }); + global.set('__rbxl_npc_moveto', (ref, x, z) => { + send('npc.moveTo', { + ref: String(ref || ''), + x: +x || 0, z: +z || 0, + }); + }); + global.set('__rbxl_npc_remove', (ref) => { + send('npc.remove', { ref: String(ref || '') }); + }); // Позиция NPC — резолвится через GameRuntime по локальному ref. // GameRuntime в tick шлёт api.updateNpcPos(localRef, x, y, z). const _npcPositions = new Map(); // localRef → {x,y,z} -- 2.47.2 From 4ca3800e4962c6483fac0277f53206b082756d26 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:15:16 +0300 Subject: [PATCH 192/214] =?UTF-8?q?docs(31)=20+=20feat(g32):=20=C2=AB?= =?UTF-8?q?=D0=93=D0=BE=D0=BD=D0=BA=D0=B0=20=D1=81=20=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D0=B3=D0=B0=D0=BC=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g31 docs: CodeBoth для g31_main. g32 паритет: - LAPS=2, CP_COUNT=4, nextCp/lap/time/won - __rbxl_timer_set — паритет с game.ui.timer=N (формат mm:ss) - __rbxl_hud_set 'race' — постоянная надпись 'Круг N/2 • чекпоинт M/4' - Heartbeat: time += dt → timer update - BindableEvent CheckpointReached(num) - 4 g32_cp_N: Touched → CheckpointReached:Fire(N) - При 2 кругах → 'ФИНИШ! Xc' + showText + confetti + win Shim: __rbxl_timer_set(seconds). --- src/community/docsGamesBuildersLua.js | 86 ++++++++++++++++++++++++++- src/community/docsLessons.jsx | 4 +- src/editor/engine/lua/RobloxShim.js | 13 ++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 8ab55dc..e5b666c 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2856,7 +2856,91 @@ end)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 32-50: явных Lua-версий пока нет. + // ИГРА 32 — «Гонка с кругами» + // ═══════════════════════════════════════════════════════════════ + 'lap-race': (function() { + const CP_COUNT = 4; + const overrides = { + g32_main: `-- === ИГРА «ГОНКА С КРУГАМИ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local RunService = game:GetService("RunService") +local LAPS = 2 +local CP_COUNT = ${CP_COUNT} +local nextCp = 0 +local lap = 0 +local time = 0 +local won = false + +__rbxl_timer_set(0) +__rbxl_show_text("Проедь 2 круга через чекпоинты!", 3) + +local clickSound = Instance.new("Sound", workspace) +clickSound.SoundId = "click"; clickSound.Volume = 0.6 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local function updateProgress() + __rbxl_hud_set("race", + "Круг " .. (lap + 1) .. "/" .. LAPS .. " • чекпоинт " .. (nextCp + 1) .. "/" .. CP_COUNT, + 50, 8, "#ffe066", 22) +end +updateProgress() + +-- Таймер каждый кадр +RunService.Heartbeat:Connect(function(dt) + if won then return end + time = time + dt + __rbxl_timer_set(time) +end) + +-- Чекпоинты шлют CheckpointReached:Fire(num) +local cpEvent = getEvent("CheckpointReached") +cpEvent.Event:Connect(function(num) + if won then return end + if num - 1 ~= nextCp then return end + clickSound:Play() + nextCp = nextCp + 1 + if nextCp >= CP_COUNT then + nextCp = 0 + lap = lap + 1 + if lap >= LAPS then + won = true + local t = math.floor(time * 10) / 10 + __rbxl_hud_set("race", "ФИНИШ! " .. t .. " сек", 50, 8, "#22dd55", 24) + __rbxl_show_text("Финиш! Круги пройдены за " .. t .. " сек", 6) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + else + __rbxl_show_text("Круг " .. lap .. " из " .. LAPS .. "!", 2) + updateProgress() + end + else + updateProgress() + end +end)`, + }; + // 4 чекпоинта — Touched → CheckpointReached:Fire(num) + for (let i = 1; i <= CP_COUNT; i++) { + overrides['g32_cp_' + i] = `-- === Скрипт чекпоинта ${i} (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local ev = ReplicatedStorage:FindFirstChild("CheckpointReached") + if ev then ev:Fire(${i}) end +end)`; + } + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 33-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index bdb1036..6437176 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -4355,7 +4355,7 @@ game.self.onTouch(() => {

                                            Шаг 2. Главный скрипт

                                            Это большой скрипт — разберём его по частям.

                                            - {`// === ИГРА «ЗАЩИТА БАЗЫ» — главный скрипт === + {`// === ИГРА «ЗАЩИТА БАЗЫ» — главный скрипт === let killed = 0; // сколько врагов уничтожено let leaked = 0; // сколько врагов дошло до базы @@ -4417,7 +4417,7 @@ game.every(2, () => { } } }); -});`} +});`}

                                            Как появляются волны врагов:

                                            • game.every(2, ...) — каждые 2 секунды diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 9ed5698..3e11c13 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1979,6 +1979,19 @@ export function registerRobloxShim(lua, opts) { const text = value == null ? null : ('Очки: ' + value); send('ui.set', { id: '__score', text }); }); + // Таймер — паритет с JS game.ui.timer = seconds. Формат mm:ss. + global.set('__rbxl_timer_set', (seconds) => { + if (seconds == null) { + send('ui.set', { id: '__timer', text: null }); + return; + } + const n = Number(seconds); + if (!Number.isFinite(n)) return; + const mm = Math.floor(Math.max(0, n) / 60); + const ss = Math.floor(Math.max(0, n) % 60); + const txt = (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss; + send('ui.set', { id: '__timer', text: txt }); + }); // Двойной прыжок — паритет с JS game.player.setDoubleJump(bool). global.set('__rbxl_set_double_jump', (enabled) => { send('player.setDoubleJump', { enabled: !!enabled }); -- 2.47.2 From f3b0cabdbd9cad9abf1d134e70b18afb75f2b17f Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:18:18 +0300 Subject: [PATCH 193/214] =?UTF-8?q?docs(32)=20+=20feat(g33):=20=C2=AB?= =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC=D0=B5=D1=80=20?= =?UTF-8?q?=D1=81=20=D0=B1=D0=BE=D1=81=D1=81=D0=BE=D0=BC=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g32 docs: CodeBoth main+cp_1. g33 паритет: - Heartbeat: py<-3 → LoadCharacter + lose - При pz>24 + py>5 (на арене) — spawnNpc 'БОСС' hp=120 speed=2 - task.delay npc.follow('player') + setLabel 'БОСС HP: 120' - __rbxl_npc_on_click(bossRef, onBossHit): dist<5 → bossHp -= 20 + npc.damage + setLabel + sparks + hit - __rbxl_npc_on_death → clear_label + 'Победа!' + confetti + win --- src/community/docsGamesBuildersLua.js | 86 ++++++++++++++++++++++++++- src/community/docsLessons.jsx | 8 +-- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index e5b666c..e478991 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -2940,7 +2940,91 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 33-50: явных Lua-версий пока нет. + // ИГРА 33 — «Платформер с боссом» + // ═══════════════════════════════════════════════════════════════ + 'boss-platformer': { + g33_main: `-- === ИГРА «ПЛАТФОРМЕР С БОССОМ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer + +local won = false +local bossSpawned = false +local bossHp = 120 +local MAX_HP = 120 +local bossRef = nil + +__rbxl_show_text("Пройди паркур до арены босса!", 3) + +local hitSound = Instance.new("Sound", workspace) +hitSound.SoundId = "hit"; hitSound.Volume = 0.6 +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Клик по боссу (через __rbxl_npc_on_click при спавне) +local function onBossHit() + if won then return end + local pp_x = __rbxl_player_x() + local pp_z = __rbxl_player_z() + local bp_x = __rbxl_npc_x(bossRef) + local bp_z = __rbxl_npc_z(bossRef) + local dx = pp_x - bp_x + local dz = pp_z - bp_z + local dist = math.sqrt(dx*dx + dz*dz) + if dist < 5 then + bossHp = bossHp - 20 + if bossHp < 0 then bossHp = 0 end + __rbxl_npc_damage(bossRef, 20) + __rbxl_set_label(bossRef, "БОСС HP: " .. bossHp, "#ff3333", 2.5) + __rbxl_spawn_particles("sparks", bp_x, 2, bp_z, 0.4, 1) + hitSound:Play() + end +end + +-- Heartbeat: респаун при падении + спавн босса при подходе к арене +RunService.Heartbeat:Connect(function() + if won then return end + local py = __rbxl_player_y() + if py < -3 then + player:LoadCharacter() + loseSound:Play() + return + end + if not bossSpawned then + local pz = __rbxl_player_z() + if pz > 24 and py > 5 then + bossSpawned = true + bossRef = __rbxl_spawn_npc("character-b", 0, 7, 32, "БОСС", MAX_HP, 2) + task.delay(0.3, function() + __rbxl_npc_follow(bossRef, "player") + __rbxl_set_label(bossRef, "БОСС HP: " .. MAX_HP, "#ff3333", 2.5) + end) + __rbxl_show_text("БОСС! Кликай по нему!", 3) + -- Подписка на клик по боссу + __rbxl_npc_on_click(bossRef, onBossHit) + -- Подписка на смерть + __rbxl_npc_on_death(bossRef, function() + if won then return end + won = true + __rbxl_clear_label(bossRef) + __rbxl_show_text("Победа! Босс повержен!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py2 = __rbxl_player_y() + local pz2 = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py2 + 3, pz2, 3, 3) + end) + end + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 34-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 6437176..0172f8b 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -4509,7 +4509,7 @@ game.every(2, () => {

                                              Шаг 2. Главный скрипт

                                              - {`// === ИГРА «ГОНКА С КРУГАМИ» — главный скрипт === + {`// === ИГРА «ГОНКА С КРУГАМИ» — главный скрипт === const LAPS = 2; // сколько кругов проехать const CP_COUNT = 4; // чекпоинтов на круге @@ -4552,7 +4552,7 @@ game.onMessage('checkpoint', (d) => { game.ui.showText('Круг ' + lap + ' из ' + LAPS + '!', 2); } } -});`} +});`}

                                              Главное здесь — порядок чекпоинтов:

                                              • game.onMessage('checkpoint', (d) => {'{...}'}) — @@ -4584,10 +4584,10 @@ game.onMessage('checkpoint', (d) => { номер. Вот скрипт первого чекпоинта:

                                                - {`// === Скрипт чекпоинта 1 === + {`// === Скрипт чекпоинта 1 === game.self.onTouch(() => { game.broadcast('checkpoint', { num: 1 }); -});`} +});`}

                                                Чекпоинт шлёт сообщение game.broadcast('checkpoint', {'{ num: 1 }'}): -- 2.47.2 From 3e572e113693699e17b53de4a2c690caea5967b5 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:22:03 +0300 Subject: [PATCH 194/214] =?UTF-8?q?docs(33)=20+=20feat(g34):=20=C2=AB?= =?UTF-8?q?=D0=A1=D0=B1=D0=BE=D1=80=20=D1=83=D1=80=D0=BE=D0=B6=D0=B0=D1=8F?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g33 docs: CodeBoth g33_main. g34 паритет: - BindableEvent Harvested → score++ + coin sound, 6 → win + confetti - 6 g34_plant_N: TweenService 5с растёт (Size+Position), Completed → ripe=true + Color жёлтый - Heartbeat distance-check (3) → __rbxl_hud_set '[E] Собрать' - E: не ripe → 'Ещё не выросло!'; ripe → Harvested:Fire + Destroy --- src/community/docsGamesBuildersLua.js | 103 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 4 +- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index e478991..def8564 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3024,7 +3024,108 @@ end)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 34-50: явных Lua-версий пока нет. + // ИГРА 34 — «Сбор урожая» + // ═══════════════════════════════════════════════════════════════ + 'harvest': (function() { + const PLANT_COUNT = 6; + const overrides = { + g34_main: `-- === ИГРА «СБОР УРОЖАЯ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local GOAL = ${PLANT_COUNT} +local harvested = 0 + +__rbxl_score_set(0) +__rbxl_show_text("Дождись, пока растения вырастут, и собери!", 4) + +local coinSound = Instance.new("Sound", workspace) +coinSound.SoundId = "coin"; coinSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local ev = getEvent("Harvested") +ev.Event:Connect(function() + harvested = harvested + 1 + __rbxl_score_set(harvested) + coinSound:Play() + if harvested >= GOAL then + __rbxl_show_text("Победа! Весь урожай собран!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + end +end)`, + }; + // 6 растений — растут 5с (TweenService size+y), потом ripe, E собирает + const plantScript = `-- === Скрипт растения (Lua) === +local TweenService = game:GetService("TweenService") +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent + +local ripe = false +local picked = false +local hintVisible = false + +-- Растение растёт 5 секунд (size + y чтобы низ оставался на земле) +local goal = { + Size = Vector3.new(1.3, 2.6, 1.3), + Position = Vector3.new(part.Position.X, 2.3, part.Position.Z), +} +local tween = TweenService:Create(part, TweenInfo.new(5), goal) +tween:Play() +tween.Completed:Connect(function() + ripe = true + part.Color = Color3.fromRGB(255, 204, 51) -- спелое жёлтое +end) + +-- Подсказка [E] Собрать когда близко +RunService.Heartbeat:Connect(function() + if picked then return end + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 3 + if near ~= hintVisible then + hintVisible = near + local hid = "g34_plant_" .. part.Name .. "_hint" + if near then + __rbxl_hud_set(hid, "[E] Собрать", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set(hid, nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + if picked then return end + if not ripe then + __rbxl_show_text("Ещё не выросло! Подожди.", 1.5) + return + end + picked = true + -- Скрыть подсказку + __rbxl_hud_set("g34_plant_" .. part.Name .. "_hint", nil) + local ev = ReplicatedStorage:FindFirstChild("Harvested") + if ev then ev:Fire() end + part:Destroy() +end)`; + for (let i = 1; i <= PLANT_COUNT; i++) { + overrides['g34_plant_' + i] = plantScript; + } + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 35-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 0172f8b..9844dd9 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -4664,7 +4664,7 @@ game.self.onTouch(() => {

                                                Шаг 2. Главный скрипт

                                                - {`// === ИГРА «ПЛАТФОРМЕР С БОССОМ» — главный скрипт === + {`// === ИГРА «ПЛАТФОРМЕР С БОССОМ» — главный скрипт === let won = false; let bossSpawned = false; @@ -4720,7 +4720,7 @@ game.onTick(() => { } }); } -});`} +});`}

                                                Разберём по частям. Сначала — паркур:

                                                • game.onTick следит за падением: упал -- 2.47.2 From 92a9ef220d0c27cc3e0314460e689791a7a0f5ac Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:25:20 +0300 Subject: [PATCH 195/214] =?UTF-8?q?docs(34)=20+=20feat(g35):=20=C2=AB?= =?UTF-8?q?=D0=9F=D1=80=D1=8F=D1=82=D0=BA=D0=B8=20=D0=BE=D1=82=20NPC=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g34 docs: CodeBoth main+plant_1. g35 паритет: - SURVIVE=40c, NPC 'Искатель' speed=3 follow('player') - __rbxl_timer_set каждый кадр - dist<1.7 → LoadCharacter + 'Найден!' + lose (с throttle 2с) - time>=40 → npc.stop + 'Победа!' + confetti + win --- src/community/docsGamesBuildersLua.js | 68 ++++++++++++++++++++++++++- src/community/docsLessons.jsx | 8 ++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index def8564..f0bb33c 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3125,7 +3125,73 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 35-50: явных Lua-версий пока нет. + // ИГРА 35 — «Прятки от NPC» + // ═══════════════════════════════════════════════════════════════ + 'hide-from-npc': { + g35_main: `-- === ИГРА «ПРЯТКИ ОТ NPC» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer + +local SURVIVE = 40 +local time = 0 +local won = false +local lastCaughtTime = 0 + +__rbxl_timer_set(0) +__rbxl_show_text("Прячься за стенами 40 секунд!", 4) + +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- NPC-искатель ходит за игроком +local seekerRef = __rbxl_spawn_npc("character-b", 0, 1, 10, "Искатель", 100, 3) +task.delay(0.3, function() + __rbxl_npc_follow(seekerRef, "player") +end) + +RunService.Heartbeat:Connect(function(dt) + if won then return end + time = time + dt + __rbxl_timer_set(time) + + -- Поймал — респаун + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local ex = __rbxl_npc_x(seekerRef) + local ez = __rbxl_npc_z(seekerRef) + if not (ex == 0 and ez == 0) then + local dx = px - ex + local dz = pz - ez + local dist = math.sqrt(dx*dx + dz*dz) + if dist < 1.7 then + local now = tick() + if now - lastCaughtTime > 2 then + lastCaughtTime = now + player:LoadCharacter() + __rbxl_show_text("Найден! Прячься снова!", 1.5) + loseSound:Play() + end + end + end + + -- Продержался 40 секунд — победа + if time >= SURVIVE then + won = true + __rbxl_npc_stop(seekerRef) + __rbxl_show_text("Победа! Ты прятался 40 секунд!", 5) + winSound:Play() + __rbxl_spawn_particles("confetti", px, 1, pz, 3, 3) + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 36-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 9844dd9..6abb767 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -4811,7 +4811,7 @@ game.onTick(() => {

                                                  Шаг 2. Главный скрипт

                                                  - {`// === ИГРА «СБОР УРОЖАЯ» — главный скрипт === + {`// === ИГРА «СБОР УРОЖАЯ» — главный скрипт === let harvested = 0; const GOAL = 6; @@ -4831,7 +4831,7 @@ game.onMessage('harvested', () => { game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); } -});`} +});`}

                                                  Главный скрипт считает собранные растения и проверяет победу. Каждое растение работает в своей «песочнице», @@ -4845,7 +4845,7 @@ game.onMessage('harvested', () => {

                                                  Шаг 3. Скрипт растения

                                                  Этот скрипт вешается на каждое растение.

                                                  - {`// === Скрипт растения === + {`// === Скрипт растения === let ripe = false; // растение выросло (спелое)? let picked = false; @@ -4870,7 +4870,7 @@ game.self.onInteract(() => { picked = true; game.self.delete(); game.broadcast('harvested'); -}, { text: 'Собрать', distance: 3 });`} +}, { text: 'Собрать', distance: 3 });`}

                                                  Разберём:

                                                  • game.tween(ref, {'{ sx, sy, sz, y }'}, опции) — -- 2.47.2 From b0bdfb6e291991aae0f0b64a3e23c91bd4784e5a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:29:36 +0300 Subject: [PATCH 196/214] =?UTF-8?q?docs(35)=20+=20feat(g36):=20=C2=AB?= =?UTF-8?q?=D0=93=D0=BE=D0=BB=D0=BE=D0=B2=D0=BE=D0=BB=D0=BE=D0=BC=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=20=D1=8F=D1=89=D0=B8=D0=BA=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g35 docs: CodeBoth g35_main. g36 паритет: - onPlate[3] флаги, при всех true → win - BindableEvent BoxMoved(i, on) - g36_box_N: Heartbeat distance-check(3) → '[E] Двинуть ящик', E: cell++ (wrap), TweenService Position.Z=ROW[cell], 0.4с, Fire(i, newZ == 6) --- src/community/docsGamesBuildersLua.js | 89 ++++++++++++++++++++++++++- src/community/docsLessons.jsx | 4 +- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index f0bb33c..3248cec 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3191,7 +3191,94 @@ end)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 36-50: явных Lua-версий пока нет. + // ИГРА 36 — «Головоломка с ящиками» + // ═══════════════════════════════════════════════════════════════ + 'box-puzzle': (function() { + const BOX_COUNT = 3; + const overrides = { + g36_main: `-- === ИГРА «ГОЛОВОЛОМКА С ЯЩИКАМИ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local onPlate = { false, false, false } +local won = false + +__rbxl_show_text("Поставь все 3 ящика на зелёные плиты!", 4) + +local clickSound = Instance.new("Sound", workspace) +clickSound.SoundId = "click"; clickSound.Volume = 0.6 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Ящики шлют BoxMoved:Fire(i, on) +local boxEvent = getEvent("BoxMoved") +boxEvent.Event:Connect(function(i, on) + onPlate[i] = on + if on then clickSound:Play() end + if not won and onPlate[1] and onPlate[2] and onPlate[3] then + won = true + __rbxl_show_text("Победа! Все ящики на местах!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + end +end)`, + }; + // 3 ящика — E двигает по ряду Z=[-6,-3,0,3,6], плита на z=6 + for (let i = 1; i <= BOX_COUNT; i++) { + overrides['g36_box_' + i] = `-- === Скрипт ящика ${i} (Lua) === +local TweenService = game:GetService("TweenService") +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent + +local ROW = { -6, -3, 0, 3, 6 } +local PLATE_Z = 6 +local cell = 1 +local hintVisible = false +local moving = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 3 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g36_box_${i}_hint", "[E] Двинуть ящик", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g36_box_${i}_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if moving then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + cell = cell + 1 + if cell > #ROW then cell = 1 end + local newZ = ROW[cell] + moving = true + local goal = { Position = Vector3.new(part.Position.X, part.Position.Y, newZ) } + local tween = TweenService:Create(part, TweenInfo.new(0.4), goal) + tween:Play() + tween.Completed:Connect(function() moving = false end) + local ev = ReplicatedStorage:FindFirstChild("BoxMoved") + if ev then ev:Fire(${i}, newZ == PLATE_Z) end +end)`; + } + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 37-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 6abb767..8bef888 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -4962,7 +4962,7 @@ game.self.onInteract(() => {

                                                    Шаг 2. Главный скрипт

                                                    Вся игра — в одном скрипте. Разберём его.

                                                    - {`// === ИГРА «ПРЯТКИ ОТ NPC» — главный скрипт === + {`// === ИГРА «ПРЯТКИ ОТ NPC» — главный скрипт === let time = 0; const SURVIVE = 40; // продержись 40 секунд @@ -5001,7 +5001,7 @@ game.onTick((dt) => { game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); } -});`} +});`}

                                                    Разберём:

                                                    • game.scene.spawnNpc(...) создаёт -- 2.47.2 From f0025f0dadae8e3532982564442bd3e59cd6abcc Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:32:55 +0300 Subject: [PATCH 197/214] =?UTF-8?q?docs(36)=20+=20feat(g37):=20=C2=AB?= =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D0=BE=D1=81=D0=B0=20=D0=BF=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D1=8F=D1=82=D1=81=D1=82=D0=B2=D0=B8=D0=B9=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g36 docs: CodeBoth main+box_1. g37 паритет: - task.delay 0.2 ДвижПлатформа yoyo loop (x: -0.5 ↔ 3, 2с) - Heartbeat: py<-3 → LoadCharacter + lose - BindableEvents CheckpointReached/FinishReached - 6 g37_spike_N: Touched → damage(25) + hit sound (i-frames 0.5с) - g37_cp: Touched → CheckpointReached:Fire → setSpawn(-0.5,1,24) - g37_finish: Touched → FinishReached:Fire → win + confetti Shim: __rbxl_set_spawn(x,y,z). --- src/community/docsGamesBuildersLua.js | 126 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 8 +- src/editor/engine/lua/RobloxShim.js | 4 + 3 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 3248cec..2fcc493 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3278,7 +3278,131 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 37-50: явных Lua-версий пока нет. + // ИГРА 37 — «Полоса препятствий» + // ═══════════════════════════════════════════════════════════════ + 'obstacle-course': (function() { + const SPIKE_IDS = [1, 2, 3, 4, 5, 6]; // id 1-6 — шипы + const overrides = { + g37_main: `-- === ИГРА «ПОЛОСА ПРЕПЯТСТВИЙ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local TweenService = game:GetService("TweenService") +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local won = false + +__rbxl_show_text("Пройди полосу: шипы, ямы, платформа!", 4) + +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local pickupSound = Instance.new("Sound", workspace) +pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Движущаяся платформа: tween yoyo x: -0.5 ↔ 3, 2с +task.delay(0.2, function() + local mover = workspace:FindFirstChild("ДвижПлатформа") + if mover then + local mp = mover.Position + local startX = mp.X + local function loopMove() + local g1 = { Position = Vector3.new(3, mp.Y, mp.Z) } + local t1 = TweenService:Create(mover, TweenInfo.new(2), g1) + t1:Play() + t1.Completed:Connect(function() + local g2 = { Position = Vector3.new(startX, mp.Y, mp.Z) } + local t2 = TweenService:Create(mover, TweenInfo.new(2), g2) + t2:Play() + t2.Completed:Connect(loopMove) + end) + end + loopMove() + end +end) + +-- Респаун при падении +RunService.Heartbeat:Connect(function() + if won then return end + local py = __rbxl_player_y() + if py < -3 then + player:LoadCharacter() + loseSound:Play() + end +end) + +-- Чекпоинт — обновляем точку возрождения +local cpEvent = getEvent("CheckpointReached") +cpEvent.Event:Connect(function() + __rbxl_set_spawn(-0.5, 1, 24) + __rbxl_show_text("Чекпоинт сохранён!", 2) + pickupSound:Play() +end) + +-- Финиш +local winEvent = getEvent("FinishReached") +winEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_show_text("Победа! Полоса пройдена!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g37_cp: `-- === Скрипт чекпоинта (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("CheckpointReached") + if ev then ev:Fire() end +end)`, + g37_finish: `-- === Скрипт финиш-зоны (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("FinishReached") + if ev then ev:Fire() end +end)`, + }; + // Шипы — Touched → damage 25 + hit + const spikeScript = `-- === Скрипт шипа (Lua) === +local part = script.Parent +local lastHit = 0 +local hitSound = Instance.new("Sound", part) +hitSound.SoundId = "hit"; hitSound.Volume = 0.6 + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local now = tick() + if now - lastHit < 0.5 then return end -- i-frames + lastHit = now + __rbxl_damage_player(25) + hitSound:Play() +end)`; + for (const sid of SPIKE_IDS) { + overrides['g37_spike_' + sid] = spikeScript; + } + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 38-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 8bef888..e383220 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -5095,7 +5095,7 @@ game.onTick((dt) => { момент победы.

                                                      - {`// === ИГРА «ГОЛОВОЛОМКА С ЯЩИКАМИ» — главный скрипт === + {`// === ИГРА «ГОЛОВОЛОМКА С ЯЩИКАМИ» — главный скрипт === // для каждого ящика — на какой плите он сейчас (true/false) const onPlate = [false, false, false]; @@ -5119,7 +5119,7 @@ game.onMessage('box', (d) => { game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); } -});`} +});`}

                                                      Разберём:

                                                      • onPlate — массив из трёх «галочек»: стоит @@ -5138,7 +5138,7 @@ game.onMessage('box', (d) => { поменяй число i в сообщении на 1 и 2).

                                                        - {`// === Скрипт ящика 1 === + {`// === Скрипт ящика 1 === // ряд позиций по Z, по которым прыгает ящик const ROW = [-6, -3, 0, 3, 6]; @@ -5152,7 +5152,7 @@ game.self.onInteract(() => { game.tween(game.self.ref, { z: z }, { duration: 0.4, easing: 'ease' }); // сообщаем главному скрипту — стоит ли ящик на плите game.broadcast('box', { i: 0, on: z === PLATE_Z }); -}, { text: 'Двинуть ящик', distance: 3 });`} +}, { text: 'Двинуть ящик', distance: 3 });`}

                                                        Что происходит:

                                                        • ROW — пять клеток ряда (значения Z);
                                                        • diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 3e11c13..8fa13a5 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1996,6 +1996,10 @@ export function registerRobloxShim(lua, opts) { global.set('__rbxl_set_double_jump', (enabled) => { send('player.setDoubleJump', { enabled: !!enabled }); }); + // Точка возрождения — паритет с JS game.player.setSpawn({x,y,z}). + global.set('__rbxl_set_spawn', (x, y, z) => { + send('player.setSpawn', { x: +x || 0, y: +y || 1, z: +z || 0 }); + }); // Камера-облёт — паритет с JS game.camera.cutscene(points, opts). // pointsFlat/lookAtFlat: x1,y1,z1,x2,y2,z2,... — потому что массив // объектов в wasmoon через C-boundary неудобен. -- 2.47.2 From 0fcc5b85d04976d54ae696ea533baec25beffd44 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:35:40 +0300 Subject: [PATCH 198/214] =?UTF-8?q?docs(37)=20+=20feat(g38):=20=C2=AB?= =?UTF-8?q?=D0=9C=D1=83=D0=B7=D1=8B=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=B3=D1=80=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g37 docs: CodeBoth main+spike_1+cp+finish. g38 паритет: - SOUNDS [coin,jump,click,hit], SEQ [1,3,2,4,1] - task.delay 1 + i*0.8 → play sound + 'Нота N из 5' - После последней task.delay → canPress=true + 'Повтори!' - BindableEvent NotePressed(n) - 4 g38_tile_N: Heartbeat distance(3) → '[E] Сыграть ноту' E → tileSound + sparks + NotePressed:Fire(n) - Правильная → playerStep++, при #SEQ → win + confetti - Ошибка → playerStep=0 + lose + 'Слушай снова' --- src/community/docsGamesBuildersLua.js | 111 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 12 +-- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 2fcc493..916d94f 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3402,7 +3402,116 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 38-50: явных Lua-версий пока нет. + // ИГРА 38 — «Музыкальная игра» + // ═══════════════════════════════════════════════════════════════ + 'music-game': (function() { + const TILES = [ + { snd: 'coin', color: '#e23b3b' }, + { snd: 'jump', color: '#facc15' }, + { snd: 'click', color: '#22c55e' }, + { snd: 'hit', color: '#3b82f6' }, + ]; + const overrides = { + g38_main: `-- === ИГРА «МУЗЫКАЛЬНАЯ ИГРА» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local SOUNDS = { "coin", "jump", "click", "hit" } +local SEQ = { 1, 3, 2, 4, 1 } +local playerStep = 0 +local won = false +local canPress = false + +__rbxl_show_text("Слушай мелодию, потом повтори!", 3) + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 + +-- Проигрываем мелодию: нота за нотой каждые 0.8 сек +for i, note in ipairs(SEQ) do + task.delay(1 + (i - 1) * 0.8, function() + local s = Instance.new("Sound", workspace) + s.SoundId = SOUNDS[note]; s.Volume = 0.8 + s:Play() + __rbxl_show_text("Нота " .. i .. " из " .. #SEQ, 0.7) + task.delay(0.6, function() s:Destroy() end) + end) +end + +-- После мелодии разрешаем игроку +task.delay(1 + #SEQ * 0.8 + 0.5, function() + canPress = true + __rbxl_show_text("Теперь повтори мелодию!", 3) +end) + +-- Плитки шлют NotePressed:Fire(n) +local pressEvent = getEvent("NotePressed") +pressEvent.Event:Connect(function(n) + if won or not canPress then return end + if n == SEQ[playerStep + 1] then + playerStep = playerStep + 1 + if playerStep >= #SEQ then + won = true + __rbxl_show_text("Победа! Мелодия повторена верно!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + end + else + playerStep = 0 + __rbxl_show_text("Ошибка! Слушай и пробуй снова.", 2) + loseSound:Play() + end +end)`, + }; + // 4 плитки — E проигрывает звук + sparks + NotePressed:Fire(n) + TILES.forEach((t, idx) => { + const n = idx + 1; + overrides['g38_tile_' + n] = `-- === Скрипт ноты-плитки ${n} (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +local tileSound = Instance.new("Sound", part) +tileSound.SoundId = "${t.snd}"; tileSound.Volume = 0.8 + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 3 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g38_tile_${n}_hint", "[E] Сыграть ноту", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g38_tile_${n}_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + tileSound:Play() + __rbxl_spawn_particles("sparks", part.Position.X, part.Position.Y + 1, part.Position.Z, 0.4, 1) + local ev = ReplicatedStorage:FindFirstChild("NotePressed") + if ev then ev:Fire(${n}) end +end)`; + }); + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 39-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index e383220..e69d927 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -5243,7 +5243,7 @@ game.self.onInteract(() => {

                                                          Шаг 2. Главный скрипт

                                                          - {`// === ИГРА «ПОЛОСА ПРЕПЯТСТВИЙ» — главный скрипт === + {`// === ИГРА «ПОЛОСА ПРЕПЯТСТВИЙ» — главный скрипт === let won = false; @@ -5285,7 +5285,7 @@ game.onMessage('finish', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                                          Разберём:

                                                          • game.after(0.2, ...) — ждём чуть-чуть, @@ -5306,16 +5306,16 @@ game.onMessage('finish', () => {

                                                            Шаг 3. Скрипты шипа, чекпоинта и финиша

                                                            - {`// === Скрипт шипа === + {`// === Скрипт шипа === game.self.onTouch(() => { game.player.damage(25); game.sound.play('hit'); -});`} +});`} - {`// === Скрипт чекпоинта === + {`// === Скрипт чекпоинта === game.self.onTouch(() => { game.broadcast('checkpoint'); -});`} +});`} {`// === Скрипт финиша === game.self.onTouch(() => { -- 2.47.2 From b42685521c962008287b6b7627cc2f7c859dc628 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:41:55 +0300 Subject: [PATCH 199/214] =?UTF-8?q?docs(38)=20+=20feat(g39):=20=C2=AB?= =?UTF-8?q?=D0=91=D0=B0=D1=88=D0=BD=D1=8F=20=E2=80=94=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g38 docs: CodeBoth main+tile_1. g39 паритет: - STEPS=8, placed counter - BindableEvent BlockPlaced(n) - 8 g39_spot_N: Heartbeat distance(4) → '[E] Поставить блок' E → CanCollide=true, Transparency=0, Color коричневый, BlockPlaced:Fire(n) - Главный: n=placed+1? placed++; иначе 'Сначала поставь ниже!' - 8 → 'Победа!' + confetti --- src/community/docsGamesBuildersLua.js | 91 ++++++++++++++++++++++++++- src/community/docsLessons.jsx | 8 +-- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 916d94f..f00f2a3 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3511,7 +3511,96 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 39-50: явных Lua-версий пока нет. + // ИГРА 39 — «Башня — стройка» + // ═══════════════════════════════════════════════════════════════ + 'tower-build': (function() { + const STEPS = 8; + const overrides = { + g39_main: `-- === ИГРА «БАШНЯ — СТРОЙКА» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local STEPS = ${STEPS} +local placed = 0 + +__rbxl_score_set(0) +__rbxl_show_text("Подходи к местам и ставь блоки (E) снизу вверх", 4) + +local clickSound = Instance.new("Sound", workspace) +clickSound.SoundId = "click"; clickSound.Volume = 0.6 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Места шлют BlockPlaced:Fire(n) +local ev = getEvent("BlockPlaced") +ev.Event:Connect(function(n) + if n ~= placed + 1 then + __rbxl_show_text("Сначала поставь блок ниже!", 1.5) + return + end + placed = placed + 1 + __rbxl_score_set(placed) + clickSound:Play() + if placed >= STEPS then + __rbxl_show_text("Победа! Башня построена!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + else + __rbxl_show_text("Блок " .. placed .. " из " .. STEPS, 1.5) + end +end)`, + }; + // 8 мест-призраков — E делает блок «реальным» + for (let i = 1; i <= STEPS; i++) { + overrides['g39_spot_' + i] = `-- === Скрипт места под блок ${i} (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent + +local built = false +local hintVisible = false + +RunService.Heartbeat:Connect(function() + if built then return end + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g39_spot_${i}_hint", "[E] Поставить блок", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g39_spot_${i}_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if built then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + built = true + -- Делаем блок «реальным»: твёрдым, непрозрачным, коричневым + part.CanCollide = true + part.Transparency = 0 + part.Color = Color3.fromRGB(181, 101, 29) + __rbxl_hud_set("g39_spot_${i}_hint", nil) + local ev = ReplicatedStorage:FindFirstChild("BlockPlaced") + if ev then ev:Fire(${i}) end +end)`; + } + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 40-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index e69d927..43c7134 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -5394,7 +5394,7 @@ game.self.onTouch(() => { верно ли игрок повторяет.

                                                            - {`// === ИГРА «МУЗЫКАЛЬНАЯ ИГРА» — главный скрипт === + {`// === ИГРА «МУЗЫКАЛЬНАЯ ИГРА» — главный скрипт === const SOUNDS = ['coin', 'jump', 'click', 'hit']; // плитки 1..4 // загаданная последовательность из 5 нот @@ -5440,7 +5440,7 @@ game.onMessage('press', (d) => { game.ui.showText('Ошибка! Слушай и пробуй снова.', 2); game.sound.play('lose'); } -});`} +});`}

                                                            Разберём:

                                                            • SEQ = [1, 3, 2, 4, 1] — загаданная мелодия: @@ -5463,13 +5463,13 @@ game.onMessage('press', (d) => { для остальных поменяй звук и число в press.

                                                              - {`// === Скрипт ноты-плитки 1 === + {`// === Скрипт ноты-плитки 1 === game.self.onInteract(() => { game.sound.play('coin'); game.scene.spawnParticles('sparks', game.self.position, { duration: 0.4, color: '#e23b3b' }); game.broadcast('press', { n: 1 }); -}, { text: 'Сыграть ноту', distance: 3 });`} +}, { text: 'Сыграть ноту', distance: 3 });`}

                                                              При нажатии E плитка играет свой звук, вспыхивает искрами и шлёт -- 2.47.2 From f7b296f43b63d1ced271da4e9778f435da625969 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:43:40 +0300 Subject: [PATCH 200/214] =?UTF-8?q?fix(g39):=203D-=D0=B4=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20distance-?= =?UTF-8?q?check=20=D0=BC=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 мест-призраков стоят один над другим (x=0, z=0, y=1,3,5,...). По X+Z они в (0,0) → все 8 видят hintVisible=true одновременно → нажатие E срабатывало у всех сразу, башня строилась за раз. Фикс: 3D-дистанция (учитываем dy). --- src/community/docsGamesBuildersLua.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index f00f2a3..f775cc2 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3566,10 +3566,14 @@ local hintVisible = false RunService.Heartbeat:Connect(function() if built then return end local px = __rbxl_player_x() + local py = __rbxl_player_y() local pz = __rbxl_player_z() local dx = part.Position.X - px + local dy = part.Position.Y - py local dz = part.Position.Z - pz - local dist = math.sqrt(dx*dx + dz*dz) + -- 3D-дистанция — иначе 8 мест-призраков один над другим + -- все видят hintVisible=true (по X+Z они в (0,0,0)) + local dist = math.sqrt(dx*dx + dy*dy + dz*dz) local near = dist <= 4 if near ~= hintVisible then hintVisible = near -- 2.47.2 From cce9d2e293fc2cc9133060ee5859955e800a3752 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:46:21 +0300 Subject: [PATCH 201/214] =?UTF-8?q?docs(39)=20+=20feat(g40):=20=C2=AB?= =?UTF-8?q?=D0=92=D1=8B=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=82=20=D0=B2=D0=BE=D0=BB=D0=BD=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g39 docs: CodeBoth main+spot_1. g40 паритет: - WAVES=3, count=wave+2 (3,4,5 врагов) - Враги по кругу radius=10 (cos/sin), npc.follow('player') - BindableEvent EnemyClicked(ref) - __rbxl_npc_on_click(ref, fn) — каждый враг шлёт ref в общий event - Главный проверяет dist<5, npc.remove + explosion + aliveInWave-- - 0 живых → task.delay 2 startWave (forward-decl) - 3 волны → 'Победа!' + confetti --- src/community/docsGamesBuildersLua.js | 83 ++++++++++++++++++++++++++- src/community/docsLessons.jsx | 8 +-- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index f775cc2..5c717cd 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3604,7 +3604,88 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 40-50: явных Lua-версий пока нет. + // ИГРА 40 — «Выживание от волн» + // ═══════════════════════════════════════════════════════════════ + 'wave-survival': { + g40_main: `-- === ИГРА «ВЫЖИВАНИЕ ОТ ВОЛН» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local WAVES = 3 +local wave = 0 +local won = false + +__rbxl_show_text("Отбей 3 волны врагов! Кликай по ним", 3) + +local hitSound = Instance.new("Sound", workspace) +hitSound.SoundId = "hit"; hitSound.Volume = 0.6 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Текущие живые враги в волне: { ref → true } +local aliveInWave = 0 + +-- Каждый враг при клике шлёт EnemyClicked:Fire(ref). +-- Главный скрипт проверяет dist<5 и наносит урон. +local clickEvent = getEvent("EnemyClicked") +local enemiesRefs = {} -- ref → true (живые) +clickEvent.Event:Connect(function(localRef) + if won then return end + if not enemiesRefs[localRef] then return end + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local ex = __rbxl_npc_x(localRef) + local ez = __rbxl_npc_z(localRef) + if ex == 0 and ez == 0 then return end + local dx = px - ex + local dz = pz - ez + local dist = math.sqrt(dx*dx + dz*dz) + if dist < 5 then + enemiesRefs[localRef] = nil + __rbxl_spawn_particles("explosion", ex, 2, ez, 0.4, 1) + __rbxl_npc_remove(localRef) + hitSound:Play() + aliveInWave = aliveInWave - 1 + if aliveInWave <= 0 then + if wave >= WAVES then + won = true + __rbxl_show_text("Победа! Все волны отбиты!", 5) + winSound:Play() + __rbxl_spawn_particles("confetti", px, 3, pz, 3, 3) + else + task.delay(2, startWave) + end + end + end +end) + +function startWave() + if won then return end + wave = wave + 1 + __rbxl_show_text("Волна " .. wave .. " из " .. WAVES .. "!", 3) + hitSound:Play() + local count = wave + 2 + aliveInWave = count + for i = 1, count do + local angle = (i - 1) / count * math.pi * 2 + local ex = math.cos(angle) * 10 + local ez = math.sin(angle) * 10 + local ref = __rbxl_spawn_npc("character-b", ex, 1, ez, "Враг", 40, 2) + enemiesRefs[ref] = true + task.delay(0.3, function() + __rbxl_npc_follow(ref, "player") + end) + __rbxl_npc_on_click(ref, function() + local ev = game:GetService("ReplicatedStorage"):FindFirstChild("EnemyClicked") + if ev then ev:Fire(ref) end + end) + end +end + +task.delay(2, startWave)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 41-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 43c7134..8ce5884 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -5550,7 +5550,7 @@ game.self.onInteract(() => {

                                                              Шаг 2. Главный скрипт

                                                              - {`// === ИГРА «БАШНЯ — СТРОЙКА» — главный скрипт === + {`// === ИГРА «БАШНЯ — СТРОЙКА» — главный скрипт === const STEPS = 8; let placed = 0; // сколько блоков поставлено @@ -5580,7 +5580,7 @@ game.onMessage('place', (d) => { } else { game.ui.showText('Блок ' + placed + ' из ' + STEPS, 1.5); } -});`} +});`}

                                                              Разберём:

                                                              • placed — сколько блоков уже стоит;
                                                              • @@ -5599,7 +5599,7 @@ game.onMessage('place', (d) => { у остальных поменяй число в place.

                                                                - {`// === Скрипт места под блок 1 === + {`// === Скрипт места под блок 1 === let built = false; game.self.onInteract(() => { if (built) return; @@ -5610,7 +5610,7 @@ game.self.onInteract(() => { game.scene.setCollide(game.self.ref, true); built = true; game.broadcast('place', { n: 1 }); -}, { text: 'Поставить блок', distance: 4 });`} +}, { text: 'Поставить блок', distance: 4 });`}

                                                                Что происходит при нажатии:

                                                                • passThrough(ref, false) — сквозь блок -- 2.47.2 From e75121cb3ddb8cf409755a20b9dbbbda2a583739 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:49:14 +0300 Subject: [PATCH 202/214] =?UTF-8?q?docs(40)=20+=20feat(g41):=20=C2=AB?= =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC=D0=B5=D1=80-?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g40 docs: CodeBoth g40_main. g41 паритет: - Heartbeat py<-3 → LoadCharacter + lose - BindableEvents CoinCollected/CheckpointReached/TreasureFound - 5 g41_coin_N (id 10..14): Touched → CoinCollected:Fire + Destroy - g41_cp: Touched → CheckpointReached:Fire → setSpawn(-0.5,7,28) - g41_finish: Touched → TreasureFound:Fire → 'Победа! Сокровище и N монет!' + confetti --- src/community/docsGamesBuildersLua.js | 116 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 4 +- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 5c717cd..0dab273 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3685,7 +3685,121 @@ task.delay(2, startWave)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 41-50: явных Lua-версий пока нет. + // ИГРА 41 — «Платформер-приключение» + // ═══════════════════════════════════════════════════════════════ + 'adventure-platformer': (function() { + // Монетки — id 10..14 (после 9 платформ) + const COIN_IDS = [10, 11, 12, 13, 14]; + const overrides = { + g41_main: `-- === ИГРА «ПЛАТФОРМЕР-ПРИКЛЮЧЕНИЕ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer +local coins = 0 +local won = false + +__rbxl_score_set(0) +__rbxl_show_text("Доберись до сокровища! Собирай монетки", 4) + +local coinSound = Instance.new("Sound", workspace) +coinSound.SoundId = "coin"; coinSound.Volume = 0.7 +local pickupSound = Instance.new("Sound", workspace) +pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.7 +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Респаун при падении +RunService.Heartbeat:Connect(function() + if won then return end + local py = __rbxl_player_y() + if py < -3 then + player:LoadCharacter() + loseSound:Play() + end +end) + +-- Монетка +local coinEvent = getEvent("CoinCollected") +coinEvent.Event:Connect(function() + coins = coins + 1 + __rbxl_score_set(coins) + coinSound:Play() +end) + +-- Чекпоинт +local cpEvent = getEvent("CheckpointReached") +cpEvent.Event:Connect(function() + __rbxl_set_spawn(-0.5, 7, 28) + __rbxl_show_text("Чекпоинт! Дальше — отсюда.", 2) + pickupSound:Play() +end) + +-- Сокровище = победа +local treasureEvent = getEvent("TreasureFound") +treasureEvent.Event:Connect(function() + if won then return end + won = true + __rbxl_show_text("Победа! Сокровище и " .. coins .. " монет!", 6) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g41_cp: `-- === Скрипт чекпоинта (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("CheckpointReached") + if ev then ev:Fire() end +end)`, + g41_finish: `-- === Скрипт сокровища (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("TreasureFound") + if ev then ev:Fire() end +end)`, + }; + // 5 монеток: Touched → CoinCollected:Fire + Destroy + const coinScript = `-- === Скрипт монетки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local taken = false + +part.Touched:Connect(function(hit) + if taken then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + taken = true + local ev = ReplicatedStorage:FindFirstChild("CoinCollected") + if ev then ev:Fire() end + part:Destroy() +end)`; + for (const cid of COIN_IDS) { + overrides['g41_coin_' + cid] = coinScript; + } + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 42-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 8ce5884..87bd020 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -5691,7 +5691,7 @@ game.self.onInteract(() => { и считает врагов.

                                                                  - {`// === ИГРА «ВЫЖИВАНИЕ ОТ ВОЛН» — главный скрипт === + {`// === ИГРА «ВЫЖИВАНИЕ ОТ ВОЛН» — главный скрипт === const WAVES = 3; // всего волн let wave = 0; @@ -5748,7 +5748,7 @@ function startWave() { } } -game.after(2, startWave); // первая волна через 2 секунды`} +game.after(2, startWave); // первая волна через 2 секунды`}

                                                                  Разберём:

                                                                  • startWave() — функция одной волны. Она -- 2.47.2 From 4085fce0d32cf43f9b58e68eee8cd63c508ba636 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:52:22 +0300 Subject: [PATCH 203/214] =?UTF-8?q?docs(41)=20+=20feat(g42):=20=C2=ABRPG-?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=BD=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g41 docs: CodeBoth 4 скрипта (main+coin_10+cp+finish). g42 паритет: - 2 NPC (Староста character-a, Кузнец character-b) - stage 0→1→2→3 цепочка - BindableEvents ElderTalk/TakeAmulet/SmithTalk - g42_elder/smith: Heartbeat distance(4) + '[E] Поговорить' InputBegan E → Fire - g42_amulet: Touched → TakeAmulet:Fire + Destroy - stage 1 elder: 'Найди амулет' - stage 1 amulet: __rbxl_inventory_define/add 'amulet' - stage 2 smith: inventory_remove + 'Победа!' + confetti - Прочие говорят соответствующую реплику --- src/community/docsGamesBuildersLua.js | 146 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 16 +-- 2 files changed, 153 insertions(+), 9 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 0dab273..6538f92 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3799,7 +3799,151 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 42-50: явных Lua-версий пока нет. + // ИГРА 42 — «RPG-деревня» + // ═══════════════════════════════════════════════════════════════ + 'rpg-village': { + g42_main: `-- === ИГРА «RPG-ДЕРЕВНЯ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local stage = 0 -- 0=начало, 1=ищем амулет, 2=несём кузнецу, 3=готово +local hasAmulet = false + +__rbxl_show_text("Деревня. Поговори со старостой (E)", 4) + +local elderRef = __rbxl_spawn_npc("character-a", 1.6, 1, 2, "Староста", 100, 0) +local smithRef = __rbxl_spawn_npc("character-b", 12.6, 1, 7, "Кузнец", 100, 0) + +local pickupSound = Instance.new("Sound", workspace) +pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Староста +local elderEvent = getEvent("ElderTalk") +elderEvent.Event:Connect(function() + if stage == 0 then + stage = 1 + __rbxl_npc_say(elderRef, "Найди потерянный амулет за домом!", 4) + __rbxl_show_text("Квест: найди фиолетовый амулет", 3) + elseif stage == 1 then + __rbxl_npc_say(elderRef, "Амулет всё ещё не у тебя...", 3) + else + __rbxl_npc_say(elderRef, "Спасибо за помощь деревне!", 3) + end +end) + +-- Амулет +local amuletEvent = getEvent("TakeAmulet") +amuletEvent.Event:Connect(function() + if stage ~= 1 then return end + stage = 2 + hasAmulet = true + __rbxl_inventory_define("amulet", "Амулет", "#a855f7") + __rbxl_inventory_add("amulet", 1) + pickupSound:Play() + __rbxl_show_text("Амулет найден! Отнеси кузнецу.", 3) +end) + +-- Кузнец +local smithEvent = getEvent("SmithTalk") +smithEvent.Event:Connect(function() + if stage == 2 and hasAmulet then + stage = 3 + hasAmulet = false + __rbxl_inventory_remove("amulet", 1) + __rbxl_npc_say(smithRef, "Отличный амулет! Вот награда, герой!", 4) + __rbxl_show_text("Победа! Квест RPG-деревни выполнен!", 6) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + elseif stage == 3 then + __rbxl_npc_say(smithRef, "Доброго пути!", 3) + else + __rbxl_npc_say(smithRef, "Принеси мне амулет — поговори со старостой.", 4) + end +end)`, + g42_elder: `-- === Скрипт старосты (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g42_elder_hint", "[E] Поговорить со старостой", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g42_elder_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("ElderTalk") + if ev then ev:Fire() end +end)`, + g42_smith: `-- === Скрипт кузнеца (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g42_smith_hint", "[E] Поговорить с кузнецом", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g42_smith_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("SmithTalk") + if ev then ev:Fire() end +end)`, + g42_amulet: `-- === Скрипт амулета (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local taken = false + +part.Touched:Connect(function(hit) + if taken then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + taken = true + local ev = ReplicatedStorage:FindFirstChild("TakeAmulet") + if ev then ev:Fire() end + part:Destroy() +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 43-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 87bd020..53d053e 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -5837,7 +5837,7 @@ game.after(2, startWave); // первая волна через 2 секунд

                                                                    Шаг 2. Главный скрипт

                                                                    - {`// === ИГРА «ПЛАТФОРМЕР-ПРИКЛЮЧЕНИЕ» — главный скрипт === + {`// === ИГРА «ПЛАТФОРМЕР-ПРИКЛЮЧЕНИЕ» — главный скрипт === let coins = 0; let won = false; @@ -5879,7 +5879,7 @@ game.onMessage('treasure', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                                                    Разберём:

                                                                    • game.onMessage('coin', ...) — пришла @@ -5899,21 +5899,21 @@ game.onMessage('treasure', () => {

                                                                      Шаг 3. Скрипты монетки, чекпоинта и сокровища

                                                                      - {`// === Скрипт монетки === + {`// === Скрипт монетки === game.self.onTouch(() => { game.broadcast('coin'); game.self.delete(); -});`} +});`} - {`// === Скрипт чекпоинта === + {`// === Скрипт чекпоинта === game.self.onTouch(() => { game.broadcast('checkpoint'); -});`} +});`} - {`// === Скрипт сокровища === + {`// === Скрипт сокровища === game.self.onTouch(() => { game.broadcast('treasure'); -});`} +});`}

                                                                      Монетка при касании засчитывается и исчезает. Чекпоинт сохраняет место. Сокровище зовёт победу. -- 2.47.2 From 019068cffa95793d415f6abb0e93f5d7aecab767 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:55:54 +0300 Subject: [PATCH 204/214] =?UTF-8?q?docs(42)=20+=20feat(g43):=20=C2=AB?= =?UTF-8?q?=D0=93=D0=BE=D0=BD=D0=BA=D0=B0=20=D1=81=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D1=8F=D1=82=D1=81=D1=82=D0=B2=D0=B8=D1=8F=D0=BC=D0=B8?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g42 docs: CodeBoth 4 скрипта. g43 паритет: - Heartbeat: time += dt → timer - BindableEvents Boost/Spike/FinishReached - Boost → set_speed(1.8) + pickup + 'УСКОРЕНИЕ!', task.delay 3 → set_speed(1) - Spike → damage_player(15) + set_speed(0.5) + hit, task.delay 1.5 → 1 - Finish → 'Финиш! Время: X сек' + confetti - 3 g43_boost_N + 5 g43_spike_N: Touched throttle 1с → Fire соответствующее - g43_finish: Touched → FinishReached:Fire Shim: __rbxl_set_speed(mul) → cmd 'player.setSpeed' с полем 'mul'. --- src/community/docsGamesBuildersLua.js | 117 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 16 ++-- src/editor/engine/lua/RobloxShim.js | 4 + 3 files changed, 128 insertions(+), 9 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 6538f92..d29a593 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -3943,7 +3943,122 @@ end)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 43-50: явных Lua-версий пока нет. + // ИГРА 43 — «Гонка с препятствиями» + // ═══════════════════════════════════════════════════════════════ + 'obstacle-race': (function() { + const BOOST_IDS = [1, 2, 3]; // id 1-3 — бусты + const SPIKE_IDS = [4, 5, 6, 7, 8]; // id 4-8 — шипы + const overrides = { + g43_main: `-- === ИГРА «ГОНКА С ПРЕПЯТСТВИЯМИ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local RunService = game:GetService("RunService") +local time = 0 +local won = false + +__rbxl_timer_set(0) +__rbxl_show_text("Гонка! Синее ускоряет, шипы мешают", 4) + +local pickupSound = Instance.new("Sound", workspace) +pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.7 +local hitSound = Instance.new("Sound", workspace) +hitSound.SoundId = "hit"; hitSound.Volume = 0.6 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Таймер каждый кадр +RunService.Heartbeat:Connect(function(dt) + if won then return end + time = time + dt + __rbxl_timer_set(time) +end) + +-- Буст ускоряет на 3с +local boostEvent = getEvent("Boost") +boostEvent.Event:Connect(function() + __rbxl_set_speed(1.8) + pickupSound:Play() + __rbxl_show_text("УСКОРЕНИЕ!", 1) + task.delay(3, function() __rbxl_set_speed(1) end) +end) + +-- Шип бьёт + замедляет на 1.5с +local spikeEvent = getEvent("Spike") +spikeEvent.Event:Connect(function() + __rbxl_damage_player(15) + __rbxl_set_speed(0.5) + hitSound:Play() + task.delay(1.5, function() __rbxl_set_speed(1) end) +end) + +-- Финиш +local finishEvent = getEvent("FinishReached") +finishEvent.Event:Connect(function() + if won then return end + won = true + local t = math.floor(time * 10) / 10 + __rbxl_show_text("Финиш! Время: " .. t .. " сек", 6) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g43_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("FinishReached") + if ev then ev:Fire() end +end)`, + }; + // Бусты — Touched → Boost:Fire (throttle 1с) + const boostScript = `-- === Скрипт буста (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local lastFire = 0 + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local now = tick() + if now - lastFire < 1 then return end + lastFire = now + local ev = ReplicatedStorage:FindFirstChild("Boost") + if ev then ev:Fire() end +end)`; + for (const bid of BOOST_IDS) { + overrides['g43_boost_' + bid] = boostScript; + } + // Шипы — Touched → Spike:Fire (throttle 1с) + const spikeScript = `-- === Скрипт шипа-ловушки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local lastFire = 0 + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local now = tick() + if now - lastFire < 1 then return end + lastFire = now + local ev = ReplicatedStorage:FindFirstChild("Spike") + if ev then ev:Fire() end +end)`; + for (const sid of SPIKE_IDS) { + overrides['g43_spike_' + sid] = spikeScript; + } + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 44-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 53d053e..00d3e74 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -5986,7 +5986,7 @@ game.self.onTouch(() => {

                                                                      Шаг 2. Главный скрипт

                                                                      - {`// === ИГРА «RPG-ДЕРЕВНЯ» — главный скрипт === + {`// === ИГРА «RPG-ДЕРЕВНЯ» — главный скрипт === // этап: 0=начало, 1=ищем амулет, 2=несём кузнецу, 3=готово let stage = 0; @@ -6038,7 +6038,7 @@ game.onMessage('smithTalk', () => { } else { smith.say('Принеси мне амулет — поговори со старостой.', 4); } -});`} +});`}

                                                                      Разберём:

                                                                      • stage — переменная-этап: 0 начало, @@ -6057,21 +6057,21 @@ game.onMessage('smithTalk', () => {

                                                                        Шаг 3. Скрипты NPC и амулета

                                                                        - {`// === Скрипт старосты === + {`// === Скрипт старосты === game.self.onInteract(() => { game.broadcast('elderTalk'); -}, { text: 'Поговорить со старостой', distance: 4 });`} +}, { text: 'Поговорить со старостой', distance: 4 });`} - {`// === Скрипт кузнеца === + {`// === Скрипт кузнеца === game.self.onInteract(() => { game.broadcast('smithTalk'); -}, { text: 'Поговорить с кузнецом', distance: 4 });`} +}, { text: 'Поговорить с кузнецом', distance: 4 });`} - {`// === Скрипт амулета === + {`// === Скрипт амулета === game.self.onTouch(() => { game.broadcast('takeAmulet'); game.self.delete(); -});`} +});`} Тумба-куб — это «кнопка разговора». Сам NPC создаётся скриптом и стоит рядом с тумбой. Игрок жмёт diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 8fa13a5..02d5bd4 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -2000,6 +2000,10 @@ export function registerRobloxShim(lua, opts) { global.set('__rbxl_set_spawn', (x, y, z) => { send('player.setSpawn', { x: +x || 0, y: +y || 1, z: +z || 0 }); }); + // Множитель скорости — паритет с JS game.player.setSpeed(mul). 1=обычная. + global.set('__rbxl_set_speed', (mul) => { + send('player.setSpeed', { mul: +mul || 1 }); + }); // Камера-облёт — паритет с JS game.camera.cutscene(points, opts). // pointsFlat/lookAtFlat: x1,y1,z1,x2,y2,z2,... — потому что массив // объектов в wasmoon через C-boundary неудобен. -- 2.47.2 From dc669a51f4727bca89eac4ba46c0b04d73dcec76 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:00:37 +0300 Subject: [PATCH 205/214] =?UTF-8?q?docs(43)=20+=20feat(g44):=20=C2=ABTower?= =?UTF-8?q?=20Defense=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g43 docs: CodeBoth main+boost_1+spike_4+finish. g44 паритет: - GOAL=14, MAX_LEAK=8, killed/leaked counters - towers[] и enemies{} (ref-keyed) - BindableEvent TowerBuilt(x, z) - Heartbeat spawn: 2.2с → npc 'character-b' speed=2 hp=50 в (-0.5,1,-3), task.delay 0.3 moveTo (-0.5, 42), on_death → killed++ - Heartbeat fire: 0.8с → каждая башня бьёт ближайшего врага в r=7 (npc.damage 25 + sparks) - Heartbeat leak: 0.5с → ez>40 → npc.remove + leaked++ + lose - 4 g44_slot_N: Heartbeat distance(4) + '[E] Построить башню' E → Instance.new('Part') Cylinder жёлтый + TowerBuilt:Fire --- src/community/docsGamesBuildersLua.js | 172 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 16 +-- 2 files changed, 179 insertions(+), 9 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index d29a593..6f128a9 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -4058,7 +4058,177 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 44-50: явных Lua-версий пока нет. + // ИГРА 44 — «Tower Defense» + // ═══════════════════════════════════════════════════════════════ + 'tower-defense': (function() { + const SLOT_IDS = [1, 2, 3, 4]; + const overrides = { + g44_main: `-- === ИГРА «TOWER DEFENSE» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local RunService = game:GetService("RunService") +local MAX_LEAK = 8 +local GOAL = 14 +local leaked = 0 +local killed = 0 +local over = false + +-- Список башен ({x, z}) +local towers = {} +-- Все враги: ref → { ref, alive } +local enemies = {} + +__rbxl_score_set(0) +__rbxl_show_text("Ставь башни (E)! Не пропусти врагов", 4) + +local clickSound = Instance.new("Sound", workspace) +clickSound.SoundId = "click"; clickSound.Volume = 0.6 +local loseSound = Instance.new("Sound", workspace) +loseSound.SoundId = "lose"; loseSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Площадки шлют TowerBuilt:Fire(x, z) +local towerEvent = getEvent("TowerBuilt") +towerEvent.Event:Connect(function(x, z) + table.insert(towers, { x = x, z = z }) + clickSound:Play() + __rbxl_show_text("Башня построена!", 1.5) +end) + +-- Спавн врагов каждые 2.2с +local total = 0 +local spawnTimer = 0 +RunService.Heartbeat:Connect(function(dt) + if over then return end + if total >= GOAL + MAX_LEAK then return end + spawnTimer = spawnTimer + dt + if spawnTimer < 2.2 then return end + spawnTimer = 0 + total = total + 1 + local ref = __rbxl_spawn_npc("character-b", -0.5, 1, -3, "Враг", 50, 2) + local rec = { ref = ref, alive = true } + enemies[ref] = rec + task.delay(0.3, function() + __rbxl_npc_moveto(ref, -0.5, 42) + end) + __rbxl_npc_on_death(ref, function() + rec.alive = false + killed = killed + 1 + __rbxl_score_set(killed) + if killed >= GOAL and not over then + over = true + __rbxl_show_text("Победа! База защищена!", 5) + winSound:Play() + end + end) +end) + +-- Башни стреляют каждые 0.8с — бьют врага в радиусе 7 от любой башни +local fireTimer = 0 +RunService.Heartbeat:Connect(function(dt) + if over then return end + fireTimer = fireTimer + dt + if fireTimer < 0.8 then return end + fireTimer = 0 + for _, t in ipairs(towers) do + for _, e in pairs(enemies) do + if e.alive then + local ex = __rbxl_npc_x(e.ref) + local ez = __rbxl_npc_z(e.ref) + if not (ex == 0 and ez == 0) then + local dx = t.x - ex + local dz = t.z - ez + local dist = math.sqrt(dx*dx + dz*dz) + if dist < 7 then + __rbxl_npc_damage(e.ref, 25) + __rbxl_spawn_particles("sparks", ex, 2, ez, 0.3, 1) + break -- одна башня — один выстрел за тик + end + end + end + end + end +end) + +-- Прорыв врагов до базы (z > 40) каждые 0.5с +local leakTimer = 0 +RunService.Heartbeat:Connect(function(dt) + if over then return end + leakTimer = leakTimer + dt + if leakTimer < 0.5 then return end + leakTimer = 0 + for _, e in pairs(enemies) do + if e.alive then + local ez = __rbxl_npc_z(e.ref) + local ex = __rbxl_npc_x(e.ref) + if not (ex == 0 and ez == 0) and ez > 40 then + e.alive = false + __rbxl_npc_remove(e.ref) + leaked = leaked + 1 + loseSound:Play() + __rbxl_show_text("Враг прорвался! (" .. leaked .. "/" .. MAX_LEAK .. ")", 2) + if leaked >= MAX_LEAK and not over then + over = true + __rbxl_show_text("База разрушена! Поражение.", 5) + end + end + end + end +end)`, + }; + // 4 площадки — E ставит жёлтый цилиндр-башню сверху + for (const sid of SLOT_IDS) { + overrides['g44_slot_' + sid] = `-- === Скрипт площадки под башню (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local built = false +local hintVisible = false + +RunService.Heartbeat:Connect(function() + if built then return end + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 4 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g44_slot_${sid}_hint", "[E] Построить башню", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g44_slot_${sid}_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if built then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + built = true + __rbxl_hud_set("g44_slot_${sid}_hint", nil) + -- Создаём башню — жёлтый цилиндр над площадкой + local tower = Instance.new("Part") + tower.Shape = Enum.PartType.Cylinder + tower.Size = Vector3.new(1.5, 3, 1.5) + tower.Position = Vector3.new(part.Position.X, part.Position.Y + 2.5, part.Position.Z) + tower.Color = Color3.fromRGB(255, 204, 51) + tower.Anchored = true + tower.Parent = workspace + local ev = ReplicatedStorage:FindFirstChild("TowerBuilt") + if ev then ev:Fire(part.Position.X, part.Position.Z) end +end)`; + } + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 45-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 00d3e74..fffb30a 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -6144,7 +6144,7 @@ game.self.onTouch(() => {

                                                                        Шаг 2. Главный скрипт

                                                                        - {`// === ИГРА «ГОНКА С ПРЕПЯТСТВИЯМИ» — главный скрипт === + {`// === ИГРА «ГОНКА С ПРЕПЯТСТВИЯМИ» — главный скрипт === let time = 0; let won = false; @@ -6185,7 +6185,7 @@ game.onMessage('finish', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                                                        Разберём:

                                                                        • onTick((dt) ={'>'} ...)dt @@ -6205,20 +6205,20 @@ game.onMessage('finish', () => {

                                                                          Шаг 3. Скрипты буста, шипа и финиша

                                                                          - {`// === Скрипт буста === + {`// === Скрипт буста === game.self.onTouch(() => { game.broadcast('boost'); -});`} +});`} - {`// === Скрипт шипа-ловушки === + {`// === Скрипт шипа-ловушки === game.self.onTouch(() => { game.broadcast('spike'); -});`} +});`} - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('finish'); -});`} +});`} setSpeed — множитель скорости. 1 — обычная, 1.8 — быстро, 0.5 — медленно. После эффекта всегда -- 2.47.2 From 1c5e5fe5bbce7192b253de5ede0ff04646b9f218 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:04:21 +0300 Subject: [PATCH 206/214] =?UTF-8?q?docs(44)=20+=20feat(g45):=20=C2=AB?= =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=B5=D0=BB=D1=8F=D0=BB=D0=BA=D0=B0-=D0=B0?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g44 docs: CodeBoth main+slot_1. g45 паритет: - GOAL=15, score/over - Humanoid.Died → 'Поражение!' - BindableEvent EnemyClicked(ref) - Heartbeat spawn 1.8с: радиус=11 cos/sin → npc 'character-b' hp=30 speed=2.2 follow('player') - npc_on_click → EnemyClicked:Fire(ref) - Главный: dist<6 → npc.remove + explosion + score++ - 15 → 'Победа!' + confetti - Heartbeat damage: каждый враг dist<1.8 + last>0.7 → damage_player(10) + hit --- src/community/docsGamesBuildersLua.js | 117 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 8 +- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 6f128a9..f526c48 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -4228,7 +4228,122 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 45-50: явных Lua-версий пока нет. + // ИГРА 45 — «Стрелялка-арена» + // ═══════════════════════════════════════════════════════════════ + 'arena-shooter': { + g45_main: `-- === ИГРА «СТРЕЛЯЛКА-АРЕНА» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local player = Players.LocalPlayer + +local GOAL = 15 +local score = 0 +local over = false + +-- Враги: { ref → { ref, alive, lastDmg } } +local enemies = {} + +__rbxl_score_set(0) +__rbxl_show_text("Перебей 15 врагов! Кликай по ним", 3) + +local hitSound = Instance.new("Sound", workspace) +hitSound.SoundId = "hit"; hitSound.Volume = 0.6 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +-- Подписка на смерть игрока +task.delay(0.5, function() + local char = player.Character or player.CharacterAdded:Wait() + local h = char:WaitForChild("Humanoid", 2) + if h then + h.Died:Connect(function() + if over then return end + over = true + __rbxl_show_text("Поражение! Тебя одолели враги.", 5) + end) + end +end) + +-- Клик по врагу: EnemyClicked:Fire(ref) +local clickEvent = getEvent("EnemyClicked") +clickEvent.Event:Connect(function(localRef) + if over then return end + local e = enemies[localRef] + if not e or not e.alive then return end + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local ex = __rbxl_npc_x(localRef) + local ez = __rbxl_npc_z(localRef) + if ex == 0 and ez == 0 then return end + local dx = px - ex + local dz = pz - ez + local dist = math.sqrt(dx*dx + dz*dz) + if dist < 6 then + e.alive = false + __rbxl_npc_remove(localRef) + __rbxl_spawn_particles("explosion", ex, 2, ez, 0.4, 1) + hitSound:Play() + score = score + 1 + __rbxl_score_set(score) + if score >= GOAL and not over then + over = true + __rbxl_show_text("Победа! Арена зачищена!", 5) + winSound:Play() + __rbxl_spawn_particles("confetti", px, 3, pz, 3, 3) + end + end +end) + +-- Спавн каждые 1.8с +local spawnTimer = 0 +RunService.Heartbeat:Connect(function(dt) + if over or score >= GOAL then return end + spawnTimer = spawnTimer + dt + if spawnTimer < 1.8 then return end + spawnTimer = 0 + local angle = math.random() * math.pi * 2 + local ex = math.cos(angle) * 11 + local ez = math.sin(angle) * 11 + local ref = __rbxl_spawn_npc("character-b", ex, 1, ez, "Враг", 30, 2.2) + enemies[ref] = { ref = ref, alive = true, lastDmg = 0 } + task.delay(0.3, function() + __rbxl_npc_follow(ref, "player") + end) + __rbxl_npc_on_click(ref, function() + local ev = game:GetService("ReplicatedStorage"):FindFirstChild("EnemyClicked") + if ev then ev:Fire(ref) end + end) +end) + +-- Враги бьют игрока вблизи (каждые 0.7с) +RunService.Heartbeat:Connect(function() + if over then return end + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local now = tick() + for _, e in pairs(enemies) do + if e.alive then + local ex = __rbxl_npc_x(e.ref) + local ez = __rbxl_npc_z(e.ref) + if not (ex == 0 and ez == 0) then + local dx = px - ex + local dz = pz - ez + local dist = math.sqrt(dx*dx + dz*dz) + if dist < 1.8 and now - e.lastDmg > 0.7 then + e.lastDmg = now + __rbxl_damage_player(10) + hitSound:Play() + end + end + end + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 46-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua // (главный скрипт → показ подсказки + слушает FinishReached → // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index fffb30a..c04cc64 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -6291,7 +6291,7 @@ game.self.onTouch(() => { стрелять и проверяет, кто победил.

                                                                          - {`// === ИГРА «TOWER DEFENSE» — главный скрипт === + {`// === ИГРА «TOWER DEFENSE» — главный скрипт === let leaked = 0; // врагов прошло до базы const MAX_LEAK = 8; @@ -6374,7 +6374,7 @@ game.every(0.5, () => { } } } -});`} +});`}

                                                                          Разберём:

                                                                          • towers и enemies — два списка: @@ -6395,7 +6395,7 @@ game.every(0.5, () => {

                                                                            Шаг 3. Скрипт площадки под башню

                                                                            - {`// === Скрипт площадки под башню === + {`// === Скрипт площадки под башню === let built = false; game.self.onInteract(() => { if (built) return; @@ -6408,7 +6408,7 @@ game.self.onInteract(() => { color: '#ffcc33', }); game.broadcast('addTower', { x: pos.x, z: pos.z }); -}, { text: 'Построить башню', distance: 4 });`} +}, { text: 'Построить башню', distance: 4 });`}

                                                                            При нажатии E скрипт создаёт жёлтый цилиндр-башню над площадкой и шлёт сообщение -- 2.47.2 From d758fdfbe6d92d89d6cb5313ad5493296eb3034e Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:07:25 +0300 Subject: [PATCH 207/214] =?UTF-8?q?docs(45)=20+=20feat(g46):=20=C2=AB?= =?UTF-8?q?=D0=9A=D0=BB=D0=B8=D0=BA=D0=B5=D1=80=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g45 docs: CodeBoth g45_main. g46 паритет (заменил simpleClicker fallback): - GOAL=200, points/perClick/autoIncome - Heartbeat: каждую секунду points += autoIncome → checkWin - BindableEvents CubeClicked/BuyPower/BuyAuto - g46_cube: ClickDetector → CubeClicked:Fire + sparks - g46_up1/up2: Heartbeat distance(3) + '[E] Купить ...' + InputBegan E - buyPower: -20 + perClick+=2 - buyAuto: -40 + autoIncome+=3 --- src/community/docsGamesBuildersLua.js | 161 +++++++++++++++++++++++++- src/community/docsLessons.jsx | 4 +- 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index f526c48..72de679 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -4350,7 +4350,166 @@ end)`, // остальные target-скрипты → красят примитив на касание). // Это даёт «хоть что-то рабочее» в любой игре до того как напишем // полноценный Lua-скрипт. Когда дописываем игру — добавляем сюда явный override. - 'clicker': { g46_main: simpleClicker() }, + 'clicker': { + g46_main: `-- === ИГРА «КЛИКЕР» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local RunService = game:GetService("RunService") +local GOAL = 200 +local points = 0 +local perClick = 1 +local autoIncome = 0 +local won = false + +__rbxl_score_set(0) +__rbxl_show_text("Кликай по жёлтому кубу! Цель: 200 очков", 4) + +local clickSound = Instance.new("Sound", workspace) +clickSound.SoundId = "click"; clickSound.Volume = 0.6 +local pickupSound = Instance.new("Sound", workspace) +pickupSound.SoundId = "pickup"; pickupSound.Volume = 0.7 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local function checkWin() + if not won and points >= GOAL then + won = true + __rbxl_show_text("Победа! Накоплено 200 очков!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) + end +end + +-- Авто-доход каждую секунду +local autoTimer = 0 +RunService.Heartbeat:Connect(function(dt) + if won then return end + autoTimer = autoTimer + dt + if autoTimer < 1 then return end + autoTimer = 0 + if autoIncome > 0 then + points = points + autoIncome + __rbxl_score_set(points) + checkWin() + end +end) + +-- Клик по кубу +local clickEvent = getEvent("CubeClicked") +clickEvent.Event:Connect(function() + if won then return end + points = points + perClick + __rbxl_score_set(points) + clickSound:Play() + checkWin() +end) + +-- Покупка силы клика (20) +local powerEvent = getEvent("BuyPower") +powerEvent.Event:Connect(function() + if points < 20 then + __rbxl_show_text("Нужно 20 очков для улучшения!", 1.5) + return + end + points = points - 20 + perClick = perClick + 2 + __rbxl_score_set(points) + pickupSound:Play() + __rbxl_show_text("Сила клика: +" .. perClick .. " за клик", 2) +end) + +-- Покупка авто-дохода (40) +local autoEvent = getEvent("BuyAuto") +autoEvent.Event:Connect(function() + if points < 40 then + __rbxl_show_text("Нужно 40 очков для авто-дохода!", 1.5) + return + end + points = points - 40 + autoIncome = autoIncome + 3 + __rbxl_score_set(points) + pickupSound:Play() + __rbxl_show_text("Авто-доход: +" .. autoIncome .. " в секунду", 2) +end)`, + g46_cube: `-- === Скрипт куба-кликера (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent + +-- ClickDetector делает куб кликабельным +local cd = Instance.new("ClickDetector") +cd.Parent = part + +cd.MouseClick:Connect(function() + __rbxl_spawn_particles("sparks", part.Position.X, part.Position.Y + 1, part.Position.Z, 0.3, 1) + local ev = ReplicatedStorage:FindFirstChild("CubeClicked") + if ev then ev:Fire() end +end)`, + g46_up1: `-- === Скрипт улучшения «сила клика» (20 очков) (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 3 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g46_up1_hint", "[E] Купить +силу клика (20)", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g46_up1_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("BuyPower") + if ev then ev:Fire() end +end)`, + g46_up2: `-- === Скрипт улучшения «авто-доход» (40 очков) (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local hintVisible = false + +RunService.Heartbeat:Connect(function() + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 3 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g46_up2_hint", "[E] Купить авто-доход (40)", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g46_up2_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + local ev = ReplicatedStorage:FindFirstChild("BuyAuto") + if ev then ev:Fire() end +end)`, + }, }; // ══════════════════════════════════════════════════════════════════ diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index c04cc64..2bed13c 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -6515,7 +6515,7 @@ game.scene.spawn('user:3', {

                                                                            Шаг 2. Главный скрипт

                                                                            - {`// === ИГРА «СТРЕЛЯЛКА-АРЕНА» — главный скрипт === + {`// === ИГРА «СТРЕЛЯЛКА-АРЕНА» — главный скрипт === let score = 0; const GOAL = 15; @@ -6577,7 +6577,7 @@ game.every(1.8, () => { } } }); -});`} +});`}

                                                                            Разберём:

                                                                            • game.onHpChange((e) ={'>'} ...) — -- 2.47.2 From 57c57796442d682dad975926d5c6ae7051056123 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:12:28 +0300 Subject: [PATCH 208/214] =?UTF-8?q?docs(46)=20+=20feat(g47):=20=C2=AB?= =?UTF-8?q?=D0=9A=D0=B2=D0=B5=D1=81=D1=82-=D0=BF=D0=BE=D0=B1=D0=B5=D0=B3?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g46 docs: CodeBoth 4 скрипта (main+cube+up1+up2). g47 паритет: - TOTAL=3, pressed/escaped counters - BindableEvents ButtonPressed/Escape - 3 g47_btn_N: Heartbeat distance(3) + '[E] Нажать кнопку' E → used=true + Color зелёный + ButtonPressed:Fire - При pressed>=3: tween двери Position.Y+6 + CanCollide=false + win sound - g47_finish: Touched → Escape:Fire → 'Победа! Сбежал!' + confetti --- src/community/docsGamesBuildersLua.js | 115 ++++++++++++++++++++++++-- src/community/docsLessons.jsx | 16 ++-- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 72de679..6e238e7 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -4343,13 +4343,114 @@ end)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 46-50: явных Lua-версий пока нет. - // buildGameProject в docsGamesBuilders.js использует generateFallbackLua - // (главный скрипт → показ подсказки + слушает FinishReached → - // победа+конфетти; скрипт на финиш-примитиве → шлёт FinishReached; - // остальные target-скрипты → красят примитив на касание). - // Это даёт «хоть что-то рабочее» в любой игре до того как напишем - // полноценный Lua-скрипт. Когда дописываем игру — добавляем сюда явный override. + // ИГРА 47 — «Квест-побег» + // ═══════════════════════════════════════════════════════════════ + 'escape-quest': (function() { + const BTN_COUNT = 3; + const overrides = { + g47_main: `-- === ИГРА «КВЕСТ-ПОБЕГ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local TweenService = game:GetService("TweenService") +local TOTAL = ${BTN_COUNT} +local pressed = 0 +local escaped = false + +__rbxl_show_text("Найди и нажми 3 кнопки, чтобы выйти!", 4) + +local clickSound = Instance.new("Sound", workspace) +clickSound.SoundId = "click"; clickSound.Volume = 0.6 +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local btnEvent = getEvent("ButtonPressed") +btnEvent.Event:Connect(function() + pressed = pressed + 1 + clickSound:Play() + __rbxl_show_text("Кнопка " .. pressed .. " из " .. TOTAL, 1.5) + if pressed >= TOTAL then + local door = workspace:FindFirstChild("Дверь") + if door then + local dp = door.Position + local goal = { Position = Vector3.new(dp.X, dp.Y + 6, dp.Z) } + TweenService:Create(door, TweenInfo.new(1.2), goal):Play() + door.CanCollide = false + end + __rbxl_show_text("Все кнопки нажаты! Дверь открыта!", 3) + winSound:Play() + end +end) + +local escEvent = getEvent("Escape") +escEvent.Event:Connect(function() + if escaped then return end + escaped = true + __rbxl_show_text("Победа! Ты сбежал из комнаты!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g47_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("Escape") + if ev then ev:Fire() end +end)`, + }; + for (let i = 1; i <= BTN_COUNT; i++) { + overrides['g47_btn_' + i] = `-- === Скрипт кнопки ${i} (Lua) === +local UserInputService = game:GetService("UserInputService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local part = script.Parent +local used = false +local hintVisible = false + +RunService.Heartbeat:Connect(function() + if used then return end + local px = __rbxl_player_x() + local pz = __rbxl_player_z() + local dx = part.Position.X - px + local dz = part.Position.Z - pz + local dist = math.sqrt(dx*dx + dz*dz) + local near = dist <= 3 + if near ~= hintVisible then + hintVisible = near + if near then + __rbxl_hud_set("g47_btn_${i}_hint", "[E] Нажать кнопку", 50, 75, "#ffe44a", 20) + else + __rbxl_hud_set("g47_btn_${i}_hint", nil) + end + end +end) + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if not hintVisible then return end + if used then return end + if input.KeyCode ~= Enum.KeyCode.E then return end + used = true + __rbxl_hud_set("g47_btn_${i}_hint", nil) + part.Color = Color3.fromRGB(34, 221, 85) + local ev = ReplicatedStorage:FindFirstChild("ButtonPressed") + if ev then ev:Fire() end +end)`; + } + return overrides; + })(), + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 48-50: явных Lua-версий пока нет. + // buildGameProject в docsGamesBuilders.js использует generateFallbackLua. 'clicker': { g46_main: `-- === ИГРА «КЛИКЕР» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 2bed13c..1450e1f 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -6660,7 +6660,7 @@ game.every(1.8, () => {

                                                                              Шаг 2. Главный скрипт

                                                                              - {`// === ИГРА «КЛИКЕР» — главный скрипт === + {`// === ИГРА «КЛИКЕР» — главный скрипт === let points = 0; // очки let perClick = 1; // очков за клик @@ -6725,7 +6725,7 @@ game.onMessage('buyAuto', () => { game.ui.score = points; game.sound.play('pickup'); game.ui.showText('Авто-доход: +' + autoIncome + ' в секунду', 2); -});`} +});`}

                                                                              Разберём:

                                                                              • points — очки, perClick — @@ -6745,22 +6745,22 @@ game.onMessage('buyAuto', () => {

                                                                                Шаг 3. Скрипты куба и кнопок

                                                                                - {`// === Скрипт куба-кликера === + {`// === Скрипт куба-кликера === game.self.onClick(() => { game.broadcast('click'); // куб слегка вспыхивает game.scene.spawnParticles('sparks', game.self.position, { duration: 0.3 }); -});`} +});`} - {`// === Скрипт улучшения «сила клика» (20 очков) === + {`// === Скрипт улучшения «сила клика» (20 очков) === game.self.onInteract(() => { game.broadcast('buyPower'); -}, { text: 'Купить +силу клика (20)', distance: 3 });`} +}, { text: 'Купить +силу клика (20)', distance: 3 });`} - {`// === Скрипт улучшения «авто-доход» (40 очков) === + {`// === Скрипт улучшения «авто-доход» (40 очков) === game.self.onInteract(() => { game.broadcast('buyAuto'); -}, { text: 'Купить авто-доход (40)', distance: 3 });`} +}, { text: 'Купить авто-доход (40)', distance: 3 });`} Главная идея кликера: сначала кликаешь руками, потом покупаешь улучшения — и игра «играет сама». Это экономика: -- 2.47.2 From e78f585fd4106b417da6340739538955c68bd7d8 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:15:11 +0300 Subject: [PATCH 209/214] =?UTF-8?q?docs(47)=20+=20feat(g48):=20=C2=AB?= =?UTF-8?q?=D0=9C=D1=83=D0=BB=D1=8C=D1=82=D0=B8=D0=BF=D0=BB=D0=B5=D0=B5?= =?UTF-8?q?=D1=80:=20=D0=A1=D0=B0=D0=BB=D0=BA=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g47 docs: CodeBoth main+btn_1+finish. g48 паритет (упрощённый — без MP-API): - __rbxl_show_text 'Опубликуй игру для игры с друзьями' - __rbxl_hud_set 'info' 50,8 'Игроков в комнате: N' (Players:GetPlayers) - Players.PlayerAdded → '<имя> присоединился!' + refresh - Players.PlayerRemoving → refresh - task.delay 2 показывает правила - В одиночке роли не назначаются (нет game.room API в shim) --- src/community/docsGamesBuildersLua.js | 37 ++++++++++++++++++++++++++- src/community/docsLessons.jsx | 12 ++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 6e238e7..7c94e26 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -4449,7 +4449,42 @@ end)`; })(), // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 48-50: явных Lua-версий пока нет. + // ИГРА 48 — «Мультиплеер: Салки» + // ═══════════════════════════════════════════════════════════════ + 'mp-tag': { + g48_main: `-- === ИГРА «МУЛЬТИПЛЕЕР: САЛКИ» — главный скрипт (Lua) === +-- Это МУЛЬТИПЛЕЕРНАЯ игра. Чтобы играть с друзьями, опубликуй её +-- с галочкой «Мультиплеер» — тогда в комнату смогут зайти несколько +-- игроков. В одиночку игра показывает только правила. + +local Players = game:GetService("Players") + +__rbxl_show_text("Салки! Опубликуй игру для игры с друзьями", 4) + +-- Показываем сколько игроков в комнате (постоянная плашка вверху) +local function refresh() + local n = #Players:GetPlayers() + __rbxl_hud_set("info", "Игроков в комнате: " .. n, 50, 8, "#ffe066", 22) +end +refresh() + +-- Подписки на вход/выход +Players.PlayerAdded:Connect(function(p) + __rbxl_show_text(p.Name .. " присоединился к салкам!", 2) + refresh() +end) +Players.PlayerRemoving:Connect(function() + refresh() +end) + +-- В одиночке роли не назначаются — показываем правила +task.delay(2, function() + __rbxl_show_text("Водящий — первый зашедший. Он догоняет остальных.", 4) +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРЫ 49-50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua. 'clicker': { g46_main: `-- === ИГРА «КЛИКЕР» — главный скрипт (Lua) === diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 1450e1f..45fc99c 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -6830,7 +6830,7 @@ game.self.onInteract(() => {

                                                                                Шаг 2. Главный скрипт

                                                                                - {`// === ИГРА «КВЕСТ-ПОБЕГ» — главный скрипт === + {`// === ИГРА «КВЕСТ-ПОБЕГ» — главный скрипт === let pressed = 0; // сколько кнопок нажато const TOTAL = 3; @@ -6862,7 +6862,7 @@ game.onMessage('escape', () => { const p = game.player.position; game.scene.spawnParticles('confetti', { x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 }); -});`} +});`}

                                                                                Разберём:

                                                                                • pressed — счётчик нажатых кнопок, @@ -6878,19 +6878,19 @@ game.onMessage('escape', () => {

                                                                                  Шаг 3. Скрипты кнопки и финиша

                                                                                  - {`// === Скрипт кнопки 1 === + {`// === Скрипт кнопки 1 === let used = false; game.self.onInteract(() => { if (used) return; used = true; game.scene.setColor(game.self.ref, '#22dd55'); // нажата — зелёная game.broadcast('pressButton'); -}, { text: 'Нажать кнопку', distance: 3 });`} +}, { text: 'Нажать кнопку', distance: 3 });`} - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('escape'); -});`} +});`}

                                                                                  Кнопка при нажатии становится зелёной (видно, что нажата), шлёт game.broadcast('pressButton') и больше -- 2.47.2 From 3a95cd148a570af1aef74307df21481d8b7f5902 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:18:39 +0300 Subject: [PATCH 210/214] =?UTF-8?q?docs(48)=20+=20feat(g49):=20=C2=AB?= =?UTF-8?q?=D0=9C=D1=83=D0=BB=D1=8C=D1=82=D0=B8=D0=BF=D0=BB=D0=B5=D0=B5?= =?UTF-8?q?=D1=80:=20=D0=93=D0=BE=D0=BD=D0=BA=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g48 docs: CodeBoth g48_main. g49 паритет (упрощённый без MP-API): - showText 'Гонка! Беги к финишу первым' - hud_set 'info' 'Игроков: N | Победил: X' (Players:GetPlayers count + LocalPlayer name) - Players.PlayerAdded/Removing → refresh - BindableEvent FinishReached - g49_finish: Touched → FinishReached:Fire (fired-флаг) - При победе: winnerName = LocalPlayer.Name + refresh + 'Победа!' + confetti --- src/community/docsGamesBuildersLua.js | 62 ++++++++++++++++++++++++++- src/community/docsLessons.jsx | 4 +- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 7c94e26..0b76eef 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -4484,7 +4484,67 @@ end)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРЫ 49-50: явных Lua-версий пока нет. + // ИГРА 49 — «Мультиплеер: Гонка» + // ═══════════════════════════════════════════════════════════════ + 'mp-race': { + g49_main: `-- === ИГРА «МУЛЬТИПЛЕЕР: ГОНКА» — главный скрипт (Lua) === +-- Мультиплеерная гонка. Чтобы соревноваться с друзьями — опубликуй +-- игру с галочкой «Мультиплеер». +${SNIPPET_BROADCAST} + +local Players = game:GetService("Players") +local winnerName = nil +local won = false + +__rbxl_show_text("Гонка! Беги к финишу первым", 3) + +local winSound = Instance.new("Sound", workspace) +winSound.SoundId = "win"; winSound.Volume = 1 + +local function refresh() + local n = #Players:GetPlayers() + local txt = "Игроков: " .. n + if winnerName then txt = txt .. " | Победил: " .. winnerName end + __rbxl_hud_set("info", txt, 50, 8, "#ffe066", 22) +end +refresh() + +Players.PlayerAdded:Connect(refresh) +Players.PlayerRemoving:Connect(refresh) + +-- Финиш +local finEvent = getEvent("FinishReached") +finEvent.Event:Connect(function() + if won then return end + won = true + -- В одиночке — мы и есть первый + local me = Players.LocalPlayer + winnerName = me and me.Name or "Игрок" + refresh() + __rbxl_show_text("Ты пришёл первым! Победа!", 5) + winSound:Play() + local px = __rbxl_player_x() + local py = __rbxl_player_y() + local pz = __rbxl_player_z() + __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3) +end)`, + g49_finish: `-- === Скрипт финиша (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +local fired = false + +part.Touched:Connect(function(hit) + if fired then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fired = true + local ev = ReplicatedStorage:FindFirstChild("FinishReached") + if ev then ev:Fire() end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 50: явных Lua-версий пока нет. // buildGameProject в docsGamesBuilders.js использует generateFallbackLua. 'clicker': { g46_main: `-- === ИГРА «КЛИКЕР» — главный скрипт (Lua) === diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 45fc99c..36c6bc7 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -6969,7 +6969,7 @@ game.self.onTouch(() => {

                                                                                  Шаг 2. Главный скрипт

                                                                                  - {`// === ИГРА «МУЛЬТИПЛЕЕР: САЛКИ» — главный скрипт === + {`// === ИГРА «МУЛЬТИПЛЕЕР: САЛКИ» — главный скрипт === // // Это МУЛЬТИПЛЕЕРНАЯ игра. Чтобы играть с друзьями, опубликуй её // с галочкой «Мультиплеер» — тогда в комнату смогут зайти несколько @@ -7006,7 +7006,7 @@ game.room.onChange('tagger', (taggerId) => { } else { game.ui.showText('Убегай от водящего!', 3); } -});`} +});`}

                                                                                  Разберём:

                                                                                  • game.players.count() — сколько игроков -- 2.47.2 From ddeb8ff93fac392022c9c77360d14474c085024f Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:21:39 +0300 Subject: [PATCH 211/214] =?UTF-8?q?docs(49)=20+=20feat(g50):=20=C2=AB?= =?UTF-8?q?=D0=A1=D0=B2=D0=BE=D1=8F=20=D0=B8=D0=B3=D1=80=D0=B0=C2=BB=20(?= =?UTF-8?q?=D0=BF=D0=B5=D1=81=D0=BE=D1=87=D0=BD=D0=B8=D1=86=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g49 docs: CodeBoth main+finish. g50 паритет: - Комментарии-инструкция как начать свою игру (5 шагов) - __rbxl_show_text 'Твоя песочница! Создай свою игру' --- src/community/docsGamesBuildersLua.js | 22 ++++++++++++++++++++-- src/community/docsLessons.jsx | 8 ++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js index 0b76eef..504b55b 100644 --- a/src/community/docsGamesBuildersLua.js +++ b/src/community/docsGamesBuildersLua.js @@ -4544,8 +4544,26 @@ end)`, }, // ═══════════════════════════════════════════════════════════════ - // ИГРА 50: явных Lua-версий пока нет. - // buildGameProject в docsGamesBuilders.js использует generateFallbackLua. + // ИГРА 50 — «Своя игра» (песочница) + // ═══════════════════════════════════════════════════════════════ + 'make-your-own': { + g50_main: `-- === «СВОЯ ИГРА» — твоя песочница (Lua) === +-- +-- Это пустая площадка. Здесь ты придумываешь и собираешь +-- СВОЮ игру с нуля. Удали этот текст и пиши свой код. +-- +-- С чего начать: +-- 1. Реши, КАКАЯ это игра (паркур / гонка / стрелялка / квест). +-- 2. Построй сцену из блоков и примитивов. +-- 3. Поставь точку спавна. +-- 4. Добавь цель — финиш, счёт или врагов. +-- 5. Напиши скрипты, оживляющие игру. +-- +-- Всё, что нужно, ты уже знаешь из уроков 1-49. Удачи! + +__rbxl_show_text("Твоя песочница! Создай свою игру", 4)`, + }, + 'clicker': { g46_main: `-- === ИГРА «КЛИКЕР» — главный скрипт (Lua) === ${SNIPPET_BROADCAST} diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 36c6bc7..bcc9998 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -7097,7 +7097,7 @@ game.room.onChange('tagger', (taggerId) => {

                                                                                    Шаг 2. Главный скрипт

                                                                                    - {`// === ИГРА «МУЛЬТИПЛЕЕР: ГОНКА» — главный скрипт === + {`// === ИГРА «МУЛЬТИПЛЕЕР: ГОНКА» — главный скрипт === // // Мультиплеерная гонка. Чтобы соревноваться с друзьями — опубликуй // игру с галочкой «Мультиплеер». @@ -7137,7 +7137,7 @@ game.onMessage('finish', () => { } else { game.ui.showText('Финиш! Но кто-то был быстрее.', 4); } -});`} +});`}

                                                                                    Разберём:

                                                                                    • game.room.get('winner') — читаем общую @@ -7157,10 +7157,10 @@ game.onMessage('finish', () => {

                                                                                      Шаг 3. Скрипт финиша

                                                                                      - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('finish'); -});`} +});`}

                                                                                      Когда любой игрок касается финиша, скрипт шлёт сообщение game.broadcast('finish') — а главный скрипт -- 2.47.2 From 61026a1df019d9c9ce749692807aeb0645feb2de Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:29:41 +0300 Subject: [PATCH 212/214] =?UTF-8?q?feat(docs):=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=20=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D1=82=20JS/Lua=20?= =?UTF-8?q?=D0=B8=D0=BD=D0=BB=D0=B0=D0=B9=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В уроке 50 (и можно дальше) в тексте было много game.ui.showText, game.broadcast и т.д. — это JS-API. В Lua-вкладке вики они оставались JS — путало юзера. Фикс: - Новый компонент использует useDocsLang(). Если lang='lua' и lua задан — показывает Lua-эквивалент. - В уроке 50 заменил все инлайны game.* на с Lua-параллелями: - game.ui.showText → __rbxl_show_text - game.sound.play → winSound:Play() - game.onMessage/broadcast → BindableEvent:Connect/Fire - game.self.onTouch/onInteract/onClick → Touched/UIS+Heartbeat/ClickDetector - game.onTick → RunService.Heartbeat - game.after/every → task.delay/spawn - game.tween → TweenService:Create - game.scene.spawnNpc → __rbxl_spawn_npc - game.ui.score → __rbxl_score_set --- src/community/docsLessons.jsx | 71 ++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index bcc9998..680450a 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { Code, ScriptKind, Step, Note, Try, Shot } from './docsData'; -import { LangTabs } from './docsLang'; +import { LangTabs, useDocsLang } from './docsLang'; import { LUA_OVERRIDES } from './docsGamesBuildersLua'; /** @@ -20,6 +20,17 @@ function CodeBoth({ game, script, children }) { ); } +/** + * Инлайн-API-имена в тексте уроков, меняющиеся в зависимости от JS/Lua вкладки. + * + * Если lua не задан — показывает js в обоих режимах. + */ +function Api({ js, lua }) { + const { lang } = useDocsLang(); + const txt = lang === 'lua' && lua ? lua : js; + return {txt}; +} + /** * docsLessons.jsx — тексты уроков для 50 мини-игр (раздел K вики). * @@ -7270,8 +7281,10 @@ game.self.onTouch(() => {

                                                                                    И обязательно покажи игроку, когда он победил — - надписью game.ui.showText('Победа!', 5), - звуком game.sound.play('win') и конфетти. + надписью , + звуком и конфетти.

                                                                                    Шаг 4. Напиши скрипты

                                                                                    @@ -7279,34 +7292,48 @@ game.self.onTouch(() => { Сцена сама по себе не «живая» — её оживляют скрипты. Начинай с главного скрипта: в нём заводи переменные (счёт, флажок победы) и лови сообщения через - game.onMessage('имя', fn). На объекты вешай - небольшие скрипты — они шлют сообщения главному через - game.broadcast('имя'). Так главный скрипт - узнаёт, что монетку собрали или кнопку нажали. Ты делал - так в каждом уроке. + <> . + На объекты вешай небольшие скрипты — они шлют сообщения + главному через . + Так главный скрипт узнаёт, что монетку собрали или кнопку + нажали. Ты делал так в каждом уроке.

                                                                                    Каждый скрипт работает в своей «песочнице» — переменные одного скрипта не видны другому. Поэтому скрипты общаются - сообщениями: один шлёт game.broadcast('имя'), - другой ловит game.onMessage('имя', fn). Можно - передать данные: game.broadcast('имя', {'{'} ... {'}'}). + сообщениями: один шлёт , + другой ловит . + Можно передать данные: .

                                                                                    Базовый набор инструментов, который ты знаешь:

                                                                                      -
                                                                                    • game.self.onTouch — реакция на касание;
                                                                                    • -
                                                                                    • game.self.onInteract — реакция на +
                                                                                    • — реакция на касание;
                                                                                    • +
                                                                                    • — реакция на E;
                                                                                    • -
                                                                                    • game.self.onClick — реакция на клик;
                                                                                    • -
                                                                                    • game.broadcast и game.onMessage +
                                                                                    • — реакция на клик;
                                                                                    • +
                                                                                    • и + <> — связь между скриптами;
                                                                                    • -
                                                                                    • game.onTick — каждый кадр;
                                                                                    • -
                                                                                    • game.after и game.every — - таймеры;
                                                                                    • -
                                                                                    • game.tween — плавное движение;
                                                                                    • -
                                                                                    • game.scene.spawnNpc — враги и NPC;
                                                                                    • -
                                                                                    • game.ui.score и - game.ui.showText — счёт и подсказки.
                                                                                    • +
                                                                                    • — каждый кадр;
                                                                                    • +
                                                                                    • — таймеры;
                                                                                    • +
                                                                                    • — плавное движение;
                                                                                    • +
                                                                                    • — враги и NPC;
                                                                                    • +
                                                                                    • и + <> — счёт и подсказки.

                                                                                    Шаг 5. Проверяй и улучшай

                                                                                    -- 2.47.2 From eedac4379dc4eedfeaf2da6d8219abab45d4ca05 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:39:08 +0300 Subject: [PATCH 213/214] =?UTF-8?q?feat:=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20Roblox=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5=D1=85=20+=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=20=D0=B2=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KubikonStudio.jsx: убрана проверка getCurrentUserId()===1 на кнопке '📦 Импорт Roblox'. Теперь доступно всем юзерам. RbxlImportModal.jsx: убран гейт ALLOWED_USER_ID и заглушка 'доступна только администратору'. API.js: убран комментарий 'только для МИНа' у RBXL_addres. docsData.jsx: новый раздел вики 'rbxl-import' (icon: package, title: 'Импорт из Roblox') с 5 статьями: - I1. Что это и зачем - I2. Как импортировать карту (шаги 1-4) - I3. Графика хорошо, скрипты — осторожно (что переносится хорошо/так себе/не переносится) - I4. Правильный порядок: сначала графика, потом скрипты (Проход 1: импорт со скриптами 'Отключены' → смотрим графику, Проход 2: включаем скрипты по одному в редакторе) - I5. Советы и частые проблемы (пустая карта, серые текстуры, тормоза, DataStoreService, провал под пол + что делать после импорта) Раздел вставлен перед 'collab' (Совместное редактирование). --- src/api/API.js | 2 +- src/community/KubikonStudio.jsx | 22 +- src/community/docsData.jsx | 344 +++++++++++++++++++++++++++++ src/components/RbxlImportModal.jsx | 16 +- 4 files changed, 356 insertions(+), 28 deletions(-) diff --git a/src/api/API.js b/src/api/API.js index 8fa4139..3e27ac8 100644 --- a/src/api/API.js +++ b/src/api/API.js @@ -10,7 +10,7 @@ export const USER_addres = BASE + '/api-user'; export const ACHIVES_addres = BASE + '/api-achievs'; export const COMMENTS_addres = BASE + '/api-comments'; export const STORYS_addres = BASE + '/api-storys'; -// rbxl-importer: только для МИНа (тест-фича импорта .rbxl карт Roblox) +// rbxl-importer: импорт .rbxl карт Roblox (см. вики «Импорт из Roblox») export const RBXL_addres = BASE + '/api-rbxl'; export const NOTICES_addres = BASE + '/api-notices'; export const HELP_addres = BASE + '/api-help'; diff --git a/src/community/KubikonStudio.jsx b/src/community/KubikonStudio.jsx index 65e4d90..993b36f 100644 --- a/src/community/KubikonStudio.jsx +++ b/src/community/KubikonStudio.jsx @@ -390,18 +390,16 @@ const KubikonStudio = () => { ВИКИ - {/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */} - {getCurrentUserId() === 1 && ( - - )} + {/* Импорт Roblox .rbxl — доступно всем */} + } ], }, + // ════════════════════════════════════════════════════ + // РАЗДЕЛ — ИМПОРТ ИЗ ROBLOX (.rbxl) + // ════════════════════════════════════════════════════ + { + id: 'rbxl-import', + icon: 'package', + title: 'Импорт из Roblox', + summary: 'Загрузи .rbxl-файл готовой Roblox-карты в Рублокс: геометрия, цвета и материалы переносятся хорошо, скрипты лучше отключить при первом импорте и включать постепенно.', + sections: [ + { + id: 'rbxl-overview', + title: 'I1. Что это и зачем', + body: ( + <> +

                                                                                    + Импорт из Roblox — это возможность загрузить + в Рублокс готовую карту из Roblox Studio в формате + .rbxl или .rbxlx. Часть карты — геометрия, + цвета, материалы, GUI — переносится в Рублокс + автоматически. Импорт превращает её в обычный проект + Рублокса, который можно редактировать, как любой + свой проект. +

                                                                                    +

                                                                                    + Кнопка «📦 Импорт Roblox» находится в левой + панели студии — внизу под кнопкой «ВИКИ». Откроется + модалка, куда можно перетащить .rbxl-файл. +

                                                                                    +

                                                                                    Зачем это нужно

                                                                                    +
                                                                                      +
                                                                                    • Перенести свою старую Roblox-карту в Рублокс, + чтобы продолжить работу здесь;
                                                                                    • +
                                                                                    • Изучить, как устроены большие карты опытных + разработчиков (классические Crossroads, + ROBLOX Battle и др.);
                                                                                    • +
                                                                                    • Использовать готовую сцену как «костяк» для + своей игры — добавить свои скрипты и механики.
                                                                                    • +
                                                                                    + + Импорт работает с файлами до 50 МБ. Этого + хватает для большинства карт. Очень большие карты + с тысячами объектов могут импортироваться долго + (20–60 секунд). + + + ), + }, + { + id: 'rbxl-how-to-use', + title: 'I2. Как импортировать карту', + body: ( + <> +

                                                                                    Шаг 1. Получи .rbxl-файл

                                                                                    +

                                                                                    + В Roblox Studio открой свою карту и сохрани её + через меню File → Save to File. Получится файл + с расширением .rbxl (бинарный) или + .rbxlx (текстовый XML). Оба формата подходят. +

                                                                                    + + Если у тебя нет своей карты, можно скачать + любую opensource Roblox-карту с GitHub — + их там много (поиск «roblox places .rbxl»). + Классика — карта Crossroads. + + +

                                                                                    Шаг 2. Загрузи в студию

                                                                                    + + Открой студию Рублокса и нажми кнопку + «📦 Импорт Roblox» в левой панели. + + + Перетащи .rbxl-файл в окно модалки или нажми + «Выбрать файл». Кликни «Анализировать». + Это займёт 5–30 секунд: сервер читает структуру карты, + считает объекты, скрипты, ассеты и показывает отчёт. + + + В отчёте увидишь, сколько в карте Part-ов, моделей, + скриптов, текстур и какие будут предупреждения. + Например: «найдены неподдерживаемые сетки», «много + BillboardGui — может тормозить» и т.п. + + +

                                                                                    Шаг 3. Настрой режим импорта

                                                                                    +

                                                                                    + Перед созданием проекта выбери, как обращаться + со скриптами и GUI карты: +

                                                                                    +
                                                                                      +
                                                                                    • + Скрипты: +
                                                                                        +
                                                                                      • Отключены (рекомендуется) — все + скрипты импортируются, но не запускаются. + Карта оживает как декорация: можно ходить + и смотреть, но без логики.
                                                                                      • +
                                                                                      • Включены — Lua-скрипты карты пытаются + запуститься. Часть API не поддерживается + (Roblox-only), некоторые карты могут зависать + или давать ошибки в консоли.
                                                                                      • +
                                                                                      • Удалить — скрипты вообще не сохраняются. + Останется только геометрия.
                                                                                      • +
                                                                                      +
                                                                                    • +
                                                                                    • + GUI: +
                                                                                        +
                                                                                      • Все — импортируется и HUD (ScreenGui), + и подписи над объектами (BillboardGui).
                                                                                      • +
                                                                                      • Только ScreenGui — переносится + только HUD. Хорошо если в карте было + 200+ табличек-вывесок города.
                                                                                      • +
                                                                                      • Пропустить — не импортировать GUI.
                                                                                      • +
                                                                                      +
                                                                                    • +
                                                                                    + +

                                                                                    Шаг 4. Создай проект

                                                                                    + + Введи название игры. Под этим именем карта появится + в твоих проектах. + + + Нажми «Создать игру». Сервер скачает текстуры + и сетки с Roblox CDN, соберёт проект и переведёт тебя + в редактор. + + + Нажми Запустить и осмотри + карту вживую. + + + ), + }, + { + id: 'rbxl-graphics-vs-scripts', + title: 'I3. Графика хорошо, скрипты — осторожно', + body: ( + <> +

                                                                                    + Главное, что нужно знать про импорт: графика + переносится хорошо, скрипты — нет. Это связано + с тем, что движок Рублокса (Babylon.js) и движок + Roblox — разные. Геометрия и материалы — это + «стандартный 3D», он одинаков везде. А скрипты + опираются на сотни Roblox-API, которые в Рублоксе + реализованы лишь частично. +

                                                                                    + +

                                                                                    Что переносится хорошо

                                                                                    +
                                                                                      +
                                                                                    • Все Part-ы (Brick, Cube, Sphere, Cylinder, + Wedge, CornerWedge) — позиция, размер, поворот, + цвет, материал, прозрачность;
                                                                                    • +
                                                                                    • Модели (Models) — собранные группы Part-ов, + включая Welds (склейки);
                                                                                    • +
                                                                                    • Текстуры и Decals — скачиваются + с Roblox CDN и применяются на Part-ы;
                                                                                    • +
                                                                                    • MeshPart-ы — пользовательские 3D-сетки;
                                                                                    • +
                                                                                    • SpawnLocation — точка появления игрока;
                                                                                    • +
                                                                                    • Lighting / Sky — время суток, цвет неба;
                                                                                    • +
                                                                                    • GUI (ScreenGui, TextLabel, TextButton, + ImageLabel, Frame) — простая разметка интерфейса + переносится верно.
                                                                                    • +
                                                                                    + +

                                                                                    Что переносится так себе

                                                                                    +
                                                                                      +
                                                                                    • Lua-скрипты — выполняются через нашу + реализацию Roblox API. Базовые вещи работают: + Touched, ClickDetector, TweenService, BindableEvent, + Vector3, CFrame, Workspace, Players. Но десятки + специальных сервисов нет: DataStoreService, + HttpService, MarketplaceService, MessagingService, + TeleportService и т.д.;
                                                                                    • +
                                                                                    • Анимации — простые работают, сложные + rig-анимации (R15, custom) могут вести себя + не так;
                                                                                    • +
                                                                                    • Физика — общая работает, но Constraint-ы, + Motors, BodyMover-ы (BodyVelocity, + BodyPosition и др.) имеют упрощённую реализацию;
                                                                                    • +
                                                                                    • Звуки — переносятся, но Roblox SoundId + (rbxassetid://) у нас не играет напрямую — + лучше заменить на наши звуки ('click', + 'win', 'coin' и т.д.).
                                                                                    • +
                                                                                    + +

                                                                                    Что точно не переносится

                                                                                    +
                                                                                      +
                                                                                    • Мультиплеерная логика на Roblox-Remote-Event-ах — + у нас своя система мультиплеера;
                                                                                    • +
                                                                                    • Покупки, бонусы, премиум — Roblox-only;
                                                                                    • +
                                                                                    • Облачное сохранение (DataStore) — у нас другое;
                                                                                    • +
                                                                                    • Чат, аватары пользователей Roblox.
                                                                                    • +
                                                                                    + + ), + }, + { + id: 'rbxl-recommended-flow', + title: 'I4. Правильный порядок: сначала графика, потом скрипты', + body: ( + <> +

                                                                                    + Импорт хорошо работает в два прохода. Не + пытайся всё запустить сразу — карта может встать + колом из-за ошибок в чужих скриптах. Делай так: +

                                                                                    + +

                                                                                    Проход 1. Импорт без скриптов

                                                                                    + + Перед созданием проекта выбери в режиме скриптов + «Отключены». + + + Создай проект. Запусти игру и пройдись по карте. + Смотри только на графику: +
                                                                                      +
                                                                                    • как лежат блоки и модели;
                                                                                    • +
                                                                                    • правильные ли материалы и цвета;
                                                                                    • +
                                                                                    • работают ли текстуры;
                                                                                    • +
                                                                                    • как выглядит освещение и небо;
                                                                                    • +
                                                                                    • нет ли провалов / летающих в воздухе кусков.
                                                                                    • +
                                                                                    +
                                                                                    + + Если что-то выглядит криво — это хорошо: + твой проект не сломан скриптами, можно спокойно + поправить геометрию руками в редакторе. + + +

                                                                                    Проход 2. Аккуратно включай скрипты

                                                                                    +

                                                                                    + После того как графика тебя устраивает, можно + по одному включать скрипты карты. Это + делается уже в редакторе проекта, в панели + «Скрипты»: +

                                                                                    + + Открой панель скриптов. У каждого скрипта рядом + с названием есть тумблер «Включён». + По умолчанию после импорта все они выключены. + + + Включай скрипты по одному, начиная с самых + простых — те, что висят на Touched-частях + (кнопки, телепорты, ловушки). Запускай игру, + смотри, всё ли работает. + + + Если включил скрипт и игра зависла или замусорила + консоль ошибками — выключи его обратно. Это + нормально. У этого скрипта, скорее всего, есть + Roblox-API, которого у нас нет. + + + Для важных скриптов, которые не работают, можно + либо переписать их под Рублокс (у нас простой + JS-API через game.*), либо + заменить на свой скрипт с похожей логикой. + + + + Не расстраивайся, если из 200 скриптов сразу + заработает только 50. Это нормально — большие + Roblox-карты опираются на сложные сервисы. Зато + графика уже на месте, и тебе остаётся написать + несколько коротких скриптов на привычном JS. + + + ), + }, + { + id: 'rbxl-tips-and-tricks', + title: 'I5. Советы и частые проблемы', + body: ( + <> +

                                                                                    Частые ошибки

                                                                                    +
                                                                                      +
                                                                                    • + «Карта пустая после импорта» — спавн-точка + могла оказаться под полом или в стене. В редакторе + переставь её в Game-вкладке: точка спавна + {' '}→ кликни в нужное место. +
                                                                                    • +
                                                                                    • + «Текстуры серые» — изредка Roblox CDN не + отдаёт текстуру (она удалена). Поставь свой цвет + или текстуру на этих Part-ах в редакторе. +
                                                                                    • +
                                                                                    • + «Игра тормозит» — обычно дело в большом + числе BillboardGui (вывески города) или unanchored + Part-ов. Импортируй заново с режимом GUI + «Только ScreenGui». +
                                                                                    • +
                                                                                    • + «Скрипт даёт ошибку DataStoreService» — + это Roblox-only сервис. Выключи скрипт или + замени логику сохранения на нашу. +
                                                                                    • +
                                                                                    • + «Игрок проваливается под пол» — иногда + у Part-а пола стоит CanCollide=false. + Выдели пол в редакторе и включи коллизию. +
                                                                                    • +
                                                                                    + +

                                                                                    Что делать после импорта

                                                                                    +
                                                                                      +
                                                                                    • Переименуй важные объекты на русский — так + удобнее писать новые скрипты;
                                                                                    • +
                                                                                    • Сгруппируй похожие Part-ы в модели (можно через + инспектор);
                                                                                    • +
                                                                                    • Поставь свои точки спавна и финиша на местах, + которые тебе нужны;
                                                                                    • +
                                                                                    • Сохрани копию проекта до того, как начнёшь + включать скрипты — на случай, если карта сломается.
                                                                                    • +
                                                                                    + + + Импорт — это стартовая площадка, а не готовый + продукт. Лучше всего он работает, когда ты берёшь + из Roblox-карты только геометрию, а механики + и интерактив пишешь сам на нашем JS-API + (через game.*). Так получится игра, + которая стабильно работает у тебя и у игроков. + + +

                                                                                    Что почитать дальше

                                                                                    +

                                                                                    + Если хочешь узнать, как переписать импортированный + Roblox-скрипт под наш движок — посмотри раздел + «Скрипты» в вики (D-G) и сравни Roblox-API + с нашим game.*. Многое делается похоже, + только короче. +

                                                                                    + + ), + }, + ], + }, + // ════════════════════════════════════════════════════ // РАЗДЕЛ — СОВМЕСТНОЕ РЕДАКТИРОВАНИЕ (Team Create) // ════════════════════════════════════════════════════ diff --git a/src/components/RbxlImportModal.jsx b/src/components/RbxlImportModal.jsx index 2247a42..f569146 100644 --- a/src/components/RbxlImportModal.jsx +++ b/src/components/RbxlImportModal.jsx @@ -1,7 +1,7 @@ /** * RbxlImportModal — модалка импорта .rbxl Roblox-карт в Rublox. * - * Доступна ТОЛЬКО МИНу (user_id === 1) — это тест-фича. + * Доступна всем пользователям (см. вики «Импорт из Roblox» о нюансах). * * Поток: * 1. Юзер дропает или выбирает .rbxl файл. @@ -13,8 +13,6 @@ import React, { useState, useRef } from 'react'; import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js'; -const ALLOWED_USER_ID = 1; // МИН - const MAX_SIZE = 50 * 1024 * 1024; // 50 MB export default function RbxlImportModal({ open, onClose, currentUserId, onCreated }) { @@ -37,18 +35,6 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate if (!open) return null; - if (currentUserId !== ALLOWED_USER_ID) { - return ( -
                                                                                    -
                                                                                    e.stopPropagation()}> -

                                                                                    Импорт из Roblox

                                                                                    -

                                                                                    Эта тест-функция доступна только администратору.

                                                                                    - -
                                                                                    -
                                                                                    - ); - } - const reset = () => { setFile(null); setReport(null); setPreviewHash(null); setTitle(''); setError(null); setAnalyzing(false); setCreating(false); -- 2.47.2 From 09a927bbfc6989e7cd41f78a83f186a531b1f132 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 23:43:12 +0300 Subject: [PATCH 214/214] =?UTF-8?q?fix(docs):=20=D0=B4=D0=B5=D1=84=D0=BE?= =?UTF-8?q?=D0=BB=D1=82=20=D1=8F=D0=B7=D1=8B=D0=BA=D0=B0=20=D0=B2=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8C=D1=8F=D1=85=20=E2=80=94=20JS,=20=D0=BD?= =?UTF-8?q?=D0=B5=20Lua?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дефолт в DEFAULT_LANG уже был 'js', но у части юзеров в localStorage залип 'lua' с прошлого посещения (rublox.docs.lang). Фикс: бамп ключа на 'rublox.docs.lang.v2' + удаление старого ключа при инициализации. У всех теперь старт с JS, переключение на Lua сохраняется по новому ключу как раньше. --- src/community/docsLang.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/community/docsLang.jsx b/src/community/docsLang.jsx index a47fa98..ffeb43d 100644 --- a/src/community/docsLang.jsx +++ b/src/community/docsLang.jsx @@ -113,7 +113,11 @@ export function highlightCode(text, lang) { } -const LS_KEY = 'rublox.docs.lang'; +// v2 — раньше при первом включении lua-режима сохранялся в LS и юзер +// потом всегда видел Lua-таб по умолчанию. Бамп ключа = сброс на JS +// у всех уже-открытых вкладок. +const LS_KEY = 'rublox.docs.lang.v2'; +const LS_KEY_OLD = 'rublox.docs.lang'; const DEFAULT_LANG = 'js'; const DocsLangContext = createContext({ @@ -124,6 +128,8 @@ const DocsLangContext = createContext({ export function DocsLangProvider({ children }) { const [lang, setLangState] = useState(() => { try { + // Очищаем старый ключ — у части юзеров там залип 'lua' + localStorage.removeItem(LS_KEY_OLD); const v = localStorage.getItem(LS_KEY); return v === 'lua' ? 'lua' : 'js'; } catch (_) { -- 2.47.2