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_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/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/RUBLOX_LUA_API_CHANGELOG.md b/RUBLOX_LUA_API_CHANGELOG.md
new file mode 100644
index 0000000..b6795d9
--- /dev/null
+++ b/RUBLOX_LUA_API_CHANGELOG.md
@@ -0,0 +1,431 @@
+# Lua API — журнал изменений
+
+Файл фиксирует **что было добавлено в Lua-runtime** при работе с реальными
+Roblox-играми. Цель — потом продублировать тот же API для **JS-движка**
+(на будущее, сейчас работаем только с Lua).
+
+Формат: дата + что и почему + куда добавлено + надо ли портировать в JS.
+
+---
+
+## 2026-06-08 — Итерация 4: Spawn-fix + философия импорта
+
+**Контекст:** МИН подтвердил после ROBLOX Battle: 100% покрытие Lua-скриптов
+из Roblox не получится (наш wasmoon не yield'ит из JS C-call boundary,
+старый Roblox-pattern WaitForChild через ChildAdded:wait тривиально вешает
+страницу). **Цель импорта сменилась**: показать геометрию и базовые
+интеракции, а не полную скриптовую логику.
+
+### Spawn fix (карта проекта 2853)
+
+После переимпорта одной из карт игрок появлялся **внутри Anchored
+геометрии** (стена/пол), не мог двигаться. Причина: SpawnLocation в старых
+.rbxl ставится впритык к плите (Y+0.5), наш отступ +1.5 не спасал от
+толстых Floor'ов 2-3 units high. Anchored=True (наш force-fix) не давал
+выпрыгнуть.
+
+Фиксы в `converter.py`:
+1. **SpawnLocation +5** вместо +1.5. Если spawn внутри толстого пола —
+ гравитация уронит обратно за 1 кадр, не страшно. Если выше — отлично.
+2. **Auto-fallback** если SpawnLocation в карте НЕ был (или дефолт остался
+ `(0, 2, 0)`):
+ ```python
+ max_top = max(p['y'] + p['sy']/2 for p in primitives)
+ scene['spawnPoint'] = {x: 0, y: max_top + 5, z: 0}
+ ```
+ Игрок появляется над самой высокой Part'ой → падает на крышу.
+
+### Философия импорта (зафиксировано как принцип)
+
+**Цель импорта .rbxl** = показать геометрию и сцену, а не воспроизвести
+скриптовое поведение. Что работает (важно):
+- ✅ Все примитивы (Part/Wedge/CornerWedge/Truss/Union/MeshPart)
+- ✅ Цвета через BrickColor (расширенная палитра 120 цветов)
+- ✅ Anchored=True для всех (карта не рассыпается)
+- ✅ SpawnLocation с правильным Y (игрок не в стене)
+- ✅ Корректный CFrame YXZ (мостики/wedge'и стоят правильно)
+- ✅ Скайбокс, освещение, экспозиция/контраст через слайдеры
+- ✅ Простые Touched-скрипты (Bouncer, BattleArmor, KillBrick)
+- ✅ Tools.Equipped/Activated handlers (часть оружия)
+
+Что НЕ воспроизводится (принимаем):
+- ❌ Сложные RoundScript / GameClock / Spawner / KillFeed-логика
+- ❌ WaitForChild через while+:wait() паттерны (regex-фильтр пропускает)
+- ❌ Регенерация построек (Regenerate*) — не нужна т.к. Anchored
+- ❌ LeaderboardV3 с DataStore (пропускается)
+- ❌ Сетевые RemoteEvent/RemoteFunction (single-player только)
+
+### Когда снова работать со скриптами
+
+Если попадётся **новая карта (2015+)** — `WaitForChild` встроен в API,
+наш regex-фильтр не сработает. Скрипты пройдут больше и будут работать
+лучше. Старые карты (2007-2010) принципиально ограничены.
+
+### Что НЕ делать
+
+- Не пытаться "ещё раз" решить yield-across-C-boundary через debug.sethook
+ или pcall-трюки. Проверено — не работает с wasmoon.
+- Не переписывать wasmoon — это месяцы работы.
+- Не сужать regex-фильтр в надежде запустить ещё пару скриптов — лучше
+ пусть пропустится лишний, чем висит страница.
+
+### Что делать дальше
+
+- Идти по .rbxl из Desktop/RBLX/ как пользователь.
+- На каждой карте проверять: геометрия загрузилась? игрок ходит? видна?
+- Если виснет — добавлять regex-паттерн в фильтр.
+- Если игрок застрял — улучшать spawn-fallback.
+- Если падают конкретные API — реализовывать в shim (как Mouse.Icon,
+ BodyVelocity-bouncer, leaderstats).
+
+### В JS
+
+✅ Все фиксы spawn + философия общая для студии и плеера.
+
+---
+
+## 2026-06-08 — Итерация 3: ROBLOX Battle (arch1_ROBLOX_Battle_v2.rbxl, проект 2851)
+
+**Контекст:** PvP-арена 2009 в XML, 1677 примитивов, 66 скриптов, 4 команды
+(TeamBeacon), 5 оружий, 12 батутов, KillFeed, раунды.
+
+### Реализовано 11 механик из 14
+
+1. **Teams** — game.Teams сервис + Team-инстансы, эвристика TeamBeacon-Model
+ в converter.py → автоматически создаёт 4 команды по имени.
+2. **Leaderstats UI** — IntValue.Value реактивно через Object.defineProperty,
+ при Parent=leaderstats шлёт leaderstatSet → существующий LeaderstatsManager.
+3. **BindableFunction/RemoteFunction** + Message/Hint классы с реактивным Text.
+4. **KillFeed UI** + creator-tag tracking в Humanoid.TakeDamage. DOM-overlay.
+5. **SpawnLocation.TeamColor** → scene.team_spawns[].
+6. **Tool/Model:Clone()** + :MakeJoints/:BreakJoints/:Remove no-op.
+7. **Creator-tag**: ObjectValue.Name='creator' проверяется на Health=0.
+8. **RegenerationScript** — no-op skip по имени (Anchored=True держит).
+9. **BattleArmor** — реактивный Humanoid.MaxHealth/Health/WalkSpeed/JumpPower.
+10. **WinGui/FireButton** через GuiManager.
+11. **AdminConsole** — no-op.
+12. **Bouncer** — BodyVelocity.Y > 10 + Parent=Torso → playerSet jumpVelocity.
+14. **Mouse.Icon** → CSS cursor через canvas.style.cursor.
+
+Также добавлены: **tick/time/delay/spawn/LoadLibrary** legacy globals,
+**SpecialMesh/BlockMesh/CylinderMesh/FileMesh** Instance.new стабы.
+
+### Новый модуль RbxlHudOverlay.js
+
+DOM-оверлей поверх canvas с KillFeed (правый верх, fade 5с) + Message
+(центр верх) + WinGui (центр). Lazy-создаётся.
+
+### Tight-loop защита (КРИТИЧНО)
+
+Roblox 2009 паттерн:
+```lua
+while not parent:FindFirstChild(name) do parent.ChildAdded:wait() end
+```
+
+Наш Signal:wait() возвращает синхронно — цикл бесконечный, страница виснет.
+**Не можем yield** из JS-функции через wasmoon C-call boundary.
+
+Перепробовали:
+- debug.sethook(yield, 'i', N) — внутри C-call падает с `yield across C-call`.
+- pcall(coroutine.yield) — ошибка ловится, счётчик не сбрасывается, вис.
+
+**Финал**: regex-фильтр в GameRuntime.js пропускает скрипты с этими паттернами.
+Из 66 скриптов 37 пропущены, 29 работают. Жертвы: RoundScript, GameClock,
+Spawner, KillFeed, LeaderboardV3, оружие Launcher/Sword/Slingshot/Cannon.
+
+### CFrame YXZ Euler
+
+Переписал `to_euler_xyz` в `rbxl_types.py` под Babylon YXZ convention:
+rx=asin(-r12), ry=atan2(r02,r22), rz=atan2(r10,r11) + gimbal-lock guard.
+Раньше извлекал XYZ-Euler, Babylon применял как YXZ — мостики
+поворачивались криво.
+
+### Persistence настроек света
+
+BabylonScene.serialize/loadFromState сохраняют scene.lighting:
+sunIntensity, hemiIntensity, sceneAmbient, exposure, contrast, saturation.
+
+### Известные баги
+
+- `memory access out of bounds` (1 раз) — WASM-crash одного скрипта.
+- `Cannot read properties of null ('then')` — wasmoon promise-detection,
+ скрипт init крашится но не блокирует.
+- 0 teams при загрузке старого проекта — нужен переимпорт.
+
+### В JS
+
+✅ Всё: Teams формат общий, KillFeed/Message HUD общий для студии+плеера.
+
+---
+
+## 2026-06-08 — Итерация 2: Crossroads (arch1_Original_Crossroads.rbxl, проект 2827)
+
+**Контекст:** Классическая Roblox-карта 2009 года для PvP, **XML-формат** .rbxl
+(старее бинарного). 877 instances, 777 Part, 83 Model. Состоит из 4 зон:
+крепость (Castle), дом (House Platform), деревья, дорожки крест-накрест.
+2 скрипта: «Regenerate Playground» и «Regenerate Castle» — периодически
+удаляют и восстанавливают постройки (для PvP).
+
+### Главное: XML-парсер для .rbxl
+
+`rbxl-importer/src/rbxl_xml_parser.py` (новый файл, ~330 строк):
+
+- `is_xml_rbxl(blob)` — детект по `
+ Для этого урока пока готова только JS-версия (показана ниже).
+ Если откроешь копию с языком Lua — получишь скрипт-заглушку
+ с подсказкой переключить язык в редакторе.
+
+ Скрипты в твоей копии будут написаны на выбранном языке.
+ Логика игры одинаковая — отличается только запись кода.
+
Нарисованная кнопка сама по себе ничего не делает —
- нужен скрипт. Самый простой способ — повесить скрипт
- прямо на кнопку.
+ нужен скрипт.
- Можно и наоборот — управлять кнопкой из глобального
- скрипта, если найти её по имени:
- Можно и наоборот — управлять кнопкой из глобального скрипта:
- Что тут происходит:
+ Lua:
Поле ввода позволяет игроку напечатать ответ.
- Когда он нажмёт Enter, срабатывает событие
-
- Разберём построчно:
+ Lua-разбор: на TextBox сигнал
+ В Рублоксе можно писать скрипты на двух языках:
+ JavaScript и Lua. Оба работают одинаково
+ хорошо. Игра не отличает их между собой — внутри одного
+ проекта одни скрипты могут быть на JS, другие на Lua,
+ и они общаются между собой как будто это один язык.
+ Чем они отличаются? Один и тот же пример на двух языках: Когда игрок касается синего блока — печатаем «Привет».
+ Видишь — оба варианта делают одно и то же. Но
+ запись отличается. JS короче для простых вещей через
+ Что выбирать новичку? Можно ли менять язык в одном скрипте?
+ Да. В редакторе скрипта вверху есть две кнопки —
+ JS и Lua. Просто нажми на нужную.
+ Твой код на текущем языке сохранится, а
+ на другом языке откроется пустой шаблон или то, что
+ ты писал там раньше. Никогда ничего не теряется.
+ А что под капотом?
+ JS-скрипты исполняются в {id}
+
+ {code}На каком языке открыть копию?
+
-);
+// ── Код-блок с подсветкой синтаксиса ──────────────────────────────
+// lang='js' (default) | 'lua'. Если не указан — автодетект по содержимому.
+// 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 {children}
;
+ }
+ const resolved = lang || (
+ /\blocal\b|\bthen\b|\bend\b|\b:Connect\b|\bfunction\(|--\s/.test(text) ? 'lua' : 'js'
+ );
+ return (
+ {text}
+
+ );
+};
// ── Плашка «куда писать скрипт» ───────────────────────────────────
// kind="global" — глобальный скрипт (создаётся в категории «Скрипты»)
@@ -221,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 — ОСНОВЫ
@@ -1012,35 +1256,55 @@ game.self.onUntouch(() => {
<>
+ {`// Скрипт висит на кнопке.
+
- {`-- Скрипт висит на кнопке (TextButton)
+-- script.Parent — это сама кнопка.
+local btn = script.Parent
+
+btn.MouseButton1Click:Connect(function()
+ print("Кнопка нажата!")
+end)`}}
+ />
+ {`// Находим кнопку по имени и вешаем на неё клик
+
+ 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 прячет.
+ gui:FindFirstChild(name, true) ищет рекурсивно
+ (третий аргумент true = во вложенных).
+ MouseButton1Click — стандартный сигнал клика на TextButton.
+ btn.Visible = false прячет элемент.
onSubmit — и скрипт получает введённый текст.
+ Когда он нажмёт Enter, скрипт получает введённый текст.
{`// Игрок вводит код. Правильный код — 1234.
+
+});`}}
+ 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') — проверяем код.
+ FocusLost
+ срабатывает когда поле теряет фокус (Enter или клик мимо).
+ Текст лежит в box.Text.
'1234' означают,
- что это текст, а не число. Игрок печатает в поле
- всегда текст, поэтому и сравнивать нужно с текстом.
+ Кавычки "1234" означают, что это
+ текст, а не число. Игрок печатает в поле всегда
+ текст, поэтому и сравнивать нужно с текстом.
+
+
+
+
+
+
+ JavaScript Lua
+
+ Где ещё используется
+ Сайты, мобильные приложения, серверы.
+ Самый популярный язык в мире.
+ Roblox, World of Warcraft (моды),
+ многие игры. Простой и быстрый.
+
+
+ Главный API
+
+ game.* (game.player,
+ game.log, game.scene)
+ game.* в Roblox-стиле
+ (game:GetService("Players"), workspace)
+
+ Похож на
+ Roblox-LUA если знаешь Roblox
+ Roblox-Studio — те же команды
+
+
+
+ Когда выбрать
+ Если планируешь делать сайты и приложения
+ — JS пригодится везде.
+ Если играешь в Roblox и видел там скрипты
+ — 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)`}}
+ />
+ game.onTouch, Lua даёт точный Roblox-стиль
+ через :Connect и события.
+ game.* в JS короче и проще читать.
+ Большая часть уроков в этой вике написана с примерами
+ на JS — все они работают, просто копируй.
+ game:GetService,
+ :Connect, workspace,
+ script.Parent.
+ WebWorker —
+ это отдельный поток в браузере, чтобы скрипт не
+ тормозил саму игру. Lua-скрипты исполняются через
+ wasmoon — Lua-интерпретатор, скомпилированный
+ в WebAssembly. Оба варианта работают на любом
+ устройстве без установки.
+
- Скрипты пишут на языке JavaScript — одном из самых - популярных языков в мире. Не пугайся: начнём с простого, - а редактор подсказывает команды по ходу набора. + Скрипты пишут на одном из двух языков: JavaScript + или Lua. Оба работают одинаково. Подробнее про + выбор языка — статья D0 выше. В этом уроке покажем + пример на обоих языках — переключай вкладки, чтобы видеть + нужный.
Как создать первый скрипт:
{`game.log('Привет! Игра запустилась.');`}
+ {`print("Привет! Игра запустилась.")`}}
+ />
- Это твой первый работающий скрипт. Команда
- game.log(...) печатает сообщение в консоль.
+ Это твой первый работающий скрипт.
; — как точка в конце предложения. Текст
- пишут в кавычках: 'привет'. Забыл кавычки
- или точку с запятой — будет ошибка.
- game.log(...) печатает в консоль.
+ Каждая команда в JavaScript заканчивается точкой с запятой
+ ; — как точка в предложении. Текст пишут
+ в кавычках: 'привет'.
+ }
+ lua={print(...) печатает в консоль.
+ В Lua точку с запятой ставить не нужно.
+ Текст пишут в кавычках: "привет" или
+ 'привет'. Lua использует ..
+ (две точки) для склейки текста: "А=" .. 5.
+
Скрипт на объекте относится к конкретному кубу, модели
или кнопке. Внутри такого скрипта работает волшебное слово
- 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").
+ {`// Создаём переменную и кладём в неё число
+ {`// JS: создаём переменную через let
let score = 0;
// Меняем значение
score = score + 10; // теперь в score лежит 10
score = score + 5; // теперь 15
-game.log('Очков:', score); // напечатает: Очков: 15`}
-
- let — это слово «создать переменную». Пишут
- его только один раз, когда коробочку заводят. Дальше
- меняют значение уже без let.
-
{`-- 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('Дверь');
- {`local name = "Герой" -- текст — в кавычках
+local isWin = false -- да/нет — true или false
+local coinCount = 0 -- число — без кавычек`}}
+ />
+ let можно писать const
+ («постоянная»). Например, найденную один раз дверь:
+ const door = game.scene.findOne('Дверь');
+ }
+ lua={local. Если хочешь явно показать
+ что значение не меняется, пиши имя ЗАГЛАВНЫМИ:
+ local DOOR = workspace.Дверь.
+ Это договорённость, а не правило Lua.
+
В каждом скрипте есть одно главное волшебное слово —
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».
+
workspace | 3D-объекты сцены |
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 по коду и посмотри, какие значения
- печатаются. Так ты увидишь, где именно что-то пошло не так.
-
{`local score = 0
+score = score + 10
+print("Очки сейчас:", score) -- Очки сейчас: 10
+
+local pos = game.Players.LocalPlayer.Character.HumanoidRootPart.Position
+print("Игрок стоит в точке:", pos)`}}
+ />
+ game.log по коду и посмотри, какие значения
+ печатаются. Так ты увидишь, где именно что-то пошло не так.
+ }
+ lua={
+ Если игра ведёт себя странно — расставь
+ print по коду и посмотри, какие значения
+ печатаются. Так ты увидишь, где именно что-то пошло не так.
+
Событие — это «что-то случилось». Скрипт может ждать событие и реагировать на него. Самые важные:
-game.onTick(fn) | каждый кадр (60 раз в секунду) |
game.onKey('space', fn) | игрок нажал клавишу |
game.self.onClick(fn) | игрок кликнул по объекту |
game.self.onTouch(fn) | игрок коснулся объекта |
game.onTick(fn)game.onKey('space', fn)game.self.onClick(fn)game.self.onTouch(fn)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.
- {`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.
+ Heartbeat выполняется ОЧЕНЬ часто — 60 раз
+ в секунду. Не делай внутри тяжёлых вычислений. Подробнее
+ об этой ошибке — раздел J4.
+
Условие — это развилка: «если что-то верно —
- сделай одно, иначе — другое». В 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 === b | a равно b |
a !== b | a не равно b |
a {'>'} b | a больше b |
a {'<'} b | a меньше b |
a {'>'}= b | a больше или равно b |
a {'<'}= b | a меньше или равно b |
===, а не один. Один знак = —
- это «положить значение в переменную», совсем другое
- действие.
- a === ba !== ba {'>'} ba {'<'} ba {'>'}= ba {'<'}= ba == b | a равно b |
a ~= b | a не равно b |
a {'>'} b | a больше b |
a {'<'} b | a меньше b |
a {'>'}= b | a больше или равно b |
a {'<'}= b | a меньше или равно b |
===, а не один. Один знак = —
+ это «положить значение в переменную», совсем другое
+ действие. И не равно — это !==.
+ }
+ lua={ ==. А «не равно» — это ~=
+ (тильда + равно). Запомни этот значок — он встречается
+ только в Lua.
+ Таймеры запускают команды не сразу, а потом:
-game.after(сек, fn) — выполнить
- один раз через несколько секунд;
- game.every(сек, fn) — выполнять
- снова и снова каждые несколько секунд;
- game.cancel(id) — остановить таймер.
- game.after(сек, fn) — выполнить
+ один раз через несколько секунд;
+ game.every(сек, fn) — выполнять
+ снова и снова каждые несколько секунд;
+ game.cancel(id) — остановить таймер.
+ task.delay(сек, fn) — выполнить
+ один раз через несколько секунд;
+ task.wait(сек) — приостановить скрипт
+ на N секунд (внутри цикла или функции);
+ while true do task.wait(1); ... end
+ в отдельной корутине через task.spawn.
+ {`// Через 3 секунды показать текст
+ {`// Через 3 секунды показать текст
game.after(3, () => {
game.ui.showText('Игра началась!', 2);
});
@@ -1432,12 +2037,42 @@ const ticker = game.every(1, () => {
game.after(10, () => {
game.cancel(ticker);
game.ui.showText('Время вышло!', 2);
-});`}
-
- Запись (game.ui.score || 0) читается так:
- «возьми счёт, а если его ещё нет — возьми 0». Это защита
- от ошибки в самом начале, когда счётчик ещё пустой.
-
{`-- Через 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).
+ Это проще, чем хранить номер таймера.
+
- Скриптом можно менять, как двигается игрок. Эти команды - принимают множитель: 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 скорость и прыжок — это прямые значения + в 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);
@@ -1487,11 +2139,29 @@ game.after(10, () => {
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). Иначе игрок останется быстрым
- навсегда — а это может сломать твой уровень.
+ Не забывай возвращать скорость обратно. Иначе игрок
+ останется быстрым навсегда — а это может сломать твой уровень.
- Персонаж умеет показывать эмоции. Команда
- 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)`}}
+ />
>
),
},
@@ -1526,13 +2222,14 @@ game.after(3, () => {
<>
Твин — это плавное изменение чего-либо за время.
- Если просто переставить объект командой move —
- он телепортируется рывком. А твин плавно доедет
- из точки в точку.
+ Если просто переставить объект — он телепортируется рывком.
+ А твин плавно доедет из точки в точку.
Команда: game.tween(объект, что менять, настройки)
{`// Находим платформу-лифт по имени
+
+ Команда: game.tween(объект, что менять, настройки)
+ {`// Находим платформу-лифт по имени
const lift = game.scene.findOne('Лифт');
// Платформа за 2 секунды плавно поднимается на высоту 10
@@ -1540,27 +2237,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-й арг — секунды сколько длится
+ EasingStyle Linear / Quad / Bounce / Elastic
+ EasingDirection In / Out / InOut
+ repeatCount сколько раз повторить (-1 = бесконечно)
+ reverses true = туда-обратно
+ 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()`}
+ >}
+ />
>
),
},
@@ -1571,33 +2306,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 = ...).
+ Без неё ты потом не сможешь объект ни подвинуть, ни удалить.
+
+ >}
+ />
>
),
},
@@ -1607,20 +2369,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()`}}
+ />
>
),
},
@@ -1642,29 +2422,64 @@ game.tween(door, { y: 6 }, { duration: 1 });`}
body: (
<>
Команды для здоровья игрока:
-game.player.hp | текущее здоровье (можно читать) |
game.player.damage(n) | нанести урон |
game.player.heal(n) | вылечить |
game.player.kill() | мгновенно убить |
game.player.respawn() | воскресить на спавне |
game.player.setSpawn(точка) | новая точка возрождения |
game.player.hpgame.player.damage(n)game.player.heal(n)game.player.kill()game.player.respawn()game.player.setSpawn(точка)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)`}}
+ />
>
),
},
@@ -1673,52 +2488,78 @@ game.tween(door, { y: 6 }, { duration: 1 });`}
title: 'F2. Физика: raycast, импульсы, взрывы',
body: (
<>
-
- Отдел game.physics отвечает за «настоящую»
- физику:
-
raycast(откуда, куда, опции) — пустить
- невидимый луч и узнать, во что он попал. Так делают
- стрельбу;
- applyImpulse(ref, сила) — толкнуть объект
- (он должен быть не закреплён);
- explode(точка, радиус, опции) — взрыв.
- Отдел game.physics отвечает за «настоящую» физику:
raycast(откуда, куда, опции) — луч для стрельбы;applyImpulse(ref, сила) — толкнуть объект;explode(точка, радиус, опции) — взрыв.В 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 — адрес объекта, в который
- попали.
-
{`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 (нормаль поверхности).
+
@@ -1727,14 +2568,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)`}}
+ />
Чем атрибут лучше обычной переменной? Переменная одна на весь скрипт. А атрибут — свой у каждого объекта. @@ -1750,36 +2604,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('звезда')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)`}}
+ />
Часто игра просит «подойди и нажми E»: открыть сундук,
- поговорить с торговцем, дёрнуть рычаг. Это делается
- командой game.self.onInteract:
+ поговорить с торговцем, дёрнуть рычаг.
{`game.self.onInteract(() => {
+ {`game.self.onInteract(() => {
game.ui.showText('Сундук открыт!', 2);
game.scene.spawnParticles('sparks',
game.self.position, { duration: 1 });
@@ -1806,11 +2685,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 запустит функцию.
{`// Допустим, 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)`}}
+ />
Связи (constraints) соединяют объекты, чтобы они
- двигались вместе или по правилам физики. Отдел —
- game.constraints:
+ двигались вместе или по правилам физики.
Пример — качели на петле:
{`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`}}
+ />
>
),
},
@@ -1925,11 +2859,13 @@ game.every(1, () => {
<>
NPC (неигровой персонаж) — это житель твоей игры:
- торговец, враг, проводник. Создаётся командой
- game.scene.spawnNpc(модель, опции).
+ торговец, враг, проводник.
{`// Создаём NPC по имени Боб
+
+ В JS используем game.scene.spawnNpc(модель, опции):
+ {`// Создаём NPC по имени Боб
const bob = game.scene.spawnNpc('character-a', {
x: 5, y: 0, z: 0,
name: 'Боб',
@@ -1937,35 +2873,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)`}
+ >}
+ />
>
),
},
@@ -1975,39 +2960,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)`}}
+ />
>
),
},
@@ -2016,38 +3044,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()`}
+ >}
+ />
- Звук в играх обязателен — игра без звука кажется
- «мёртвой». Но не запускай длинную музыку в самом начале:
- это скучно и тормозит старт. Звуки вешай на события:
- прыжок, попадание, победа.
+ Звук обязателен — игра без звука кажется «мёртвой».
+ Но не запускай длинную музыку в начале — это тормозит старт.
+ Звуки вешай на события: прыжок, попадание, победа.
>
),
@@ -2057,29 +3106,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("Поехали!")`}
+ >}
+ />
>
),
},
@@ -2089,22 +3174,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`}}
+ />
>
),
},
@@ -2113,40 +3213,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)`}
+ >}
+ />
>
),
},
@@ -2156,29 +3278,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).
>
),
@@ -2189,16 +3348,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) -- крит`}
+ >}
+ />
>
),
},
@@ -2207,30 +3411,68 @@ game.fx.damageFloater(pos, 0, { isMiss: true }); // промах MISS`}
title: 'G9. Предметы и инвентарь с редкостями',
body: (
<>
-
- Полноценный инвентарь (сетка + хотбар, стаки, редкости).
- Сначала опиши предметы, потом выдавай:
-
- {`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.
+
+ >}
+ />
>
),
},
@@ -2239,18 +3481,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) -- полночь`}
+ >}
+ />
>
),
},
@@ -2259,32 +3535,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)`}
+ >}
+ />
>
),
},
@@ -2293,21 +3628,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.
+
+ >}
+ />
>
),
},
@@ -2329,140 +3692,328 @@ game.onVehicleExit(() => game.ui.showText('Вышел', 1));`}
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 + Anim NPC (вручную)
+ 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экран загрузки
+
+
}
+ />
>
),
},
@@ -2715,15 +4266,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');
@@ -2732,14 +4281,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)`}}
+ />
>
),
},
@@ -2750,30 +4321,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`}}
+ />
Сделай красный неоновый куб, повесь на него скрипт смерти —
получится лава. Поставь его в проёме как преграду.
@@ -2787,38 +4393,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.
- {`-- В 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)`}}
+ />
>
),
},
@@ -2829,34 +4470,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)`}}
+ />
+ Сдвинуть сам объект при касании (опустить мост):
+
- Плавно сдвинуть — через 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:
+{`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)`}}
+ />
>
),
},
@@ -2866,88 +4535,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`}}
+ />
+ Типы примитивов:
+{`Enum.PartType.Block / Ball / Cylinder / Wedge / CornerWedge
+-- Для cone/pyramid/torus используются MeshPart или SpecialMesh:
+local sphere = Instance.new("Part")
+sphere.Shape = Enum.PartType.Ball -- сфера`}}
+ />
+ Менять свойства существующего объекта:
+Math.PI/2 ≈ 1.57, 180° = Math.PI ≈ 3.14.
- {`-- Прямое присваивание свойств 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() -- удалить`}}
+ />
+ Math.PI/2 ≈ 1.57.
+ math.rad(90).
+
- Вращающийся объект (монета, портал) — крутим каждый
- кадр через 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)`}}
+ />
+ Парение вверх-вниз:
+Пульсация размера через 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)`}}
+ />
+ Пульсация размера:
+Мигание цветом каждые полсекунды:
-{`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()`}}
+ />
+ Мигание цветом:
+{`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`}}
+ />
>
),
},
@@ -2956,47 +4706,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 одну секунду.
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)`}}
+ />
+ Обратный отсчёт:
+Кнопка на экране (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:
+{`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)`}}
+ />
>
),
},
@@ -3024,34 +4851,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`}}
+ />
+ Игрок упал вниз:
+Финиш — дошёл до зоны, победа:
+});`}} + 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)`}}
+ />
>
),
},
@@ -3060,30 +4928,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).
- {`-- Враг должен быть 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)`}}
+ />
>
),
},
@@ -3092,35 +4998,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`}}
+ />
+ Сохранение между сессиями:
+{`-- В 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)`}}
+ />
+ Импорт из Roblox — это возможность загрузить + в Рублокс готовую карту из Roblox Studio в формате + .rbxl или .rbxlx. Часть карты — геометрия, + цвета, материалы, GUI — переносится в Рублокс + автоматически. Импорт превращает её в обычный проект + Рублокса, который можно редактировать, как любой + свой проект. +
++ Кнопка «📦 Импорт Roblox» находится в левой + панели студии — внизу под кнопкой «ВИКИ». Откроется + модалка, куда можно перетащить .rbxl-файл. +
++ В Roblox Studio открой свою карту и сохрани её + через меню File → Save to File. Получится файл + с расширением .rbxl (бинарный) или + .rbxlx (текстовый XML). Оба формата подходят. +
++ Перед созданием проекта выбери, как обращаться + со скриптами и GUI карты: +
++ Главное, что нужно знать про импорт: графика + переносится хорошо, скрипты — нет. Это связано + с тем, что движок Рублокса (Babylon.js) и движок + Roblox — разные. Геометрия и материалы — это + «стандартный 3D», он одинаков везде. А скрипты + опираются на сотни Roblox-API, которые в Рублоксе + реализованы лишь частично. +
+ ++ Импорт хорошо работает в два прохода. Не + пытайся всё запустить сразу — карта может встать + колом из-за ошибок в чужих скриптах. Делай так: +
+ ++ После того как графика тебя устраивает, можно + по одному включать скрипты карты. Это + делается уже в редакторе проекта, в панели + «Скрипты»: +
+game.*), либо
+ заменить на свой скрипт с похожей логикой.
+ game.*). Так получится игра,
+ которая стабильно работает у тебя и у игроков.
+
+ Если хочешь узнать, как переписать импортированный
+ Roblox-скрипт под наш движок — посмотри раздел
+ «Скрипты» в вики (D-G) и сравни Roblox-API
+ с нашим game.*. Многое делается похоже,
+ только короче.
+
- Выдели весь текст ниже и скопируй (Ctrl+A внутри блока - или мышью), затем вставь в нейросеть перед своим вопросом: + Сверху выбери язык скриптов своей игры. Выдели весь + текст ниже и скопируй (Ctrl+A внутри блока или мышью), + затем вставь в нейросеть перед своим вопросом:
-{AI_CONTEXT}
+ {AI_CONTEXT_LUA}}
+ />
>
),
},
diff --git a/src/community/docsGamesBuilders.js b/src/community/docsGamesBuilders.js
index 1e92045..064598f 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',
@@ -6158,8 +6160,143 @@ export function hasGameBuilder(id) {
return typeof GAME_BUILDERS[id] === 'function';
}
-/** Построить project_data для игры-урока. Возвращает объект или null. */
-export function buildGameProject(id) {
- const fn = GAME_BUILDERS[id];
- return fn ? fn() : null;
+// ══════════════════════════════════════════════════════════════════
+// 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' — на каком языке скрипты в копии.
+ */
+/**
+ * Генерирует минимальный рабочий Lua-каркас для скрипта когда явной
+ * Lua-реализации в LUA_OVERRIDES нет. Анализирует target и name чтобы
+ * сделать что-то осмысленное:
+ * - target=null (главный скрипт): показывает подсказку, слушает событие
+ * FinishReached и при срабатывании — конфетти + Победа
+ * - target=primitive с именем содержащим "Финиш"/"Final": Touched →
+ * шлёт FinishReached
+ * - target=primitive с любым другим именем: Touched → красит примитив
+ * в случайный цвет (визуальный feedback что скрипт работает)
+ */
+function generateFallbackLua(s, gameTitle) {
+ const target = s.target;
+ const name = s.name || s.id || '';
+ const title = gameTitle || 'игра';
+ // Главный скрипт (target=null)
+ if (!target || target === null) {
+ return `-- === ${name} (Lua, авто-каркас) ===
+-- Полная Lua-версия этой игры пока в разработке.
+-- Этот каркас обеспечивает базовое поведение: подсказка + победа на финише.
+local ReplicatedStorage = game:GetService("ReplicatedStorage")
+local function getEvent(eventName)
+ local ev = ReplicatedStorage:FindFirstChild(eventName)
+ if not ev then
+ ev = Instance.new("BindableEvent")
+ ev.Name = eventName
+ ev.Parent = ReplicatedStorage
+ end
+ return ev
+end
+
+__rbxl_show_text("${title.replace(/"/g, '\\"')}", 3)
+
+local winSound = Instance.new("Sound", workspace)
+winSound.SoundId = "win"; winSound.Volume = 1
+
+local won = false
+local winEvent = getEvent("FinishReached")
+winEvent.Event:Connect(function()
+ if won then return end
+ won = true
+ winSound:Play()
+ __rbxl_show_text("Победа!", 4)
+ local px = __rbxl_player_x()
+ local py = __rbxl_player_y()
+ local pz = __rbxl_player_z()
+ __rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3)
+end)`;
+ }
+ // Скрипт на примитиве с именем "Финиш" / "ФинишЗона" / "Final"
+ const isFinish = /финиш|финал|final/i.test(name);
+ if (isFinish) {
+ return `-- === ${name} (Lua, авто-каркас) ===
+-- При касании игроком шлём событие победы.
+local ReplicatedStorage = game:GetService("ReplicatedStorage")
+local part = script.Parent
+local fired = false
+
+part.Touched:Connect(function(hit)
+ if fired then return end
+ local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
+ if not h then return end
+ fired = true
+ local ev = ReplicatedStorage:FindFirstChild("FinishReached")
+ if not ev then
+ ev = Instance.new("BindableEvent")
+ ev.Name = "FinishReached"
+ ev.Parent = ReplicatedStorage
+ end
+ ev:Fire()
+end)`;
+ }
+ // Общий каркас для любого target-примитива — Touched красит в случайный цвет
+ return `-- === ${name} (Lua, авто-каркас) ===
+-- Полная Lua-версия этого скрипта пока в разработке.
+-- Базовое поведение: при касании предмет реагирует визуально.
+local part = script.Parent
+local touched = false
+
+part.Touched:Connect(function(hit)
+ if touched then return end
+ local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
+ if not h then return end
+ touched = true
+ -- Меняем цвет на яркий зелёный — простой feedback
+ part.Color = Color3.fromRGB(60, 230, 80)
+end)`;
+}
+
+export function buildGameProject(id, opts = {}) {
+ const fn = GAME_BUILDERS[id];
+ if (!fn) return null;
+ const project = fn();
+ if (opts.lang === 'lua' && project) {
+ const scene = project.scene || {};
+ if (Array.isArray(scene.scripts)) {
+ const overrides = LUA_OVERRIDES[id] || {};
+ // Извлекаем название игры из любого скрипта (для подсказки в fallback)
+ let gameTitle = '';
+ const mainScript = scene.scripts.find(s => !s.target);
+ if (mainScript) {
+ const m = /===\s*ИГРА\s*[«"](.+?)[»"]/i.exec(mainScript.code || '');
+ if (m) gameTitle = m[1];
+ }
+ scene.scripts = scene.scripts.map(s => {
+ if (s.language === 'lua') return s;
+ // Приоритет: явный code_lua → override из реестра → авто-fallback.
+ let luaCode = s.code_lua;
+ if (!luaCode) {
+ const ov = overrides[s.id];
+ if (typeof ov === 'function') luaCode = ov(s);
+ else if (typeof ov === 'string') luaCode = ov;
+ }
+ if (!luaCode || !luaCode.trim()) {
+ luaCode = generateFallbackLua(s, gameTitle);
+ }
+ return {
+ ...s,
+ language: 'lua',
+ code: luaCode,
+ code_js: s.code_js || s.code,
+ code_lua: luaCode,
+ };
+ });
+ }
+ }
+ return project;
}
diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js
new file mode 100644
index 0000000..504b55b
--- /dev/null
+++ b/src/community/docsGamesBuildersLua.js
@@ -0,0 +1,4779 @@
+/**
+ * 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 Players = game:GetService("Players")
+local score = 0
+local TOTAL = 8
+
+-- HUD: счётчик в правом верхнем углу
+local player = Players.LocalPlayer
+local screenGui = Instance.new("ScreenGui", player.PlayerGui)
+screenGui.Name = "CoinHUD"
+
+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
+ label.Text = "Монеты: " .. score .. " / " .. TOTAL
+ coinSound:Play()
+ if score >= TOTAL then
+ -- Победный текст
+ 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-объекту
+ 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) ===
+${SNIPPET_BROADCAST}
+
+local Players = game:GetService("Players")
+local RunService = game:GetService("RunService")
+local player = Players.LocalPlayer
+local won = false
+
+-- Подсказка по центру (паритет с JS game.ui.showText)
+__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 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)
+
+-- Финиш-зона шлёт BindableEvent
+local finishEvent = getEvent("FinishReached")
+finishEvent.Event:Connect(function()
+ if won then return end
+ won = true
+ winSound:Play()
+ __rbxl_show_text("Победа! Ты дошёл до финиша!", 5)
+ -- Конфетти над игроком (паритет с JS game.scene.spawnParticles)
+ 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)`,
+ 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
+ fired = true
+ local ev = ReplicatedStorage:FindFirstChild("FinishReached")
+ if ev then ev:Fire() end
+end)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 3 — «Не упади» (платформа сужается)
+ // ═══════════════════════════════════════════════════════════════
+ '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
+
+__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 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 — «Кнопка и дверь»
+ // ═══════════════════════════════════════════════════════════════
+ 'button-door': {
+ g4_main: `-- === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+__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 TweenService = game:GetService("TweenService")
+local RunService = game:GetService("RunService")
+
+local part = script.Parent
+local opened = false
+local hintVisible = false
+
+-- Подсказка над кнопкой. 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)
+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 -- скрыт по умолчанию
+
+local clickSound = Instance.new("Sound", part)
+clickSound.SoundId = "click"; clickSound.Volume = 0.8
+
+-- Каждый кадр проверяем расстояние до игрока. Подсказку показываем
+-- только если игрок в радиусе 4 единиц.
+RunService.Heartbeat:Connect(function()
+ 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)
+ 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
+ label.Visible = false -- скрываем подсказку (Destroy не уничтожает GUI-overlay)
+ label:Destroy()
+ hintGui:Destroy()
+ clickSound:Play()
+ __rbxl_show_text("Дверь открывается!", 2)
+ part.Color = Color3.fromRGB(100, 255, 100)
+
+ 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)`,
+ 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
+ fired = true
+ local ev = ReplicatedStorage:FindFirstChild("WinReached")
+ if ev then ev:Fire() end
+end)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 5 — «Лабиринт»
+ // ═══════════════════════════════════════════════════════════════
+ 'maze': {
+ g5_main: `-- === ИГРА «ЛАБИРИНТ» — главный скрипт (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
+ fired = true
+ local ev = ReplicatedStorage:FindFirstChild("WinReached")
+ if ev then ev:Fire() end
+end)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 6 — «Угадай цвет»
+ // ═══════════════════════════════════════════════════════════════
+ 'color-tiles': (function() {
+ const overrides = {
+ g6_main: `-- === ИГРА «ЦВЕТНЫЕ ПЛИТКИ» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+local Players = game:GetService("Players")
+local player = Players.LocalPlayer
+local painted = 0
+local TOTAL = 36
+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, 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 — «Ловишка предметов»
+ // ═══════════════════════════════════════════════════════════════
+ 'catch-falling': {
+ g7_main: `-- === ИГРА «ПОЙМАЙ ПАДАЮЩЕЕ» — главный скрипт (Lua) ===
+local Players = game:GetService("Players")
+local Debris = game:GetService("Debris")
+
+local player = Players.LocalPlayer
+local score = 0
+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 сек роняем куб (через Heartbeat — task.spawn не умеет yield)
+local RunService = game:GetService("RunService")
+local _spawnTimer = 0
+
+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)
+
+ 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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 8 — «Беги до финиша»
+ // ═══════════════════════════════════════════════════════════════
+ 'run-to-finish': {
+ 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 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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 9 — «Светофор»
+ // ═══════════════════════════════════════════════════════════════
+ '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
+
+__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
+
+-- Находим светофор и сразу красим в зелёный
+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)
+
+-- Финиш-зона шлёт 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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 10 — «Прыжки на пружинах»
+ // ═══════════════════════════════════════════════════════════════
+ '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")
+ 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 — «Эхо» (нажми кнопку → звук)
+ // ═══════════════════════════════════════════════════════════════
+ '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
+
+part.Touched:Connect(function(hit)
+ local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
+ if not h then return end
+ 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 — «Кодовая дверь»
+ // ═══════════════════════════════════════════════════════════════
+ 'code-door': (function() {
+ const overrides = {
+ g12_main: `-- === ИГРА «ДВЕРЬ ПО КОДУ» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+local TweenService = game:GetService("TweenService")
+local CODE = { 3, 1, 4, 2 }
+local entered = {}
+local opened = false
+local won = false
+
+__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.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 — «Торговец»
+ // ═══════════════════════════════════════════════════════════════
+ 'trader': {
+ g13_main: `-- === ИГРА «ТОРГОВЕЦ» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+local Players = game:GetService("Players")
+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
+
+-- Спавним NPC-торговца за прилавком (паритет с JS spawnNpc)
+local traderRef = __rbxl_spawn_npc("character-a", 0, 1, 5, "Торговец Боб", 100, 0)
+
+-- Определяем итем "Ключ" в инвентаре и показываем hotbar
+__rbxl_inventory_define("key", "Ключ", "#ffd700")
+
+-- Игрок заговорил с торговцем
+local talkEvent = getEvent("TalkTrader")
+talkEvent.Event:Connect(function()
+ if hasKey then
+ __rbxl_npc_say(traderRef, "Иди к двери, ключ у тебя!", 3)
+ return
+ end
+ hasKey = true
+ __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 __rbxl_inventory_has("key") 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
+
+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("g13_counter_hint", "[E] Поговорить с торговцем", 50, 75, "#ffe44a", 20)
+ else
+ __rbxl_hud_set("g13_counter_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("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
+
+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("g13_door_hint", "[E] Открыть дверь", 50, 75, "#ffe44a", 20)
+ else
+ __rbxl_hud_set("g13_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)`,
+ 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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 14 — «Собери по тегу»
+ // ═══════════════════════════════════════════════════════════════
+ 'collect-by-tag': (function() {
+ const TOTAL = 7;
+ const overrides = {
+ g14_main: `-- === ИГРА «СОБЕРИ ПО ТЕГАМ» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+local CollectionService = game:GetService("CollectionService")
+local Players = game:GetService("Players")
+local player = Players.LocalPlayer
+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, 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)`,
+ };
+ // Скрипт каждой звезды — одинаковый: на touch → untag + destroy + Fire
+ const starScript = `-- === Скрипт звезды (Lua) ===
+local CollectionService = game:GetService("CollectionService")
+local ReplicatedStorage = game:GetService("ReplicatedStorage")
+local part = script.Parent
+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
+end)`;
+ for (let i = 1; i <= TOTAL; i++) {
+ overrides['g14_star_' + i] = starScript;
+ }
+ return overrides;
+ })(),
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 15 — «Тир»
+ // ═══════════════════════════════════════════════════════════════
+ '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
+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, 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
+ 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)`,
+ };
+ // Скрипт каждой мишени — ClickDetector + взрыв искр + сообщение
+ const targetScript = `-- === Скрипт мишени (Lua) ===
+-- Клик ЛКМ по мишени = выстрел. ClickDetector ловит клик в 3D.
+local ReplicatedStorage = game:GetService("ReplicatedStorage")
+local part = script.Parent
+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 — «Лавовый пол»
+ // ═══════════════════════════════════════════════════════════════
+ 'lava-floor': {
+ 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 секунды.
+-- ВАЖНО: проверяем что игрок НЕ над островком (по 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
+ 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)`,
+ 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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 17 — «Ключ от сундука»
+ // ═══════════════════════════════════════════════════════════════
+ 'key-chest': {
+ 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)
+
+-- Игрок пытается открыть сундук
+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
+ 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 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("TakeKey")
+ if ev then ev:Fire() end
+ part:Destroy()
+end)`,
+ g17_chest: `-- === Скрипт сундука (Lua) ===
+-- Подойти и нажать E чтобы открыть.
+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("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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 18 — «Качели»
+ // ═══════════════════════════════════════════════════════════════
+ 'swing': {
+ g18_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
+
+-- Раскачиваем качели туда-сюда через изменение 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)
+
+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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 19 — «Лифт»
+ // ═══════════════════════════════════════════════════════════════
+ 'elevator': {
+ g19_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
+
+-- Лифт ездит вверх-вниз. 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
+ fired = true
+ local ev = ReplicatedStorage:FindFirstChild("WinReached")
+ if ev then ev:Fire() end
+end)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 20 — «Имена врагов»
+ // ═══════════════════════════════════════════════════════════════
+ 'enemy-names': {
+ 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", 2.5)
+ -- 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
+
+-- Клик по конкретному 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", 2.5)
+ __rbxl_spawn_particles("sparks", enemy.x, 2, enemy.z, 0.4, 1)
+ hitSound:Play()
+ end)
+end`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 21 — «Догонялки»
+ // ═══════════════════════════════════════════════════════════════
+ 'chaser': {
+ g21_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
+
+-- Спавним 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)
+
+-- Финиш сообщает о победе
+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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 22 — «Опасная зона»
+ // ═══════════════════════════════════════════════════════════════
+ 'danger-zone': {
+ 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
+
+part.Touched:Connect(function(hit)
+ local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
+ 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 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
+
+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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 23 — «3 переключателя»
+ // ═══════════════════════════════════════════════════════════════
+ 'switches': (function() {
+ const overrides = {
+ g23_main: `-- === ИГРА «ПЕРЕКЛЮЧАТЕЛИ» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+local TweenService = game:GetService("TweenService")
+local ORDER = { 2, 3, 1 } -- правильный порядок рычагов
+local pressed = {}
+local opened = false
+local won = false
+
+__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 — «Падающий мост»
+ // ═══════════════════════════════════════════════════════════════
+ '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 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)`,
+ };
+ // 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 — «Облёт камеры»
+ // ═══════════════════════════════════════════════════════════════
+ 'flyby-camera': {
+ g25_main: `-- === ИГРА «КАМЕРА-ОБЛЁТ» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+local won = false
+
+-- При старте — облёт уровня камерой по точкам.
+-- 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
+
+-- Когда облёт закончился — отдаём камеру игроку и пишем подсказку
+__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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 26 — «Магнит монет»
+ // ═══════════════════════════════════════════════════════════════
+ 'coin-magnet': (function() {
+ const TOTAL = 8;
+ const overrides = {
+ g26_main: `-- === ИГРА «МАГНИТ МОНЕТ» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+local TOTAL = ${TOTAL}
+local score = 0
+
+__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)`,
+ };
+ // 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 — «Двойной прыжок»
+ // ═══════════════════════════════════════════════════════════════
+ 'double-jump': {
+ g27_main: `-- === ИГРА «ДВОЙНОЙ ПРЫЖОК» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+local Players = game:GetService("Players")
+local RunService = game:GetService("RunService")
+local player = Players.LocalPlayer
+local won = false
+
+-- Включаем игроку двойной прыжок — теперь можно прыгнуть ещё раз в воздухе
+__rbxl_set_double_jump(true)
+__rbxl_show_text("Жми Space ДВАЖДЫ — двойной прыжок!", 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
+
+-- Респаун при падении в пропасть
+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 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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 28 — «Призрачные стены»
+ // ═══════════════════════════════════════════════════════════════
+ '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
+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 фиолетовые стены — клик через ClickDetector делает стену проходимой
+ const wallScript = `-- === Скрипт призрачной стены (Lua) ===
+local part = script.Parent
+local ghost = false
+
+local clickSound = Instance.new("Sound", part)
+clickSound.SoundId = "click"; clickSound.Volume = 0.7
+
+-- ClickDetector — даёт стене кликабельность (как в игре «Тир»)
+local cd = Instance.new("ClickDetector")
+cd.Parent = part
+
+cd.MouseClick: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 — «Магазин»
+ // ═══════════════════════════════════════════════════════════════
+ '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
+
+-- Спавним продавца ЗА прилавком (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()
+ 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 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 — «Квесты»
+ // ═══════════════════════════════════════════════════════════════
+ 'quest-tasks': {
+ g30_main: `-- === ИГРА «КВЕСТ С ЗАДАНИЯМИ» — главный скрипт (Lua) ===
+${SNIPPET_BROADCAST}
+
+local stage = 0 -- 0=не начат, 1=собрать монетку, 2=дойти до флага, 3=вернуться, 4=готово
+
+local function setObjective(text, color)
+ __rbxl_hud_set("objective", "ЦЕЛЬ: " .. text, 50, 8, color or "#ffe066", 24)
+end
+setObjective("подойди к квестодателю и нажми E")
+
+-- Спавним 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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 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 — «Гонка с кругами»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «Платформер с боссом»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «Сбор урожая»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «Прятки от 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 — «Головоломка с ящиками»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «Полоса препятствий»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «Музыкальная игра»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «Башня — стройка»
+ // ═══════════════════════════════════════════════════════════════
+ '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 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
+ -- 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
+ 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 — «Выживание от волн»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «Платформер-приключение»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «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 — «Гонка с препятствиями»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «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 — «Стрелялка-арена»
+ // ═══════════════════════════════════════════════════════════════
+ '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)`,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // ИГРА 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 — «Мультиплеер: Салки»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «Мультиплеер: Гонка»
+ // ═══════════════════════════════════════════════════════════════
+ '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 — «Своя игра» (песочница)
+ // ═══════════════════════════════════════════════════════════════
+ 'make-your-own': {
+ g50_main: `-- === «СВОЯ ИГРА» — твоя песочница (Lua) ===
+--
+-- Это пустая площадка. Здесь ты придумываешь и собираешь
+-- СВОЮ игру с нуля. Удали этот текст и пиши свой код.
+--
+-- С чего начать:
+-- 1. Реши, КАКАЯ это игра (паркур / гонка / стрелялка / квест).
+-- 2. Построй сцену из блоков и примитивов.
+-- 3. Поставь точку спавна.
+-- 4. Добавь цель — финиш, счёт или врагов.
+-- 5. Напиши скрипты, оживляющие игру.
+--
+-- Всё, что нужно, ты уже знаешь из уроков 1-49. Удачи!
+
+__rbxl_show_text("Твоя песочница! Создай свою игру", 4)`,
+ },
+
+ '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)`,
+ },
+};
+
+// ══════════════════════════════════════════════════════════════════
+// Хелперы для генерации часто повторяющихся скриптов
+// ══════════════════════════════════════════════════════════════════
+
+/** Возвращает 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 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
new file mode 100644
index 0000000..ffeb43d
--- /dev/null
+++ b/src/community/docsLang.jsx
@@ -0,0 +1,463 @@
+/**
+ * docsLang.jsx — поддержка вкладок JS/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 (
+ {luaResolved} : null}
+ />
+ );
+}
+
+/**
+ * Инлайн-API-имена в тексте уроков, меняющиеся в зависимости от JS/Lua вкладки.
+ * {txt};
+}
/**
* docsLessons.jsx — тексты уроков для 50 мини-игр (раздел K вики).
@@ -104,7 +134,7 @@ export const LESSONS = {
Главный скрипт считает монетки и проверяет победу.
{`// === ИГРА «СОБЕРИ МОНЕТКИ» — главный скрипт ===
+ {`// === ИГРА «СОБЕРИ МОНЕТКИ» — главный скрипт ===
// Этот скрипт глобальный: считает собранные монетки и проверяет победу.
let score = 0; // сколько монеток собрано
@@ -131,7 +161,7 @@ game.onMessage('coin', () => {
{ x: p.x, y: p.y + 3, z: p.z },
{ duration: 3, count: 3 });
}
-});`}
+});`}
Разберём построчно:
@@ -154,14 +184,14 @@ game.onMessage('coin', () => { касание и сообщает главному скрипту: меня собрали.{`// === Скрипт монетки ===
+ {`// === Скрипт монетки ===
// game.self — это сама монетка, на которой висит скрипт.
game.self.onTouch(() => {
// игрок коснулся монетки — сообщаем главному скрипту
game.broadcast('coin');
game.self.delete(); // монетка исчезает со сцены
-});`}
+});`}
Что происходит: onTouch срабатывает, когда
игрок дотронулся до монетки. Внутри мы шлём
@@ -274,7 +304,7 @@ game.self.onTouch(() => {
Главный скрипт следит за падением и обрабатывает победу.
{`// === ИГРА «ПРЫГАЙ ПО ПЛАТФОРМАМ» — главный скрипт ===
+ {`// === ИГРА «ПРЫГАЙ ПО ПЛАТФОРМАМ» — главный скрипт ===
let won = false; // победа уже была?
@@ -304,7 +334,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 +350,13 @@ game.onMessage('finish', () => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
// Висит на невидимой зоне над зелёной площадкой.
// Игрок встал на площадку — его тело внутри зоны — победа.
game.self.onTouch(() => {
game.broadcast('finish'); // сообщаем главному скрипту о победе
-});`}
+});`}
Когда игрок касается финиша, скрипт шлёт
game.broadcast('finish'). Главный скрипт ловит
@@ -401,7 +431,7 @@ game.self.onTouch(() => {
Следит за падением и победой — как в уроке 2.
{`// === ИГРА «НЕ УПАДИ» — главный скрипт ===
+ {`// === ИГРА «НЕ УПАДИ» — главный скрипт ===
let won = false;
@@ -428,7 +458,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 });
-});`}
+});`}
@@ -436,7 +466,7 @@ game.onMessage('finish', () => { который убирает её через секунду после касания.
{`// === Скрипт исчезающей плитки ===
+ {`// === Скрипт исчезающей плитки ===
let triggered = false; // плитка уже запущена на исчезновение?
@@ -448,7 +478,7 @@ game.self.onTouch(() => {
game.after(1.2, () => {
game.self.delete();
});
-});`}
+});`}
Разберём:
triggered — флажок-защёлка. Игрок может
@@ -532,7 +562,7 @@ game.self.onTouch(() => {
{`// === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт ===
+ {`// === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт ===
game.ui.showText('Подойди к красной кнопке и нажми E', 4);
@@ -544,7 +574,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 });
-});`}
+});`}
@@ -552,7 +582,7 @@ game.onMessage('win', () => { открывает дверь.
{`// === Скрипт кнопки ===
+ {`// === Скрипт кнопки ===
let opened = false;
@@ -570,7 +600,7 @@ game.self.onInteract(() => {
}, {
text: 'Открыть дверь', // подсказка над кнопкой
distance: 4 // на сколько метров подойти
-});`}
+});`}
Разберём:
game.self.onInteract(fn, опции) — это
@@ -592,10 +622,10 @@ game.self.onInteract(() => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win'); // сообщаем главному скрипту о победе
-});`}
+});`}
Запусти игру:
Скрипты совсем простые — лабиринт держится на постройке.
{`// === ИГРА «ЛАБИРИНТ» — главный скрипт ===
+ {`// === ИГРА «ЛАБИРИНТ» — главный скрипт ===
game.ui.showText('Найди выход из лабиринта!', 3);
@@ -700,12 +730,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'); // сообщаем главному скрипту о победе
-});`}
+});`}
{`// === ИГРА «ЦВЕТНЫЕ ПЛИТКИ» — главный скрипт ===
+ {`// === ИГРА «ЦВЕТНЫЕ ПЛИТКИ» — главный скрипт ===
let painted = 0; // сколько плиток раскрашено
const TOTAL = 36; // всего плиток (6×6)
@@ -785,7 +815,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 +823,7 @@ game.onMessage('paint', () => {
{`// === Скрипт цветной плитки ===
+ {`// === Скрипт цветной плитки ===
let painted = false; // плитка уже раскрашена?
@@ -803,7 +833,7 @@ game.self.onTouch(() => {
// меняем цвет плитки на ярко-зелёный
game.scene.setColor(game.self.ref, '#33dd55');
game.broadcast('paint'); // сообщаем главному скрипту о покраске
-});`}
+});`}
Главное здесь:
game.scene.setColor(ref, цвет) — меняет
@@ -874,7 +904,7 @@ game.self.onTouch(() => {
{`// === ИГРА «ПОЙМАЙ ПАДАЮЩЕЕ» — главный скрипт ===
+ {`// === ИГРА «ПОЙМАЙ ПАДАЮЩЕЕ» — главный скрипт ===
let score = 0;
const GOAL = 15; // сколько кубов нужно поймать
@@ -922,7 +952,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 +1035,7 @@ game.onPlayerTouch((e) => {
{`// === ИГРА «БЕГИ К ФИНИШУ» — главный скрипт ===
+ {`// === ИГРА «БЕГИ К ФИНИШУ» — главный скрипт ===
let finished = false;
let time = 0; // прошло секунд
@@ -1032,7 +1062,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 +1078,10 @@ game.onMessage('finish', () => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('finish'); // сообщаем главному скрипту о финише
-});`}
+});`}
Это самый сложный скрипт пока — разберём внимательно.
{`// === ИГРА «СВЕТОФОР» — главный скрипт ===
+ {`// === ИГРА «СВЕТОФОР» — главный скрипт ===
let phase = 'green'; // 'green' (беги) или 'red' (замри)
let won = false;
@@ -1173,7 +1203,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() ставит зелёный цвет и через
@@ -1196,10 +1226,10 @@ game.onMessage('win', () => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win'); // сообщаем главному скрипту о победе
-});`}
+});`}
{`// === ИГРА «ПРЫЖОК-ПРУЖИНА» — главный скрипт ===
+ {`// === ИГРА «ПРЫЖОК-ПРУЖИНА» — главный скрипт ===
let won = false;
@@ -1294,18 +1324,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 });
-});`}
+});`}
Повесь этот скрипт на каждый батут — он одинаковый.
{`// === Скрипт батута ===
+ {`// === Скрипт батута ===
// Игрок встал на батут — мощный подброс вверх.
game.self.onTouch(() => {
game.player.boostJump(3.2); // 3.2 = в 3 раза выше обычного прыжка
game.sound.play('jump');
-});`}
+});`}
game.player.boostJump(сила) — мгновенно
подбрасывает игрока. 1 — как обычный прыжок,
@@ -1320,10 +1350,10 @@ game.self.onTouch(() => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win'); // сообщаем главному скрипту о победе
-});`}
+});`}
{`// === ИГРА «ЭХО-КОМНАТА» — главный скрипт ===
+ {`// === ИГРА «ЭХО-КОМНАТА» — главный скрипт ===
let stepped = 0; // на сколько плиток наступили
const TOTAL = 6;
@@ -1417,7 +1447,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 });
-});`}
+});`}
@@ -1427,7 +1457,7 @@ game.onMessage('finish', () => {
'hit'.
{`// === Скрипт звуковой плитки ===
+ {`// === Скрипт звуковой плитки ===
let used = false; // на эту плитку уже наступали?
@@ -1442,7 +1472,7 @@ game.self.onTouch(() => {
used = true;
game.broadcast('step'); // сообщаем главному скрипту о новой плитке
}
-});`}
+});`}
Разберём:
game.sound.play('coin') — проигрывает
@@ -1462,10 +1492,10 @@ game.self.onTouch(() => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('finish'); // сообщаем главному скрипту о финише
-});`}
+});`}
{`// === ИГРА «ДВЕРЬ ПО КОДУ» — главный скрипт ===
+ {`// === ИГРА «ДВЕРЬ ПО КОДУ» — главный скрипт ===
// СЕКРЕТНЫЙ КОД — порядок кнопок. Поменяй на свой!
const CODE = [3, 1, 4, 2];
@@ -1575,7 +1605,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] — секретный код,
@@ -1609,7 +1639,7 @@ game.onMessage('win', () => {
цифра в press(...) и в подсказке.
{`// === Скрипт кнопки-цифры 1 ===
+ {`// === Скрипт кнопки-цифры 1 ===
game.self.onInteract(() => {
// сообщаем главному скрипту номер нажатой кнопки
@@ -1617,7 +1647,7 @@ game.self.onInteract(() => {
}, {
text: 'Нажать кнопку 1',
distance: 3
-});`}
+});`}
Для «Кнопки_2» поставь {'{ num: 2 }'} и текст
«Нажать кнопку 2», и так далее.
@@ -1625,10 +1655,10 @@ game.self.onInteract(() => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win'); // сообщаем главному скрипту о победе
-});`}
+});`}
{`// === ИГРА «ТОРГОВЕЦ» — главный скрипт ===
+ {`// === ИГРА «ТОРГОВЕЦ» — главный скрипт ===
game.ui.showText('Поговори с торговцем — нажми E у прилавка', 4);
@@ -1740,7 +1770,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', опции) —
@@ -1756,30 +1786,30 @@ game.onMessage('win', () => {
{`// === Скрипт прилавка ===
+ {`// === Скрипт прилавка ===
game.self.onInteract(() => {
game.broadcast('talk'); // сообщаем главному скрипту: говорим с торговцем
}, {
text: 'Поговорить с торговцем',
distance: 4
-});`}
+});`}
{`// === Скрипт двери ===
+ {`// === Скрипт двери ===
game.self.onInteract(() => {
game.broadcast('openDoor'); // сообщаем главному скрипту: открыть дверь
}, {
text: 'Открыть дверь',
distance: 4
-});`}
+});`}
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win'); // сообщаем главному скрипту о победе
-});`}
+});`}
{`// === ИГРА «СОБЕРИ ПО ТЕГАМ» — главный скрипт ===
+ {`// === ИГРА «СОБЕРИ ПО ТЕГАМ» — главный скрипт ===
game.ui.showText('Собери все ЖЁЛТЫЕ звёзды!', 3);
@@ -1897,7 +1927,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, ...)
@@ -1918,13 +1948,13 @@ game.onMessage('collected', () => {
Этот скрипт повесь на каждую из 7 звёзд.
{`// === Скрипт звезды ===
+ {`// === Скрипт звезды ===
game.self.onTouch(() => {
// снимаем тег и удаляем звезду
game.scene.untag(game.self.ref, 'звезда');
game.self.delete();
game.broadcast('collected'); // сообщаем главному скрипту о сборе
-});`}
+});`}
Что происходит при касании:
game.scene.untag(game.self.ref, 'звезда') —
@@ -2015,7 +2045,7 @@ game.self.onTouch(() => {
{`// === ИГРА «ТИР» — главный скрипт ===
+ {`// === ИГРА «ТИР» — главный скрипт ===
let score = 0;
const TOTAL = 8;
@@ -2036,7 +2066,7 @@ game.onMessage('hit', () => {
game.scene.spawnParticles('confetti',
{ x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 });
}
-});`}
+});`}
Здесь всё знакомо:
score — счётчик попаданий;{`// === Скрипт мишени ===
+ {`// === Скрипт мишени ===
// Клик по 3D-объекту = выстрел в него.
game.self.onClick(() => {
@@ -2062,7 +2092,7 @@ game.self.onClick(() => {
{ count: 1, color: '#ff6633' });
game.self.delete(); // мишень сбита
game.broadcast('hit'); // сообщаем главному скрипту о попадании
-});`}
+});`}
Разберём:
game.self.onClick(() => {'{...}'}) —
@@ -2170,7 +2200,7 @@ game.self.onClick(() => {
{`// === ИГРА «ЛАВА-ПОЛ» — главный скрипт ===
+ {`// === ИГРА «ЛАВА-ПОЛ» — главный скрипт ===
let won = false;
@@ -2196,7 +2226,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 следит, не провалился ли игрок
совсем низко — тогда возвращает его на старт;{`// === Скрипт лавы ===
+ {`// === Скрипт лавы ===
// Игрок коснулся лавы — урон. У damage есть защита (i-frames),
// так что урон не каждый кадр, а раз в ~0.5 секунды.
game.self.onTouch(() => {
game.player.damage(20);
game.sound.play('hit');
-});`}
+});`}
Разберём:
game.player.damage(20) — отнимает у игрока
@@ -2229,10 +2259,10 @@ game.self.onTouch(() => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win'); // сообщаем главному скрипту о победе
-});`}
+});`}
{`// === ИГРА «КЛЮЧ И СУНДУК» — главный скрипт ===
+ {`// === ИГРА «КЛЮЧ И СУНДУК» — главный скрипт ===
game.ui.showText('Найди ключ и открой сундук!', 3);
@@ -2327,7 +2357,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', ...) — игрок
@@ -2351,11 +2381,11 @@ game.onMessage('openChest', () => {
{`// === Скрипт ключа ===
+ {`// === Скрипт ключа ===
game.self.onTouch(() => {
game.broadcast('takeKey'); // сообщаем главному скрипту: ключ найден
game.self.delete(); // ключ подобран
-});`}
+});`}
Игрок коснулся ключа — скрипт шлёт сообщение
game.broadcast('takeKey') (главный скрипт
@@ -2365,10 +2395,10 @@ game.self.onTouch(() => {
{`// === Скрипт сундука ===
+ {`// === Скрипт сундука ===
game.self.onInteract(() => {
game.broadcast('openChest'); // сообщаем главному скрипту: открыть сундук
-}, { text: 'Открыть сундук', distance: 4 });`}
+}, { text: 'Открыть сундук', distance: 4 });`}
Когда игрок подходит ближе чем на 4 метра, над сундуком появляется подсказка «Открыть сундук». Нажатие @@ -2452,7 +2482,7 @@ game.self.onInteract(() => { на петлю и раскачивает.
{`// === ИГРА «КАЧЕЛИ» — главный скрипт ===
+ {`// === ИГРА «КАЧЕЛИ» — главный скрипт ===
let won = false;
@@ -2493,7 +2523,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 секунды
@@ -2522,10 +2552,10 @@ game.onMessage('win', () => {
победу.
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win');
-});`}
+});`}
{`// === ИГРА «ЛИФТ» — главный скрипт ===
+ {`// === ИГРА «ЛИФТ» — главный скрипт ===
let won = false;
@@ -2634,7 +2664,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 секунды:
@@ -2661,10 +2691,10 @@ game.onMessage('win', () => {
из разных «песочниц» общаются.
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win');
-});`}
+});`}
{`// === ИГРА «ИМЕНА НАД ВРАГАМИ» — главный скрипт ===
+ {`// === ИГРА «ИМЕНА НАД ВРАГАМИ» — главный скрипт ===
game.ui.showText('Победи всех врагов! Кликай по ним', 3);
@@ -2787,7 +2817,7 @@ enemyData.forEach((d) => {
game.scene.spawnParticles('sparks', npc.position, { duration: 0.4 });
}
});
-});`}
+});`}
Разберём по частям. Сначала про создание врагов:
enemyData — список врагов: у каждого имя,
@@ -2891,7 +2921,7 @@ enemyData.forEach((d) => {
не догнал ли он игрока.
{`// === ИГРА «ПРЕСЛЕДОВАТЕЛЬ» — главный скрипт ===
+ {`// === ИГРА «ПРЕСЛЕДОВАТЕЛЬ» — главный скрипт ===
let won = false;
@@ -2933,7 +2963,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', опции) —
@@ -2957,10 +2987,10 @@ game.onMessage('win', () => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win');
-});`}
+});`}
Когда игрок касается финиша, скрипт шлёт сообщение
game.broadcast('win'). Главный скрипт ловит
@@ -3034,7 +3064,7 @@ game.self.onTouch(() => {
{`// === ИГРА «ЗОНА ОПАСНОСТИ» — главный скрипт ===
+ {`// === ИГРА «ЗОНА ОПАСНОСТИ» — главный скрипт ===
let inZone = false; // игрок сейчас в красной зоне?
let won = false;
@@ -3065,7 +3095,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 — флажок «игрок внутри красной
@@ -3086,7 +3116,7 @@ game.onMessage('win', () => {
{`// === Скрипт зоны опасности ===
+ {`// === Скрипт зоны опасности ===
// onTouch — игрок вошёл, onUntouch — вышел.
game.self.onTouch(() => {
@@ -3094,7 +3124,7 @@ game.self.onTouch(() => {
});
game.self.onUntouch(() => {
game.broadcast('zone-leave');
-});`}
+});`}
Здесь важна пара событий: onTouch
срабатывает, когда игрок входит в зону, а
@@ -3104,13 +3134,13 @@ game.self.onUntouch(() => {
{`// === Скрипт аптечки ===
+ {`// === Скрипт аптечки ===
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 единиц здоровья. Аптечку взяли один раз —
@@ -3119,10 +3149,10 @@ game.self.onTouch(() => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win');
-});`}
+});`}
{`// === ИГРА «ПЕРЕКЛЮЧАТЕЛИ» — главный скрипт ===
+ {`// === ИГРА «ПЕРЕКЛЮЧАТЕЛИ» — главный скрипт ===
// правильный порядок рычагов
const ORDER = [2, 3, 1];
@@ -3230,7 +3260,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] — секретный порядок:
@@ -3258,10 +3288,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', а второй кусок —
@@ -3278,10 +3308,10 @@ game.self.onInteract(() => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win');
-});`}
+});`}
{`// === ИГРА «ПАДАЮЩИЙ МОСТ» — главный скрипт ===
+ {`// === ИГРА «ПАДАЮЩИЙ МОСТ» — главный скрипт ===
let won = false;
@@ -3373,7 +3403,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 — провалился
@@ -3386,14 +3416,14 @@ game.onMessage('win', () => {
Этот скрипт вешается на каждую доску моста.
{`// === Скрипт доски моста ===
+ {`// === Скрипт доски моста ===
let cracking = false;
game.self.onTouch(() => {
if (cracking) return;
cracking = true;
game.sound.play('click');
game.after(1, () => { game.self.delete(); });
-});`}
+});`}
Разберём:
cracking — флажок-защёлка. Игрок может
@@ -3414,10 +3444,10 @@ game.self.onTouch(() => {
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win');
-});`}
+});`}
{`// === ИГРА «КАМЕРА-ОБЛЁТ» — главный скрипт ===
+ {`// === ИГРА «КАМЕРА-ОБЛЁТ» — главный скрипт ===
let won = false;
@@ -3509,7 +3539,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([точки], опции) —
@@ -3537,10 +3567,10 @@ game.onMessage('win', () => {
из разных «песочниц» общаются между собой.
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win');
-});`}
+});`}
{`// === ИГРА «МАГНИТ МОНЕТ» — главный скрипт ===
+ {`// === ИГРА «МАГНИТ МОНЕТ» — главный скрипт ===
let score = 0;
const TOTAL = 8;
@@ -3622,7 +3652,7 @@ game.onMessage('coin', () => {
game.scene.spawnParticles('confetti',
{ x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 });
}
-});`}
+});`}
Главный скрипт простой — он только считает собранные монетки и проверяет победу. Каждая монетка работает @@ -3635,7 +3665,7 @@ game.onMessage('coin', () => {
Этот скрипт вешается на каждую монетку.
{`// === Скрипт магнитной монетки ===
+ {`// === Скрипт магнитной монетки ===
let flying = false; // монетка уже летит к игроку?
let taken = false;
@@ -3662,7 +3692,7 @@ game.onTick(() => {
{ x: p.x, y: p.y + 1, z: p.z },
{ duration: 0.5, easing: 'ease' });
}
-});`}
+});`}
Разберём по частям:
game.self.position и
@@ -3753,7 +3783,7 @@ game.onTick(() => {
{`// === ИГРА «ДВОЙНОЙ ПРЫЖОК» — главный скрипт ===
+ {`// === ИГРА «ДВОЙНОЙ ПРЫЖОК» — главный скрипт ===
let won = false;
@@ -3781,7 +3811,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) — даёт
@@ -3806,10 +3836,10 @@ game.onMessage('win', () => {
из разных «песочниц» общаются.
{`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('win');
-});`}
+});`}
{`// === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» — главный скрипт ===
+ {`// === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» — главный скрипт ===
game.ui.showText('Кликай по фиолетовым стенам — пройди сквозь!', 4);
@@ -3885,7 +3915,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' от финиша через
@@ -3896,7 +3926,7 @@ game.onMessage('win', () => {
Этот скрипт вешается на каждую стену.
{`// === Скрипт призрачной стены ===
+ {`// === Скрипт призрачной стены ===
let ghost = false;
game.self.onClick(() => {
@@ -3907,7 +3937,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) — функция внутри
@@ -4000,7 +4030,7 @@ game.self.onTouch(() => {
продаёт ключ, открывает дверь.
{`// === ИГРА «МАГАЗИН» — главный скрипт ===
+ {`// === ИГРА «МАГАЗИН» — главный скрипт ===
let coins = 0;
const PRICE = 5; // ключ стоит 5 монет
@@ -4051,7 +4081,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 });
-});`}
+});`}
Каждый объект магазина — монетка, прилавок, дверь, финиш — работает в своей «песочнице» и не видит переменные @@ -4081,23 +4111,23 @@ game.onMessage('win', () => {
{`// === Скрипт монетки ===
+ {`// === Скрипт монетки ===
game.self.onTouch(() => {
game.broadcast('coin');
game.self.delete();
-});`}
+});`}
{`// === Скрипт прилавка ===
+ {`// === Скрипт прилавка ===
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. Подошёл к прилавку и нажал @@ -4178,7 +4208,7 @@ game.self.onTouch(() => { по заданиям.
{`// === ИГРА «КВЕСТ С ЗАДАНИЯМИ» — главный скрипт ===
+ {`// === ИГРА «КВЕСТ С ЗАДАНИЯМИ» — главный скрипт ===
// этап квеста: 0=не начат, 1=собрать монетку, 2=дойти до флага,
// 3=вернуться к NPC, 4=готово
@@ -4226,7 +4256,7 @@ game.onMessage('flag-done', () => {
stage = 3;
game.sound.play('pickup');
game.ui.showText('Квест: вернись к квестодателю', 3);
-});`}
+});`}
Главное здесь — переменная stage:
stage хранит, на каком шаге квеста
@@ -4256,23 +4286,23 @@ game.onMessage('flag-done', () => {
{`// === Скрипт квестодателя ===
+ {`// === Скрипт квестодателя ===
game.self.onInteract(() => {
game.broadcast('talk');
-}, { text: 'Поговорить', distance: 4 });`}
+}, { text: 'Поговорить', distance: 4 });`}
{`// === Скрипт квест-монетки ===
+ {`// === Скрипт квест-монетки ===
game.self.onTouch(() => {
game.broadcast('coin-done');
game.self.delete();
-});`}
+});`}
{`// === Скрипт квест-флага ===
+ {`// === Скрипт квест-флага ===
game.self.onTouch(() => {
game.broadcast('flag-done');
-});`}
+});`}
Это большой скрипт — разберём его по частям.
{`// === ИГРА «ЗАЩИТА БАЗЫ» — главный скрипт ===
+ {`// === ИГРА «ЗАЩИТА БАЗЫ» — главный скрипт ===
let killed = 0; // сколько врагов уничтожено
let leaked = 0; // сколько врагов дошло до базы
@@ -4398,7 +4428,7 @@ game.every(2, () => {
}
}
});
-});`}
+});`}
Как появляются волны врагов:
game.every(2, ...) — каждые 2 секунды
@@ -4490,7 +4520,7 @@ game.every(2, () => {
{`// === ИГРА «ГОНКА С КРУГАМИ» — главный скрипт ===
+ {`// === ИГРА «ГОНКА С КРУГАМИ» — главный скрипт ===
const LAPS = 2; // сколько кругов проехать
const CP_COUNT = 4; // чекпоинтов на круге
@@ -4533,7 +4563,7 @@ game.onMessage('checkpoint', (d) => {
game.ui.showText('Круг ' + lap + ' из ' + LAPS + '!', 2);
}
}
-});`}
+});`}
Главное здесь — порядок чекпоинтов:
game.onMessage('checkpoint', (d) => {'{...}'}) —
@@ -4565,10 +4595,10 @@ game.onMessage('checkpoint', (d) => {
номер. Вот скрипт первого чекпоинта:
{`// === Скрипт чекпоинта 1 ===
+ {`// === Скрипт чекпоинта 1 ===
game.self.onTouch(() => {
game.broadcast('checkpoint', { num: 1 });
-});`}
+});`}
Чекпоинт шлёт сообщение
game.broadcast('checkpoint', {'{ num: 1 }'}):
@@ -4645,7 +4675,7 @@ game.self.onTouch(() => {
{`// === ИГРА «ПЛАТФОРМЕР С БОССОМ» — главный скрипт ===
+ {`// === ИГРА «ПЛАТФОРМЕР С БОССОМ» — главный скрипт ===
let won = false;
let bossSpawned = false;
@@ -4701,7 +4731,7 @@ game.onTick(() => {
}
});
}
-});`}
+});`}
Разберём по частям. Сначала — паркур:
game.onTick следит за падением: упал
@@ -4792,7 +4822,7 @@ game.onTick(() => {
{`// === ИГРА «СБОР УРОЖАЯ» — главный скрипт ===
+ {`// === ИГРА «СБОР УРОЖАЯ» — главный скрипт ===
let harvested = 0;
const GOAL = 6;
@@ -4812,7 +4842,7 @@ game.onMessage('harvested', () => {
game.scene.spawnParticles('confetti',
{ x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 });
}
-});`}
+});`}
Главный скрипт считает собранные растения и проверяет победу. Каждое растение работает в своей «песочнице», @@ -4826,7 +4856,7 @@ game.onMessage('harvested', () => {
Этот скрипт вешается на каждое растение.
{`// === Скрипт растения ===
+ {`// === Скрипт растения ===
let ripe = false; // растение выросло (спелое)?
let picked = false;
@@ -4851,7 +4881,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 }'}, опции) —
@@ -4943,7 +4973,7 @@ game.self.onInteract(() => {
Вся игра — в одном скрипте. Разберём его.
{`// === ИГРА «ПРЯТКИ ОТ NPC» — главный скрипт ===
+ {`// === ИГРА «ПРЯТКИ ОТ NPC» — главный скрипт ===
let time = 0;
const SURVIVE = 40; // продержись 40 секунд
@@ -4982,7 +5012,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(...) создаёт
@@ -5076,7 +5106,7 @@ game.onTick((dt) => {
момент победы.
{`// === ИГРА «ГОЛОВОЛОМКА С ЯЩИКАМИ» — главный скрипт ===
+ {`// === ИГРА «ГОЛОВОЛОМКА С ЯЩИКАМИ» — главный скрипт ===
// для каждого ящика — на какой плите он сейчас (true/false)
const onPlate = [false, false, false];
@@ -5100,7 +5130,7 @@ game.onMessage('box', (d) => {
game.scene.spawnParticles('confetti',
{ x: p.x, y: p.y + 3, z: p.z }, { duration: 3, count: 3 });
}
-});`}
+});`}
Разберём:
onPlate — массив из трёх «галочек»: стоит
@@ -5119,7 +5149,7 @@ game.onMessage('box', (d) => {
поменяй число i в сообщении на 1 и 2).
{`// === Скрипт ящика 1 ===
+ {`// === Скрипт ящика 1 ===
// ряд позиций по Z, по которым прыгает ящик
const ROW = [-6, -3, 0, 3, 6];
@@ -5133,7 +5163,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);{`// === ИГРА «ПОЛОСА ПРЕПЯТСТВИЙ» — главный скрипт ===
+ {`// === ИГРА «ПОЛОСА ПРЕПЯТСТВИЙ» — главный скрипт ===
let won = false;
@@ -5266,7 +5296,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, ...) — ждём чуть-чуть,
@@ -5287,16 +5317,16 @@ game.onMessage('finish', () => {
{`// === Скрипт шипа ===
+ {`// === Скрипт шипа ===
game.self.onTouch(() => {
game.player.damage(25);
game.sound.play('hit');
-});`}
+});`}
{`// === Скрипт чекпоинта ===
+ {`// === Скрипт чекпоинта ===
game.self.onTouch(() => {
game.broadcast('checkpoint');
-});`}
+});`}
{`// === Скрипт финиша ===
game.self.onTouch(() => {
@@ -5375,7 +5405,7 @@ game.self.onTouch(() => {
верно ли игрок повторяет.
- {`// === ИГРА «МУЗЫКАЛЬНАЯ ИГРА» — главный скрипт ===
+ {`// === ИГРА «МУЗЫКАЛЬНАЯ ИГРА» — главный скрипт ===
const SOUNDS = ['coin', 'jump', 'click', 'hit']; // плитки 1..4
// загаданная последовательность из 5 нот
@@ -5421,7 +5451,7 @@ game.onMessage('press', (d) => {
game.ui.showText('Ошибка! Слушай и пробуй снова.', 2);
game.sound.play('lose');
}
-});`}
+});`}
Разберём:
SEQ = [1, 3, 2, 4, 1] — загаданная мелодия:
@@ -5444,13 +5474,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 плитка играет
свой звук, вспыхивает искрами и шлёт
@@ -5531,7 +5561,7 @@ game.self.onInteract(() => {
Шаг 2. Главный скрипт
- {`// === ИГРА «БАШНЯ — СТРОЙКА» — главный скрипт ===
+ {`// === ИГРА «БАШНЯ — СТРОЙКА» — главный скрипт ===
const STEPS = 8;
let placed = 0; // сколько блоков поставлено
@@ -5561,7 +5591,7 @@ game.onMessage('place', (d) => {
} else {
game.ui.showText('Блок ' + placed + ' из ' + STEPS, 1.5);
}
-});`}
+});`}
Разберём:
placed — сколько блоков уже стоит;
@@ -5580,7 +5610,7 @@ game.onMessage('place', (d) => {
у остальных поменяй число в place.
- {`// === Скрипт места под блок 1 ===
+ {`// === Скрипт места под блок 1 ===
let built = false;
game.self.onInteract(() => {
if (built) return;
@@ -5591,7 +5621,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) — сквозь блок
@@ -5672,7 +5702,7 @@ game.self.onInteract(() => {
и считает врагов.
- {`// === ИГРА «ВЫЖИВАНИЕ ОТ ВОЛН» — главный скрипт ===
+ {`// === ИГРА «ВЫЖИВАНИЕ ОТ ВОЛН» — главный скрипт ===
const WAVES = 3; // всего волн
let wave = 0;
@@ -5729,7 +5759,7 @@ function startWave() {
}
}
-game.after(2, startWave); // первая волна через 2 секунды`}
+game.after(2, startWave); // первая волна через 2 секунды`}
Разберём:
startWave() — функция одной волны. Она
@@ -5818,7 +5848,7 @@ game.after(2, startWave); // первая волна через 2 секунд
Шаг 2. Главный скрипт
- {`// === ИГРА «ПЛАТФОРМЕР-ПРИКЛЮЧЕНИЕ» — главный скрипт ===
+ {`// === ИГРА «ПЛАТФОРМЕР-ПРИКЛЮЧЕНИЕ» — главный скрипт ===
let coins = 0;
let won = false;
@@ -5860,7 +5890,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', ...) — пришла
@@ -5880,21 +5910,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');
-});`}
+});`}
Монетка при касании засчитывается и исчезает. Чекпоинт
сохраняет место. Сокровище зовёт победу.
@@ -5967,7 +5997,7 @@ game.self.onTouch(() => {
Шаг 2. Главный скрипт
- {`// === ИГРА «RPG-ДЕРЕВНЯ» — главный скрипт ===
+ {`// === ИГРА «RPG-ДЕРЕВНЯ» — главный скрипт ===
// этап: 0=начало, 1=ищем амулет, 2=несём кузнецу, 3=готово
let stage = 0;
@@ -6019,7 +6049,7 @@ game.onMessage('smithTalk', () => {
} else {
smith.say('Принеси мне амулет — поговори со старостой.', 4);
}
-});`}
+});`}
Разберём:
stage — переменная-этап: 0 начало,
@@ -6038,21 +6068,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 создаётся
скриптом и стоит рядом с тумбой. Игрок жмёт
@@ -6125,7 +6155,7 @@ game.self.onTouch(() => {
Шаг 2. Главный скрипт
- {`// === ИГРА «ГОНКА С ПРЕПЯТСТВИЯМИ» — главный скрипт ===
+ {`// === ИГРА «ГОНКА С ПРЕПЯТСТВИЯМИ» — главный скрипт ===
let time = 0;
let won = false;
@@ -6166,7 +6196,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
@@ -6186,20 +6216,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 — медленно. После эффекта всегда
@@ -6272,7 +6302,7 @@ game.self.onTouch(() => {
стрелять и проверяет, кто победил.
- {`// === ИГРА «TOWER DEFENSE» — главный скрипт ===
+ {`// === ИГРА «TOWER DEFENSE» — главный скрипт ===
let leaked = 0; // врагов прошло до базы
const MAX_LEAK = 8;
@@ -6355,7 +6385,7 @@ game.every(0.5, () => {
}
}
}
-});`}
+});`}
Разберём:
towers и enemies — два списка:
@@ -6376,7 +6406,7 @@ game.every(0.5, () => {
Шаг 3. Скрипт площадки под башню
- {`// === Скрипт площадки под башню ===
+ {`// === Скрипт площадки под башню ===
let built = false;
game.self.onInteract(() => {
if (built) return;
@@ -6389,7 +6419,7 @@ game.self.onInteract(() => {
color: '#ffcc33',
});
game.broadcast('addTower', { x: pos.x, z: pos.z });
-}, { text: 'Построить башню', distance: 4 });`}
+}, { text: 'Построить башню', distance: 4 });`}
При нажатии E скрипт создаёт
жёлтый цилиндр-башню над площадкой и шлёт сообщение
@@ -6496,7 +6526,7 @@ game.scene.spawn('user:3', {
Шаг 2. Главный скрипт
- {`// === ИГРА «СТРЕЛЯЛКА-АРЕНА» — главный скрипт ===
+ {`// === ИГРА «СТРЕЛЯЛКА-АРЕНА» — главный скрипт ===
let score = 0;
const GOAL = 15;
@@ -6558,7 +6588,7 @@ game.every(1.8, () => {
}
}
});
-});`}
+});`}
Разберём:
game.onHpChange((e) ={'>'} ...) —
@@ -6641,7 +6671,7 @@ game.every(1.8, () => {
Шаг 2. Главный скрипт
- {`// === ИГРА «КЛИКЕР» — главный скрипт ===
+ {`// === ИГРА «КЛИКЕР» — главный скрипт ===
let points = 0; // очки
let perClick = 1; // очков за клик
@@ -6706,7 +6736,7 @@ game.onMessage('buyAuto', () => {
game.ui.score = points;
game.sound.play('pickup');
game.ui.showText('Авто-доход: +' + autoIncome + ' в секунду', 2);
-});`}
+});`}
Разберём:
points — очки, perClick —
@@ -6726,22 +6756,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 });`}
Главная идея кликера: сначала кликаешь руками, потом
покупаешь улучшения — и игра «играет сама». Это экономика:
@@ -6811,7 +6841,7 @@ game.self.onInteract(() => {
Шаг 2. Главный скрипт
- {`// === ИГРА «КВЕСТ-ПОБЕГ» — главный скрипт ===
+ {`// === ИГРА «КВЕСТ-ПОБЕГ» — главный скрипт ===
let pressed = 0; // сколько кнопок нажато
const TOTAL = 3;
@@ -6843,7 +6873,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 — счётчик нажатых кнопок,
@@ -6859,19 +6889,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') и больше
@@ -6950,7 +6980,7 @@ game.self.onTouch(() => {
Шаг 2. Главный скрипт
- {`// === ИГРА «МУЛЬТИПЛЕЕР: САЛКИ» — главный скрипт ===
+ {`// === ИГРА «МУЛЬТИПЛЕЕР: САЛКИ» — главный скрипт ===
//
// Это МУЛЬТИПЛЕЕРНАЯ игра. Чтобы играть с друзьями, опубликуй её
// с галочкой «Мультиплеер» — тогда в комнату смогут зайти несколько
@@ -6987,7 +7017,7 @@ game.room.onChange('tagger', (taggerId) => {
} else {
game.ui.showText('Убегай от водящего!', 3);
}
-});`}
+});`}
Разберём:
game.players.count() — сколько игроков
@@ -7078,7 +7108,7 @@ game.room.onChange('tagger', (taggerId) => {
Шаг 2. Главный скрипт
- {`// === ИГРА «МУЛЬТИПЛЕЕР: ГОНКА» — главный скрипт ===
+ {`// === ИГРА «МУЛЬТИПЛЕЕР: ГОНКА» — главный скрипт ===
//
// Мультиплеерная гонка. Чтобы соревноваться с друзьями — опубликуй
// игру с галочкой «Мультиплеер».
@@ -7118,7 +7148,7 @@ game.onMessage('finish', () => {
} else {
game.ui.showText('Финиш! Но кто-то был быстрее.', 4);
}
-});`}
+});`}
Разберём:
game.room.get('winner') — читаем общую
@@ -7138,10 +7168,10 @@ game.onMessage('finish', () => {
Шаг 3. Скрипт финиша
- {`// === Скрипт финиша ===
+ {`// === Скрипт финиша ===
game.self.onTouch(() => {
game.broadcast('finish');
-});`}
+});`}
Когда любой игрок касается финиша, скрипт шлёт сообщение
game.broadcast('finish') — а главный скрипт
@@ -7251,8 +7281,10 @@ game.self.onTouch(() => {
И обязательно покажи игроку, когда он победил —
- надписью game.ui.showText('Победа!', 5),
- звуком game.sound.play('win') и конфетти.
+ надписью ,
+ звуком и конфетти.
Шаг 4. Напиши скрипты
@@ -7260,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. Проверяй и улучшай
diff --git a/src/components/RbxlImportModal.jsx b/src/components/RbxlImportModal.jsx
index 8d137ec..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 }) {
@@ -26,25 +24,22 @@ 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');
+ // Режим GUI: 'all' — все, 'screen-only' — только ScreenGui (HUD),
+ // 'skip' — не импортировать. Старые карты часто имеют 200+ BillboardGui
+ // (вывески города), что вешает рендер.
+ const [guiMode, setGuiMode] = useState('all');
const fileInputRef = useRef(null);
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);
+ setScriptsMode('disabled');
+ setGuiMode('all');
};
const handleClose = () => { reset(); onClose?.(); };
@@ -88,7 +83,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, guiMode });
onCreated?.(result);
handleClose();
// редирект на редактор
@@ -175,6 +170,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 классов)
@@ -206,6 +224,98 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
+ {report.scripts_total > 0 && (
+
+
+ Что делать со скриптами ({report.scripts_total} шт.)?
+
+
+
+
+
+ )}
+
+ {(() => {
+ 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) => (
+
+ ))}
+
+ );
+ })()}
+
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,
+ onCancel, // если задан — вызывается при клике на «cancel» вместо тихого закрытия
+ onClose,
+}) {
+ const handleCancel = () => {
+ try { onCancel?.(); } finally { onClose?.(); }
+ };
+ const confirmBtnRef = useRef(null);
+
+ useEffect(() => {
+ // Автофокус на кнопке подтверждения
+ const t = setTimeout(() => confirmBtnRef.current?.focus(), 50);
+ const onKey = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); onClose?.(); }
+ else if (e.key === 'Enter') {
+ // Enter — confirm только если кнопка в фокусе или ничего не в фокусе
+ if (document.activeElement === confirmBtnRef.current || document.activeElement?.tagName === 'BODY') {
+ e.preventDefault();
+ handleConfirm();
+ }
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => { clearTimeout(t); window.removeEventListener('keydown', onKey); };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleConfirm = () => {
+ try { onConfirm?.(); } finally { onClose?.(); }
+ };
+
+ return (
+
+
+ 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/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx
index 531aaed..a7c23d1 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,39 @@ 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);
+ // 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 = (
+
+ {isLua ? 'LUA' : 'JS'}
+
+ );
return (
+
+
+ Заливка теней
+ {(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/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx
index ccebae2..b546426 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';
@@ -43,6 +43,7 @@ import KubikonDesktopOnlyStub from '../community/KubikonDesktopOnlyStub';
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
import cl from './KubikonEditor.module.css';
import Icon from './Icon';
+import ConfirmModal from './ConfirmModal';
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины → авто-сохранение
@@ -512,6 +513,8 @@ const KubikonEditor = () => {
// BillboardEditorModal — открывается из инспектора при клике
// «Редактировать табличку…». Содержит primitiveData выделенного билборда.
const [billboardEditorData, setBillboardEditorData] = useState(null);
+ // ConfirmModal — кастомная модалка вместо window.confirm.
+ const [confirmState, setConfirmState] = useState(null);
// Bumper для обновления списков в Toolbox после edit/settings/delete.
const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0);
// Bump-счётчик: инкрементируется при создании/очистке гладкого
@@ -2043,13 +2046,19 @@ const KubikonEditor = () => {
// Флаш ScriptEditor — без этого 600мс свежих правок не успеют
// попасть в _scripts[]/dirtyRef и confirm-диалог не покажется.
try { scriptEditorFlushRef.current?.(); } catch (_) {}
- // Несохранённые изменения — спрашиваем
+ // Несохранённые изменения — кастомная модалка с 3 кнопками:
+ // Сохранить (по умолчанию), Не сохранять, Отмена.
if (dirtyRef.current) {
- const ok = window.confirm('Есть несохранённые изменения. Сохранить перед выходом?');
- if (ok) {
- doSave().finally(() => navigate('/'));
- return;
- }
+ setConfirmState({
+ title: 'Несохранённые изменения',
+ message: 'Сохранить проект перед выходом? Если выйти без сохранения — последние правки пропадут.',
+ confirmLabel: 'Сохранить и выйти',
+ cancelLabel: 'Выйти без сохранения',
+ confirmTone: 'primary',
+ onConfirm: () => doSave().finally(() => navigate('/')),
+ onCancel: () => navigate('/'), // выйти без сохранения
+ });
+ return;
}
navigate('/');
};
@@ -3324,10 +3333,43 @@ const KubikonEditor = () => {
scriptId={sc.id}
value={sc.code}
target={sc.target}
+ language={sc.language || 'js'}
flushRef={scriptEditorFlushRef}
isSoloRunning={soloScriptId === sc.id}
+ onLanguageChange={(lang, currentEditorCode) => {
+ // Два слота: code_js и code_lua живут в самом скрипте.
+ // При переключении: сохраняем текущий код в слот ТЕКУЩЕГО
+ // языка, достаём слот ЦЕЛЕВОГО языка (или шаблон если пусто).
+ const fromLang = sc.language === 'lua' ? 'lua' : 'js';
+ if (fromLang === lang) return;
+ const fromSlotKey = fromLang === 'lua' ? 'code_lua' : 'code_js';
+ const toSlotKey = lang === 'lua' ? 'code_lua' : 'code_js';
+ // Сохраняем текущий редактируемый код в слот текущего языка
+ const savedSlots = {
+ ...(sc.code_js !== undefined ? { code_js: sc.code_js } : {}),
+ ...(sc.code_lua !== undefined ? { code_lua: sc.code_lua } : {}),
+ [fromSlotKey]: currentEditorCode || '',
+ };
+ // Достаём слот целевого языка или подставляем шаблон
+ let nextCode = savedSlots[toSlotKey];
+ if (nextCode === undefined || nextCode === '') {
+ nextCode = lang === 'lua'
+ ? (sc.target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL)
+ : JS_TEMPLATE_GLOBAL;
+ }
+ sceneRef.current?.upsertScript(
+ sc.id, nextCode, undefined, undefined, lang, savedSlots
+ );
+ setScriptsList(sceneRef.current?.getScripts?.() || []);
+ markDirty();
+ }}
onSave={(code) => {
- 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();
}}
@@ -4187,6 +4229,13 @@ const KubikonEditor = () => {
setBillboardEditorData(null);
}}
/>
+ {/* Кастомная модалка подтверждения вместо window.confirm. */}
+ {confirmState && (
+ setConfirmState(null)}
+ />
+ )}
);
};
diff --git a/src/editor/ScriptEditor.jsx b/src/editor/ScriptEditor.jsx
index faeb2a1..cc7adec 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-редактор кода скрипта в табе.
@@ -34,7 +36,50 @@ 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 когда текущий код выглядит «пустым».
+export const LUA_TEMPLATE_PART = `-- Скрипт привязан к Part. script.Parent = эта часть.
+local part = script.Parent
+print("Скрипт детали", part.Name, "запущен")
+
+part.Touched:Connect(function(hit)
+ print("Касание:", hit.Name)
+end)
+`;
+export const LUA_TEMPLATE_GLOBAL = `-- Глобальный Lua-скрипт. Доступ к game.* API через Roblox-обёртку.
+local Players = game:GetService("Players")
+print("Привет, Рублокс! Lua-скрипты работают.")
+
+-- Здороваемся со всеми кто уже в игре + кто заходит позже
+for _, player in ipairs(Players:GetPlayers()) do
+ print("Игрок в игре:", player.Name)
+end
+Players.PlayerAdded:Connect(function(player)
+ print("Зашёл игрок:", player.Name)
+end)
+`;
+export const JS_TEMPLATE_GLOBAL = `// Глобальный JS-скрипт. Подробнее: см. game.* API в /справочник.
+game.onPlayerJoined((player) => {
+ game.chat.say('Привет, ' + player.name + '!');
+});
+`;
+
+function isCodeLikelyEmptyTemplate(code) {
+ if (!code) return true;
+ const trimmed = code.trim();
+ if (trimmed.length === 0) return true;
+ // Содержит ТОЛЬКО комментарии и пустые строки
+ const lines = trimmed.split('\n').map(l => l.trim()).filter(Boolean);
+ return lines.every(l =>
+ l.startsWith('//') || l.startsWith('--') ||
+ l.startsWith('/*') || l.startsWith('*/') || l.startsWith('*')
+ );
+}
+
+function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, language, onLanguageChange, onClose, flushRef }) {
+ const currentLanguage = language === 'lua' ? 'lua' : 'js';
+ // Кастомная модалка подтверждения смены языка (вместо window.confirm)
+ const [confirmState, setConfirmState] = useState(null);
// Локальный буфер кода — то что в редакторе сейчас.
// Синхронизируется с external value только при смене scriptId.
const [localCode, setLocalCode] = useState(value || '');
@@ -76,6 +121,15 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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);
@@ -162,6 +216,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);
@@ -282,6 +339,54 @@ 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 +499,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
+ {confirmState && (
+ setConfirmState(null)}
+ />
+ )}
);
}
diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js
index e1feeeb..ec00bd8 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
@@ -3002,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;
@@ -3036,24 +3070,36 @@ export class BabylonScene {
const EPS = 0.25;
// 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 +3207,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 {
@@ -3215,7 +3272,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 есть
@@ -5364,6 +5444,7 @@ export class BabylonScene {
code: s.code,
name: s.name || null,
target: newTarget,
+ language: s.language || 'js',
});
}
if (srcScripts.length > 0) {
@@ -5506,7 +5587,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 — приватный режим / переполнение */ }
@@ -5521,7 +5602,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 +6758,7 @@ export class BabylonScene {
}
/** Установить код одного скрипта по id. Если id нет — создать новый. */
- upsertScript(id, code, target = undefined, name = undefined) {
+ upsertScript(id, code, target = undefined, name = undefined, language = undefined, slots = undefined) {
const i = this._scripts.findIndex(s => s.id === id);
if (i >= 0) {
this._scripts[i] = {
@@ -6685,6 +6766,11 @@ export class BabylonScene {
code,
...(target !== undefined ? { target } : {}),
...(name !== undefined ? { name } : {}),
+ ...(language !== undefined ? { language } : {}),
+ // Слоты code_js и code_lua — сохраняемый код для каждого языка.
+ // Передаются при переключении языка, чтобы код другого языка
+ // не пропадал.
+ ...(slots && typeof slots === 'object' ? slots : {}),
};
} else {
this._scripts.push({
@@ -6692,6 +6778,8 @@ export class BabylonScene {
code,
target: target !== undefined ? target : null,
name: name || null,
+ language: language || 'js',
+ ...(slots && typeof slots === 'object' ? slots : {}),
});
}
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
@@ -7717,6 +7805,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,
@@ -7736,6 +7833,7 @@ export class BabylonScene {
code: s.code,
target: s.target || null,
name: s.name || null,
+ language: s.language === 'lua' ? 'lua' : 'js',
})),
},
editorCamera: this.camera ? {
@@ -8193,12 +8291,19 @@ export class BabylonScene {
code: s.code,
target: s.target || null,
name: s.name || null,
+ language: s.language === 'lua' ? 'lua' : 'js',
}));
}
// Окружение (время суток, скайбокс, туман)
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);
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js
index 16dfde9..8bc1bbd 100644
--- a/src/editor/engine/GameRuntime.js
+++ b/src/editor/engine/GameRuntime.js
@@ -19,7 +19,9 @@ 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, parseRobloxLuaMeta } from './rbxl-lua-integration.js';
+import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
+import { RbxlHudOverlay } from './RbxlHudOverlay.js';
export class GameRuntime {
constructor(scene3d) {
@@ -115,11 +117,70 @@ 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?.() || [];
+ // Единый Lua-batch: и user-Lua (language='lua'), и импортированные .rbxl
+ // скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox.
+ // .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua.
+ const luaUserBatch = [];
+ // Импортированные .rbxl-скрипты ВКЛЮЧЕНЫ — итеративно настраиваем API
+ // под реальные скрипты. Выключить временно: window.__RBXL_SKIP_IMPORTED=true.
+ const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true);
+ let rbxlSkipped = 0;
for (const s of scripts) {
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
- rbxlBatch.push(s);
+ if (!runImportedRbxl) { rbxlSkipped++; continue; }
+ // Уважаем поле enabled=false из Roblox-метадаты: такие скрипты
+ // были disabled-шаблоны (для клонирования через :Clone()), их
+ // запуск немедленно крашит coroutine (WASM access out of bounds).
+ const meta = parseRobloxLuaMeta(s.code);
+ if (meta && meta.enabled === false) { rbxlSkipped++; continue; }
+ // Пропускаем Regeneration-скрипты: у нас Anchored=True для
+ // импорта, постройки не разрушаются, регенерация не нужна.
+ // Их работа (model:remove + Clone) даст визуальные глитчи.
+ const sname = String(s.name || '').toLowerCase();
+ if (sname.startsWith('regenerate') || sname === 'regenerationscript') {
+ rbxlSkipped++; continue;
+ }
+ const luaSource = unpackRobloxLuaCode(s.code);
+ // SAFETY: пропускаем скрипты с tight-loop'ами через ChildAdded:wait()
+ // или WaitForChild через пользовательский while-not-FindFirstChild.
+ // Они подвешивают страницу (wait() возвращает синхронно, скрипт
+ // никогда не yield'ит из C-call). Распространённый Roblox 2009
+ // паттерн который мы не можем безопасно эмулировать.
+ if (luaSource && (
+ /while\s+not\s+\w+[:.]FindFirstChild/.test(luaSource) ||
+ /ChildAdded:[Ww]ait\(\)/.test(luaSource) ||
+ /:[Gg]etChildren\(\)\s*\[\d/.test(luaSource)
+ )) {
+ rbxlSkipped++;
+ console.warn(`[GameRuntime] skipped ${s.name}: содержит небезопасный tight-loop (WaitForChild/ChildAdded:wait)`);
+ continue;
+ }
+ if (luaSource && luaSource.trim()) {
+ // Эвристика Tool: если скрипт ссылается на Equipped/Activated
+ // или Tool = script.Parent — он лежит в Tool. Все Tool-скрипты
+ // с target=null склеиваем в ОДИН виртуальный Tool, имя берём
+ // из самого "явного" скрипта (содержит RayGun/Sword/Gun/Weapon).
+ let toolName = null;
+ if (s.target == null && /(script\.Parent|Tool)\.(Equipped|Unequipped|Activated|Deactivated)/.test(luaSource)) {
+ // Все Tool-скрипты группируем в ОДИН виртуальный Tool с именем "Tool".
+ // Для Zapper-демки этого хватит. В будущем — парсинг StarterPack из converter.
+ toolName = 'Tool';
+ }
+ luaUserBatch.push({
+ id: s.id,
+ name: s.name,
+ target: s.target,
+ toolName,
+ language: 'lua',
+ code: luaSource,
+ _rbxlImported: true,
+ });
+ }
+ continue;
+ }
+ if (s && s.language === 'lua') {
+ if (typeof s.code === 'string' && s.code.trim()) luaUserBatch.push(s);
continue;
}
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
@@ -151,25 +212,157 @@ 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;
+ // Импортированные .rbxl-скрипты теперь идут через тот же LuaSharedSandbox
+ // вместе с user-Lua (см. luaUserBatch выше). Отдельный Worker больше не нужен.
+ let luaUserCount = 0;
+ if (luaUserBatch.length > 0) {
+ try {
+ const sb = new LuaSharedSandbox();
+ // partSet/sceneCreate — переиспользуем обработчик rbxl
+ sb.setOnCommand(({ cmd, payload }) => {
+ if (cmd === 'partSet' || cmd === 'partVel' ||
+ cmd === 'sceneCreate' || cmd === 'sceneDelete') {
+ try {
+ handleLuaCommand(null, cmd, payload, this);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e);
+ }
+ } else if (cmd === 'toolRegistered') {
+ // Lua-shim создал Tool — кладём в hotbar инвентаря.
+ try { this._registerRbxlTool(payload); } catch (e) {
+ console.warn('[GameRuntime] toolRegistered failed', e);
+ }
+ } else if (cmd === 'lightingTimeUpdate') {
+ // Roblox Lighting:SetMinutesAfterMidnight → Babylon небо.
+ // Ускоряем в 8x + меняем пресет skybox (clear/sunset/night).
+ try {
+ const baseHour = Number(payload?.hour);
+ if (baseHour >= 0 && baseHour < 24) {
+ if (this._lightBaseHour == null) {
+ this._lightBaseHour = baseHour;
+ this._lightStartReal = performance.now();
+ }
+ const dGame = baseHour - this._lightBaseHour;
+ const accel = 8;
+ const hour = ((this._lightBaseHour + dGame * accel) % 24 + 24) % 24;
+ this.scene3d?.setTimeOfDay?.(hour);
+ // Skybox preset по фазе:
+ // 06-08 sunset, 08-17 clear, 17-19 sunset, 19-06 starry-night
+ let targetPreset;
+ if (hour >= 6 && hour < 8) targetPreset = 'sunset';
+ else if (hour >= 8 && hour < 17) targetPreset = 'lowpoly-roblox';
+ else if (hour >= 17 && hour < 19) targetPreset = 'sunset';
+ else targetPreset = 'starry-night';
+ if (this._lightPreset !== targetPreset) {
+ this._lightPreset = targetPreset;
+ try {
+ const sb = this.scene3d?.skybox;
+ if (sb?.fadeTo) sb.fadeTo({ preset: targetPreset }, 2);
+ else this.scene3d?.setSkybox?.({ preset: targetPreset });
+ } catch (_) {}
+ }
+ }
+ } catch (_) {}
+ } else if (cmd === 'particleCreated') {
+ // Roblox Instance.new('Sparkles') — запомнили какие
+ // partlcle-эффекты есть у Tool. При equip покажем у руки.
+ this._rbxlPendingParticles = this._rbxlPendingParticles || [];
+ this._rbxlPendingParticles.push(payload);
+ } else if (cmd === 'mouseIconChanged') {
+ // Roblox Mouse.Icon → CSS cursor на canvas
+ try {
+ const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
+ if (canvas) canvas.style.cursor = payload.cssCursor || 'default';
+ } catch (_) {}
+ } else if (cmd === 'hudMessage') {
+ // Roblox Message/Hint в верхней трети экрана
+ try {
+ this._ensureRbxlHud();
+ if (payload.visible && payload.text) {
+ this._rbxlHud.showMessage(payload.text);
+ } else {
+ this._rbxlHud.hideMessage();
+ }
+ } catch (_) {}
+ } else if (cmd === 'killFeed') {
+ // Кастомное событие от нашего creator-tag tracker'а
+ try {
+ this._ensureRbxlHud();
+ this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon);
+ } catch (_) {}
+ } else if (cmd === 'winShow') {
+ try {
+ this._ensureRbxlHud();
+ this._rbxlHud.showWin(payload.text || 'WIN!');
+ } catch (_) {}
+ } else if (cmd === 'ui.showText') {
+ // Lua-helper __rbxl_show_text: красивый центрированный
+ // текст без рамки (паритет с JS game.ui.showText).
+ try {
+ this._ensureRbxlHud();
+ this._rbxlHud.showMessage(payload.text || '');
+ const dur = Number(payload.duration) || 2;
+ const t = payload.text || '';
+ setTimeout(() => {
+ try {
+ if (this._rbxlHud._lastMessage === t) {
+ this._rbxlHud.hideMessage();
+ }
+ } catch (_) {}
+ }, dur * 1000);
+ try { this._rbxlHud._lastMessage = t; } catch (_) {}
+ } catch (_) {}
+ } else if (cmd === 'leaderstatSet') {
+ // Roblox leaderstats: IntValue.Value меняется → HUD.
+ try {
+ const lm = this.scene3d?.leaderstats;
+ if (lm) {
+ const statName = String(payload.statName || 'Stat');
+ if (!lm._defs.some(d => d.name === statName)) {
+ lm.define(statName, { initial: 0 });
+ }
+ lm.set(lm._meId || 'me', statName, Number(payload.value) || 0);
+ }
+ } catch (_) {}
+ } else {
+ this._handleCommand(null, cmd, payload);
+ }
+ });
+ // Передаём snapshot ДО start чтобы Workspace.Children заполнились
+ try {
+ const snap = this._buildSceneSnapshot();
+ sb.sendSceneSnapshot(snap);
+ } catch (_) {}
+ for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target, s.name, { toolName: s.toolName });
+ sb.start();
+ this.sandboxes.push(sb);
+ this._luaUserSandbox = sb;
+ luaUserCount = luaUserBatch.length;
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error('[GameRuntime] Lua user runtime failed to init', e);
+ this._log('error', `Lua-runtime ошибка: ${e?.message || e}`);
}
}
- this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`);
- if (rbxlCount > 0) {
- this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`);
+ 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}`);
+ }
+ if (rbxlSkipped > 0) {
+ this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped} (Roblox-скрипты не поддерживаются — пиши свои Lua-скрипты под Этап 1-7 API)`);
+ }
+ if (luaWritten > 0) {
+ this._log('info', `Запущено Lua-скриптов юзера: ${luaWritten}`);
}
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик —
@@ -467,6 +660,146 @@ 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 там). */
+ _registerRbxlTool(payload) {
+ if (!payload || payload.index == null) return;
+ // invUI — это новая drag-drop система с defineItem, а не inventory (старая)
+ const invUI = this.scene3d?.invUI;
+ if (!invUI || typeof invUI.defineItem !== 'function') {
+ console.warn('[GameRuntime] invUI not available for tool', payload);
+ return;
+ }
+ const itemId = `rbxlTool_${payload.index}`;
+ const toolName = String(payload.name || `Tool ${payload.index}`);
+ invUI.defineItem({
+ id: itemId,
+ name: toolName,
+ emoji: '🔫',
+ rarity: 'uncommon',
+ maxStack: 1,
+ description: `Импортированный Roblox-Tool: ${toolName}`,
+ });
+ // Кладём в конкретный hotbar-слот (index 1..9 → slot 0..8)
+ const slot = Math.max(0, Math.min(8, payload.index - 1));
+ invUI.hotbar[slot] = { itemId, count: 1 };
+ invUI._renderHotbar?.();
+ // На первом Tool — навешиваем слушатели слотов и кликов мыши.
+ if (!this._rbxlToolHooks) {
+ this._rbxlToolHooks = true;
+ this._rbxlActiveSlot = -1;
+ // Авто-эквип первого Tool сразу при регистрации — иначе юзер
+ // не понимает что нажимать. В Roblox StarterPack тоже сразу
+ // в Backpack попадает и юзер жмёт 1 для эквипа.
+ setTimeout(() => {
+ if (this._rbxlActiveSlot < 0) {
+ invUI.setActiveHotbar?.(slot);
+ const sb = this._luaUserSandbox;
+ sb?.sendGlobalEvent?.({ type: 'equipTool', index: payload.index });
+ this._rbxlActiveSlot = slot;
+ // Если у Tool были Sparkles — рисуем непрерывно у руки игрока
+ this._startRbxlToolParticles();
+ }
+ }, 100);
+ invUI.on('slot', () => {
+ const sl = invUI.active;
+ const item = invUI.hotbar[sl];
+ const sb = this._luaUserSandbox;
+ if (!sb) return;
+ if (item && item.itemId.startsWith('rbxlTool_')) {
+ const idx = +item.itemId.slice('rbxlTool_'.length);
+ sb.sendGlobalEvent?.({ type: 'equipTool', index: idx });
+ this._rbxlActiveSlot = sl;
+ this._startRbxlToolParticles();
+ } else if (this._rbxlActiveSlot >= 0) {
+ sb.sendGlobalEvent?.({ type: 'unequipTool' });
+ this._rbxlActiveSlot = -1;
+ this._stopRbxlToolParticles();
+ }
+ });
+ // Клики мыши при экипированном Tool — Activated/mouseButton1Down
+ try {
+ const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
+ if (canvas) {
+ const sb = this._luaUserSandbox;
+ canvas.addEventListener('mousedown', (e) => {
+ if (e.button !== 0) return;
+ if (this._rbxlActiveSlot < 0) return;
+ // Hit-position: raycast от камеры в сцену
+ const hit = this._raycastFromCamera?.() || { x: 0, y: 5, z: 0 };
+ sb?.sendGlobalEvent?.({ type: 'mouseButton1Down', hit });
+ sb?.sendGlobalEvent?.({ type: 'toolActivated' });
+ });
+ canvas.addEventListener('mouseup', (e) => {
+ if (e.button !== 0) return;
+ if (this._rbxlActiveSlot < 0) return;
+ sb?.sendGlobalEvent?.({ type: 'mouseButton1Up' });
+ });
+ }
+ } catch (_) {}
+ }
+ }
+
+ /** Запускает непрерывный эмиттер Sparkles у руки игрока, пока Tool экипирован. */
+ _startRbxlToolParticles() {
+ if (this._rbxlSparkInterval) return;
+ const particles = this._rbxlPendingParticles || [];
+ if (particles.length === 0) return;
+ // RayGun Color3.new(0,0,1) → #0000ff. Берём цвет первой партиклы.
+ const p0 = particles[0] || {};
+ const col = p0.color || [0, 0, 1];
+ const hexCol = '#' + [col[0], col[1], col[2]].map(c => {
+ const v = Math.max(0, Math.min(255, Math.round((Number(c) || 0) * 255)));
+ return v.toString(16).padStart(2, '0');
+ }).join('');
+ // Каждые 200мс — короткий burst у руки игрока (приблизительно)
+ this._rbxlSparkInterval = setInterval(() => {
+ try {
+ const pl = this.scene3d?.player;
+ if (!pl || !pl._pos) return;
+ this.scene3d?._spawnParticleEffect?.({
+ type: 'sparks',
+ position: { x: pl._pos.x + 0.3, y: pl._pos.y + 0.4, z: pl._pos.z + 0.3 },
+ color: hexCol,
+ duration: 0.4,
+ count: 0.5,
+ });
+ } catch (_) {}
+ }, 200);
+ }
+
+ _stopRbxlToolParticles() {
+ if (this._rbxlSparkInterval) {
+ clearInterval(this._rbxlSparkInterval);
+ this._rbxlSparkInterval = null;
+ }
+ }
+
+ /** Простой raycast от камеры — для mouse.Hit. */
+ _raycastFromCamera() {
+ try {
+ const cam = this.scene3d?.scene?.activeCamera;
+ if (!cam) return { x: 0, y: 5, z: 0 };
+ const forward = cam.getForwardRay?.()?.direction;
+ const pos = cam.position;
+ if (!pos || !forward) return { x: 0, y: 5, z: 0 };
+ const t = 50;
+ return { x: pos.x + forward.x * t, y: pos.y + forward.y * t, z: pos.z + forward.z * t };
+ } catch (_) {
+ return { x: 0, y: 5, z: 0 };
+ }
+ }
+
stop() {
if (this.sandboxes.length > 0) {
this._log('info', 'Остановка скриптов');
@@ -474,6 +807,14 @@ 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;
+ // Очищаем Roblox HUD overlay (KillFeed/Message/WinGui)
+ try { this._rbxlHud?.dispose(); } catch (_) {}
+ this._rbxlHud = null;
// Удаляем все объекты, которые скрипты наспавнили через
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
// и накапливаются при повторных запусках.
@@ -621,7 +962,61 @@ export class GameRuntime {
this._syncPhysicsToScene();
}
const state = this._collectState();
+ // Реальная позиция игрока для Lua __rbxl_player_pos()
+ // PlayerController хранит позицию в player._pos (Vector3).
+ const player = this.scene3d?.player;
+ let realPos = null;
+ if (player?._pos) {
+ const halfH = player.HALF_H ?? 0.9;
+ realPos = { x: player._pos.x, y: player._pos.y - halfH, z: player._pos.z };
+ } else if (state?.player) {
+ realPos = { x: state.player.x, y: state.player.y, z: state.player.z };
+ }
+ // Собираем актуальные позиции спавненных динамических примитивов
+ // (id >= 800000) — нужно для AABB-touched-check в Lua-shim, чтобы
+ // ловить попадание игрока в падающий куб.
+ let spawnedPositions = null;
+ try {
+ const pm = this.scene3d?.primitiveManager;
+ if (pm && pm.instances) {
+ for (const [id, data] of pm.instances.entries()) {
+ if (id < 800000 || data.anchored !== false) continue;
+ if (!spawnedPositions) spawnedPositions = [];
+ spawnedPositions.push([id, data.x, data.y, data.z]);
+ }
+ }
+ } catch (_) {}
+ // Собираем позиции NPC для Lua-shim
+ const npcPositions = [];
+ try {
+ const nm = this.scene3d?.npcManager;
+ if (nm && nm.npcs && this._localToReal) {
+ // localRef ('npc_lua_N') → реальный 'npc:' → 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) {
+ try { sb.api.updatePlayerPos(realPos.x, realPos.y, realPos.z); } catch (_) {}
+ }
+ // Синк спавненных динамических примитивов
+ if (spawnedPositions && sb.api?.updateSpawnedPos) {
+ for (const [id, x, y, z] of spawnedPositions) {
+ try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {}
+ }
+ }
+ // Синк позиций NPC
+ if (npcPositions.length > 0 && sb.api?.updateNpcPos) {
+ for (const [ref, x, y, z] of npcPositions) {
+ try { sb.api.updateNpcPos(ref, x, y, z); } catch (_) {}
+ }
+ }
// Для скриптов с target — добавляем актуальную позицию self
const stateForSb = sb.target
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
@@ -1118,7 +1513,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);
@@ -1183,6 +1579,32 @@ 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 реальный mesh лежит в npc.data.rootMesh (модель).
+ const mesh = npc.data?.rootMesh || npc.data?.rootNode
+ || npc.rootMesh || npc.rootNode || null;
+ return {
+ kind: 'npc',
+ data: {
+ mesh,
+ rootMesh: mesh,
+ x: npc.x ?? 0,
+ y: npc.y ?? 0,
+ z: npc.z ?? 0,
+ },
+ };
+ }
+ }
+ }
const um = tryGet(this.scene3d?.userModelManager);
if (um) return { kind: 'userModel', data: um };
return null;
@@ -1288,6 +1710,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 });
@@ -1739,6 +2172,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?.setNpcLocalRef) {
+ try { sb.api.setNpcLocalRef(payload.ref, 'npc:' + npcId); } catch (_) {}
+ }
+ }
}
// Сообщаем воркеру маппинг localRef → npcId, чтобы
// npc.onDeath по локальному ref находил правильного NPC.
@@ -3198,16 +3638,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);
@@ -3935,6 +4387,73 @@ export class GameRuntime {
}
return;
}
+ if (cmd === 'playerSet' && payload) {
+ // Из Lua-runtime: humanoid.Health = N → {prop:'health', value:N}.
+ // Используем PlayerController.takeDamage, который запускает полный
+ // death-flow: distance debris, _onDeath callback (respawn), звук.
+ // Сбрасываем _lastDamageTime чтобы invulnerability не блокировал.
+ const player = this.scene3d?.player;
+ if (!player) return;
+ if (payload.prop === 'health') {
+ const target = Math.max(0, Number(payload.value) || 0);
+ const damage = Math.max(0, (player.hp || 0) - target);
+ if (damage > 0 && typeof player.takeDamage === 'function') {
+ player._lastDamageTime = 0;
+ player.takeDamage(damage, 'lua');
+ } else {
+ player.hp = target;
+ }
+ } else if (payload.prop === 'jumpVelocity') {
+ // Bouncer (батут): Lua-скрипт даёт игроку Y-velocity = N
+ try {
+ if (player._vy !== undefined) player._vy = Number(payload.value) || 0;
+ else if (player.velocity) player.velocity.y = Number(payload.value) || 0;
+ } catch (_) {}
+ } else if (payload.prop === 'walkSpeed') {
+ try { player.walkSpeed = Number(payload.value) || player.walkSpeed; } catch (_) {}
+ } else if (payload.prop === 'jumpPower') {
+ try { player.jumpPower = Number(payload.value) || player.jumpPower; } catch (_) {}
+ } else if (payload.prop === 'maxHealth') {
+ try {
+ const max = Math.max(1, Number(payload.value) || 100);
+ player.maxHp = max;
+ if (player.hp > max) player.hp = max;
+ } catch (_) {}
+ } else if (payload.prop === 'position') {
+ // Lua-вызов hrp.Position = ... — телепорт игрока
+ try {
+ const v = payload.value || {};
+ const halfH = player.HALF_H ?? 0.9;
+ if (player._pos) {
+ player._pos.set(v.x || 0, (v.y || 0) + halfH, v.z || 0);
+ if (player._vy != null) player._vy = 0;
+ } else if (player.body?.position?.set) {
+ player.body.position.set(v.x || 0, v.y || 0, v.z || 0);
+ }
+ } catch (_) {}
+ } else if (payload.prop === 'respawn') {
+ // Lua-вызов player:LoadCharacter() — телепорт к spawn и сброс HP
+ try {
+ if (typeof player.respawn === 'function') {
+ player.respawn();
+ } else {
+ const sp = this.scene3d?.projectData?.scene?.spawnPoint
+ || this.projectData?.scene?.spawnPoint
+ || { x: 0, y: 5, z: 0 };
+ // PlayerController хранит позицию в player._pos.
+ const halfH = player.HALF_H ?? 0.9;
+ if (player._pos) {
+ player._pos.set(sp.x, sp.y + halfH, sp.z);
+ if (player._vy != null) player._vy = 0;
+ } else if (player.body?.position?.set) {
+ player.body.position.set(sp.x, sp.y, sp.z);
+ }
+ player.hp = player.maxHp || 100;
+ }
+ } catch (_) {}
+ }
+ return;
+ }
// eslint-disable-next-line no-console
console.warn('[GameRuntime] unknown cmd', cmd);
}
@@ -4213,6 +4732,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,
@@ -4222,11 +4742,18 @@ 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,
});
}
}
- 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
@@ -4359,6 +4886,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 */ }
}
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;
diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js
index 8a50c0c..e8b2a08 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 = (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).
@@ -567,9 +572,18 @@ export class PrimitiveManager {
break;
}
case 'matte':
- default:
mat.specularColor = new Color3(0, 0, 0);
break;
+ case 'glossy':
+ default: {
+ // Roblox Plastic — слабый specular, без emissive.
+ // diffuse=#cccccc должно выглядеть СЕРЫМ (как в Roblox).
+ // ambient (от scene 0.3 × mat.ambient 0.4) даёт цвет в тенях,
+ // но не убивает контраст.
+ mat.specularColor = new Color3(0.05, 0.05, 0.05);
+ mat.specularPower = 64;
+ break;
+ }
}
// Триггеры — всегда полупрозрачные жёлтые в редакторе
@@ -689,7 +703,16 @@ export class PrimitiveManager {
const data = this.instances.get(id);
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/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/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 84cda46..0000000
--- a/src/editor/engine/RobloxLuaSharedSandbox.js
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * RobloxLuaSharedSandbox — main-side обёртка над одним shared Lua-worker'ом.
- *
- * v2 (после rewrite):
- * - start(sceneSnap, guiTree, worker) → init с GUI-деревом
- * - addScriptsBatch(scripts) → одной пачкой все скрипты регистрируются в VM
- * - kickoff() → запускает event loop, fire'ит PlayerAdded
- * - tick(dt) каждый кадр
- * - fireEvent(kind, payload) — маршрутизирует в Worker.handleEvent
- *
- * GameRuntime пушит ОДИН экземпляр в this.sandboxes.
- */
-export class RobloxLuaSharedSandbox {
- constructor() {
- this.worker = null;
- this._onCommand = null;
- this._booted = false;
- this._scriptsLoaded = false;
- this._stopped = false;
- this._pendingTicks = [];
- this._pendingEvents = [];
- this._pendingScripts = null;
- this._pendingKickoff = false;
- this.scriptId = 'rbxl-shared';
- }
-
- setOnCommand(cb) { this._onCommand = cb; }
-
- start(sceneSnap, guiTree, worker) {
- if (this.worker) return;
- this.worker = worker;
- this.worker.onmessage = (e) => this._handle(e);
- this.worker.onerror = (err) => {
- this._emit('log', { level: 'error', text: `SharedWorker error: ${err.message || err}` });
- };
- this.worker.postMessage({ cmd: 'init', payload: { sceneSnap, guiTree } });
- }
-
- addScriptsBatch(scripts) {
- if (!this._booted) { this._pendingScripts = scripts; return; }
- try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts } }); } catch (e) {}
- }
-
- kickoff() {
- if (!this._scriptsLoaded) { this._pendingKickoff = true; return; }
- try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
- }
-
- tick(dt) {
- if (!this.worker) return;
- if (!this._booted) { this._pendingTicks.push(dt); return; }
- try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
- }
-
- fireEvent(kind, payload) {
- if (!this.worker) return;
- const ev = { kind, ...(payload || {}) };
- if (!this._booted) { this._pendingEvents.push(ev); return; }
- try { this.worker.postMessage({ cmd: 'event', payload: ev }); } catch (e) {}
- }
-
- stop() {
- this._stopped = true;
- try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
- try { this.worker?.terminate(); } catch (e) {}
- this.worker = null;
- }
-
- _handle(ev) {
- if (this._stopped) return;
- const { cmd, payload } = ev.data || {};
- if (cmd === 'boot') {
- this._booted = true;
- // флушим pending scripts
- if (this._pendingScripts) {
- try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts: this._pendingScripts } }); } catch (e) {}
- this._pendingScripts = null;
- }
- // ticks накопленные до boot
- for (const dt of this._pendingTicks) {
- try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
- }
- this._pendingTicks = [];
- return;
- }
- if (cmd === 'ready') {
- this._scriptsLoaded = true;
- this._emit('ready', payload);
- if (this._pendingKickoff) {
- try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
- this._pendingKickoff = false;
- }
- // флушим pending events
- for (const e of this._pendingEvents) {
- try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (er) {}
- }
- this._pendingEvents = [];
- return;
- }
- this._emit(cmd, payload);
- }
-
- _emit(cmd, payload) {
- if (this._onCommand) { try { this._onCommand(cmd, payload); } catch (e) {} }
- }
-
- // ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ──
- sendSceneSnapshot(_snap) {}
- sendGuiSnapshot(_snap) {}
- sendSkinsSnapshot(_snap) {}
- sendInventorySnapshot(_snap) {}
- sendTerrainHeightmap(_payload) {}
- sendGlobalEvent(payload) {
- if (!payload || typeof payload !== 'object') return;
- const type = payload.type;
- // playerTouch: BabylonScene уже детектит касания → Touched на Part
- if (type === 'playerTouch' && payload.target) {
- const m = /^primitive:(\d+)$/.exec(String(payload.target));
- if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; }
- }
- // GUI click — Rublox GuiOverlay шлёт guiClick с id
- if (type === 'guiClick' && (payload.id || payload.localId)) {
- this.fireEvent('guiClick', { guiId: payload.id || payload.localId });
- return;
- }
- // keyboard
- if (type === 'keydown' || type === 'keyup') {
- this.fireEvent(type, { key: payload.key });
- return;
- }
- // hp/death
- if (type === 'hpChange' || type === 'humanoidHealth') {
- this.fireEvent('humanoidHealth', { health: payload.hp || payload.health || 0 });
- return;
- }
- if (type === 'died' || type === 'humanoidDied') {
- this.fireEvent('humanoidDied', {});
- return;
- }
- // default: пробрасываем как kind=type
- this.fireEvent(type || 'unknown', payload);
- }
- sendBroadcast(msg, data) { this.fireEvent('broadcast', { msg, data }); }
- sendOnTouchEvent(payload) { this.fireEvent('touched', { primId: payload?.primId, isPlayer: true }); }
- sendOnTickEvent(dt) { this.tick(dt); }
- sendTweenDone(payload) { this.fireEvent('tweenDone', payload); }
- sendSpawnResolved(payload) { this.fireEvent('spawnResolved', payload); }
- setInitialSelfPosition(_p) {}
- setModules(_modules) {}
-}
diff --git a/src/editor/engine/RobloxLuaSharedWorker.js b/src/editor/engine/RobloxLuaSharedWorker.js
deleted file mode 100644
index 60c16d6..0000000
--- a/src/editor/engine/RobloxLuaSharedWorker.js
+++ /dev/null
@@ -1,380 +0,0 @@
-/* eslint-disable no-restricted-globals */
-/**
- * RobloxLuaSharedWorker.js — single-VM Lua-runtime для импортированных Roblox-скриптов.
- *
- * Архитектура v2 (после ITERATION 5-step rewrite):
- *
- * ФАЗА 1 (boot): создаём wasmoon-VM, регистрируем Roblox API без скриптов.
- *
- * ФАЗА 2 (populate): main шлёт snapshot сцены (primitives + GUI tree).
- * Создаём workspace со всеми Part'ами и PlayerGui со всем GUI-деревом.
- * На каждом TextButton — MouseButton1Click сигнал, на каждом Part — Touched.
- *
- * ФАЗА 3 (addScripts): main шлёт ВСЕ скрипты ОДНИМ батчем. Worker загружает
- * их в Lua-VM как отдельные функции в pcall. Скрипты регистрируют свои
- * Connect'ы (Touched, MouseButton1Click, Heartbeat, ...). top-level wait()
- * yield'ится через coroutine — управление возвращается в worker.
- *
- * ФАЗА 4 (run): main шлёт 'startEvents'. Worker запускает scheduler-tick
- * и начинает обрабатывать события (touched/guiClick/heartbeat).
- *
- * IPC:
- * <- init { sceneSnap, guiTree }
- * <- addScripts { scripts: [{id, target, luaSource}] }
- * <- start
- * <- tick { dt }
- * <- event { kind, payload }
- * <- stop
- * -> boot
- * -> ready
- * -> log/partSet/partVel/playerCmd/broadcast/guiUpdate
- */
-
-import { LuaFactory } from 'wasmoon';
-import { registerRobloxApi, RbxSignal } from './roblox-shim.js';
-
-const state = {
- factory: null,
- lua: null,
- sceneSnap: { primitives: {} },
- guiTree: [],
- isStopped: false,
- initPromise: null,
- eventsStarted: false,
- pendingEvents: [],
- scriptCount: 0,
- coroutines: [], // активные ждущие корутины: { co, resumeAt }
- nowSec: 0,
- api: null, // результат registerRobloxApi: { workspace, game, part_by_id, gui_by_id, localPlayer, humanoid }
-};
-
-function send(cmd, payload) {
- self.postMessage({ cmd, payload });
-}
-
-function log(level, text) {
- send('log', { level, text });
-}
-
-const scheduler = {
- now: () => state.nowSec,
- schedule: (sec, fn) => {
- state.coroutines.push({ resumeAt: state.nowSec + (sec || 0), fn });
- },
- spawn: (fn) => {
- // spawn — fn запускается асинхронно (на следующем tick'е)
- state.coroutines.push({ resumeAt: state.nowSec, fn });
- },
-};
-
-self.addEventListener('message', async (ev) => {
- const { cmd, payload } = ev.data || {};
- try {
- if (cmd === 'init') await handleInit(payload);
- else if (cmd === 'addScripts') await handleAddScripts(payload);
- else if (cmd === 'start') handleStart();
- else if (cmd === 'tick') handleTick(payload);
- else if (cmd === 'event') {
- if (!state.eventsStarted) state.pendingEvents.push(payload);
- else handleEvent(payload);
- }
- else if (cmd === 'stop') {
- state.isStopped = true;
- try { state.lua?.global?.close?.(); } catch (e) {}
- }
- } catch (err) {
- log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
- }
-});
-
-async function handleInit({ sceneSnap, guiTree }) {
- if (state.initPromise) { await state.initPromise; return; }
- state.initPromise = (async () => {
- state.sceneSnap = sceneSnap || { primitives: {} };
- state.guiTree = guiTree || [];
- state.factory = new LuaFactory();
- state.lua = await state.factory.createEngine({
- injectObjects: true,
- enableProxy: true,
- traceAllocations: false,
- });
- state.api = registerRobloxApi(state.lua, {
- getSceneSnap: () => state.sceneSnap,
- getGuiTree: () => state.guiTree,
- targetPrimitiveId: null,
- send,
- scheduler,
- });
- // Передаём part_by_id в Lua как table {id → Instance}
- // ВНИМАНИЕ: lua_table принимает string keys, ключи кладём как строки.
- try {
- const m = state.api?.part_by_id;
- if (m) {
- const obj = {};
- for (const [id, part] of m) obj[String(id)] = part;
- state.lua.global.set('__rbxl_parts_by_id', obj);
- }
- } catch (e) {}
- // null-stub builder: возвращает Instance-like объект который безопасно
- // отвечает на .Parent, .Name, .WaitForChild и т.п. чтобы цепочки
- // script.Parent.Parent.X не валили.
- const makeNullStub = () => {
- const stub = {
- Name: 'NullStub',
- ClassName: 'Nil',
- Children: [],
- __isNullStub: true,
- };
- // Parent — самоссылающийся nullStub
- stub.Parent = stub;
- stub.FindFirstChild = () => stub;
- stub.FindFirstChildOfClass = () => stub;
- stub.FindFirstAncestor = () => stub;
- stub.FindFirstAncestorOfClass = () => stub;
- stub.WaitForChild = () => stub;
- stub.GetChildren = () => [];
- stub.GetDescendants = () => [];
- stub.IsA = () => false;
- stub.Clone = () => makeNullStub();
- stub.Destroy = () => {};
- stub.GetService = () => stub;
- // Сигналы — пустой connector
- const nullSig = {
- Connect: () => ({ Disconnect: () => {}, Connected: false }),
- Wait: () => null,
- Fire: () => {},
- };
- // Любой каpitalized property — сигнал-stub
- return new Proxy(stub, {
- get(t, k) {
- if (k in t) return t[k];
- if (typeof k === 'string' && /^[A-Z]/.test(k)) return nullSig;
- return undefined;
- },
- set(t, k, v) { t[k] = v; return true; },
- });
- };
- state.lua.global.set('__rbxl_make_null_stub', makeNullStub);
- // ВАЖНО: создаём nullStub НА СТОРОНЕ LUA как настоящую table с
- // metatable __index возвращающей сам stub. Это позволит цепочкам
- // .Parent.X.Y:WaitForChild():Connect() корректно работать и обе
- // нотации (. и :) сработают.
- await state.lua.doString(`
- __null_stub_mt = {}
- function __make_null_stub()
- local t = setmetatable({
- Name = "Nil",
- ClassName = "Nil",
- __isNullStub = true,
- Visible = false,
- Enabled = false,
- Value = 0,
- Text = "",
- }, __null_stub_mt)
- return t
- end
- __null_stub_singleton = __make_null_stub()
- -- nullSignal с обоими Connect/connect:
- local function null_sig_method() return { Disconnect = function() end, disconnect = function() end, Connected = false } end
- __null_signal = setmetatable({
- Connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
- connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
- Wait = function() return nil end,
- wait = function() return nil end,
- Fire = function() end,
- fire = function() end,
- }, { __index = function() return function() return __null_stub_singleton end end })
- -- Любой index nullStub'а → возвращает либо null_signal (для уже известных
- -- сигнальных имён) либо noop-функцию которая возвращает сам stub.
- __null_stub_mt.__index = function(t, k)
- -- известные сигнал-имена
- local sig_names = {Touched=true,TouchEnded=true,Changed=true,Activated=true,
- MouseButton1Click=true,MouseButton1Down=true,MouseButton1Up=true,
- MouseEnter=true,MouseLeave=true,InputBegan=true,InputEnded=true,
- PlayerAdded=true,PlayerRemoving=true,CharacterAdded=true,CharacterRemoving=true,
- Heartbeat=true,Stepped=true,RenderStepped=true,Died=true,HealthChanged=true,
- FocusLost=true,Focused=true,ChildAdded=true,ChildRemoved=true,
- AncestryChanged=true,DescendantAdded=true,DescendantRemoving=true}
- if sig_names[k] then return __null_signal end
- -- любой метод → функция которая возвращает stub (поддерживает оба синтаксиса)
- return function(...) return __null_stub_singleton end
- end
- __null_stub_mt.__newindex = function(t, k, v) rawset(t, k, v) end
- __null_stub_mt.__call = function(t, ...) return __null_stub_singleton end
- -- Сделаем __null_stub_singleton.Parent = сам себя (lazy)
- rawset(__null_stub_singleton, "Parent", __null_stub_singleton)
- `);
- // Заменяем __rbxl_make_null_stub на Lua-side функцию
- await state.lua.doString(`
- function __rbxl_make_null_stub() return __null_stub_singleton end
- `);
- // КРИТИЧНО: расширенные metatable для nil + function + number чтобы
- // любые цепочки nil.x.y:method() и func.x не валили скрипты.
- await state.lua.doString(`
- if debug and debug.setmetatable then
- local _stub_mt = {
- __index = function(t, k) return __null_stub_singleton end,
- __newindex = function(t, k, v) end,
- __call = function(t, ...) return __null_stub_singleton end,
- __add = function(a, b) return 0 end,
- __sub = function(a, b) return 0 end,
- __mul = function(a, b) return 0 end,
- __div = function(a, b) return 0 end,
- __mod = function(a, b) return 0 end,
- __pow = function(a, b) return 0 end,
- __unm = function() return 0 end,
- __concat = function(a, b) return "" end,
- __len = function() return 0 end,
- __eq = function(a, b) return false end,
- __lt = function(a, b) return false end,
- __le = function(a, b) return false end,
- __tostring = function() return "nil" end,
- }
- debug.setmetatable(nil, _stub_mt)
- debug.setmetatable(function() end, _stub_mt)
- -- НЕ ставим на number/string/boolean — они должны работать нормально
- end
- `);
- // helper: безопасный pcall с warn'ом при ошибке
- await state.lua.doString(`
- __rbxl_scripts = {}
- function __rbxl_safe_run(id, fn)
- local ok, err = pcall(fn)
- if not ok then warn("[rbxl-lua " .. tostring(id) .. " err] " .. tostring(err)) end
- end
- -- Lookup Part по primitiveId. Используем __rbxl_parts_by_id из JS,
- -- т.к. ipairs() на JS array не работает (0-indexed vs Lua 1-indexed).
- function __rbxl_lookup_part(id)
- if __rbxl_parts_by_id then
- return __rbxl_parts_by_id[tostring(id)] or __rbxl_parts_by_id[id]
- end
- return nil
- end
- `);
- send('boot', null);
- })();
- await state.initPromise;
-}
-
-async function handleAddScripts({ scripts }) {
- if (!state.lua) { log('error', 'addScripts before init'); return; }
- let ok = 0, fail = 0;
- for (const s of scripts) {
- const safeId = String(s.id).replace(/[^a-zA-Z0-9_]/g, '_');
- const targetExpr = s.target != null
- ? `__rbxl_lookup_part(${JSON.stringify(s.target)}) or __rbxl_make_null_stub()`
- : '__rbxl_make_null_stub()';
- // Оборачиваем в pcall. script — локальный, не конфликтует между скриптами.
- // script.Parent НИКОГДА не nil — даём nullStub чтобы цепочки
- // script.Parent.Parent.X не валили.
- const wrapped = `
- do
- local script = setmetatable({
- Name = "Script_${safeId}",
- Parent = ${targetExpr},
- ClassName = "LocalScript",
- }, { __index = function(t, k) return rawget(t, k) end })
- __rbxl_safe_run("${safeId}", function()
- ${s.luaSource}
- end)
- end
- `;
- try {
- await state.lua.doString(wrapped);
- ok++;
- } catch (e) {
- fail++;
- // ошибки парсинга/runtime, считаем но не валим всё
- }
- }
- state.scriptCount = ok;
- send('ready', { ok, fail });
-}
-
-function handleStart() {
- state.eventsStarted = true;
- // Эмитим Players.PlayerAdded + CharacterAdded чтобы скрипты которые
- // делают game.Players.PlayerAdded:Connect(...) получили событие.
- try {
- const lp = state.api?.localPlayer;
- const players = state.api?.services?.get('Players');
- if (lp && players?.PlayerAdded?.Fire) players.PlayerAdded.Fire(lp);
- if (lp?.CharacterAdded?.Fire && lp.Character) lp.CharacterAdded.Fire(lp.Character);
- } catch (e) {}
- // Флушим накопленные события
- for (const e of state.pendingEvents) handleEvent(e);
- state.pendingEvents = [];
-}
-
-function handleTick({ dt }) {
- if (state.isStopped || !state.lua) return;
- state.nowSec += dt || 0;
- // Резолвим планированные корутины
- if (state.coroutines.length > 0) {
- const due = [];
- const left = [];
- for (const c of state.coroutines) {
- if (c.resumeAt <= state.nowSec) due.push(c); else left.push(c);
- }
- state.coroutines = left;
- for (const c of due) {
- try { c.fn(); } catch (e) { log('warn', `coroutine err: ${e?.message || e}`); }
- }
- }
- // RunService сигналы
- try {
- const rs = state.api?.services?.get('RunService');
- if (rs?.Heartbeat?.Fire) rs.Heartbeat.Fire(dt);
- if (rs?.Stepped?.Fire) rs.Stepped.Fire(state.nowSec, dt);
- if (rs?.RenderStepped?.Fire) rs.RenderStepped.Fire(dt);
- } catch (e) {}
-}
-
-function handleEvent(payload) {
- if (state.isStopped || !state.lua || !state.api) return;
- const { kind } = payload || {};
- try {
- if (kind === 'guiClick' || kind === 'guiActivated') {
- const guiId = payload.guiId;
- const inst = state.api.gui_by_id?.get(guiId);
- if (inst) {
- if (kind === 'guiActivated') inst.Activated?.Fire?.(1);
- else inst.MouseButton1Click?.Fire?.();
- }
- } else if (kind === 'touched') {
- const primId = payload.primId;
- const part = state.api.part_by_id?.get(primId);
- if (part?.Touched?.Fire) {
- // hit = HumanoidRootPart
- part.Touched.Fire(state.api.character?.HumanoidRootPart || part);
- }
- // также Humanoid.Touched на самом игроке
- if (payload.isPlayer) {
- state.api.humanoid?.Touched?.Fire?.(part);
- }
- } else if (kind === 'keydown' || kind === 'keyup') {
- // UserInputService.InputBegan/Ended
- const uis = state.api.services?.get('UserInputService') ||
- (() => {
- const s = new (state.lua.global.get('Instance')?.new ? Object : Object)();
- return null;
- })();
- if (uis) {
- if (kind === 'keydown') uis.InputBegan?.Fire?.({ KeyCode: { Name: payload.key } });
- else uis.InputEnded?.Fire?.({ KeyCode: { Name: payload.key } });
- }
- } else if (kind === 'humanoidDied') {
- state.api.humanoid?.Died?.Fire?.();
- } else if (kind === 'humanoidHealth') {
- const h = state.api.humanoid;
- if (h) {
- h.Health = payload.health;
- h.HealthChanged?.Fire?.(payload.health);
- }
- }
- } catch (e) {
- log('warn', `event ${kind} err: ${e?.message || e}`);
- }
-}
-
-self.__rbxlSharedState = state;
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/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();
}
diff --git a/src/editor/engine/StudioCollab.js b/src/editor/engine/StudioCollab.js
index 283c584..06a4cdf 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',
});
}
}
@@ -523,7 +524,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':
diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js
new file mode 100644
index 0000000..466a257
--- /dev/null
+++ b/src/editor/engine/lua/LuaSharedSandbox.js
@@ -0,0 +1,337 @@
+/**
+ * LuaSharedSandbox (v3, main-thread) — wasmoon-VM работает в MAIN потоке,
+ * без Web Worker. Это позволяет:
+ * - Видеть точные Lua-ошибки в DevTools (через console.error)
+ * - Использовать debugger / breakpoints прямо в RobloxShim.js
+ * - Не возиться с молчаливыми Worker-падениями
+ *
+ * Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style
+ * скриптов это нестрашно — они быстрые.
+ *
+ * API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent /
+ * sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot /
+ * sendTerrainHeightmap / stop / tick / target.
+ *
+ * Что добавлено сверх ScriptSandbox:
+ * - addScript(id, code, target) — добавить скрипт в общий VM. Можно
+ * до или после start().
+ * - start() — асинхронен (createEngine), но возвращает сразу. После init
+ * стартует main loop (Heartbeat + scheduler).
+ */
+
+import { LuaFactory } from 'wasmoon';
+import { registerRobloxShim } from './RobloxShim.js';
+
+export class LuaSharedSandbox {
+ constructor() {
+ this.vm = null;
+ this.api = null;
+ this._onCommand = null;
+ this._isReady = false;
+ this._isStopped = false;
+ this._isKickedOff = false;
+ this._pendingScripts = []; // [{id, code, target, name}]
+ this._scriptsById = new Map();
+ this._scenes = null;
+ this._guiTree = null;
+ this._loopHandle = null;
+ this._lastTickAt = 0;
+ // Маркер для GameRuntime.routeEvent — этот sandbox принимает все
+ // события и сам маршрутизирует через shim.fireTargetEvent.
+ this._luaShared = true;
+ }
+
+ setOnCommand(cb) { this._onCommand = cb; }
+
+ get target() { return null; }
+ tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ }
+
+ addScript(id, code, target, name, extra) {
+ const entry = {
+ id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`),
+ code: String(code || ''),
+ target: target == null ? null : target,
+ name: name || null,
+ toolName: extra?.toolName || null,
+ };
+ this._scriptsById.set(entry.id, entry);
+ if (!this._isKickedOff) {
+ this._pendingScripts.push(entry);
+ } else {
+ this._startSingleScript(entry);
+ }
+ }
+
+ removeScript(id) {
+ this._scriptsById.delete(String(id));
+ }
+
+ /** Стартует VM, регистрирует shim, запускает main-loop. */
+ start() {
+ if (this.vm || this._isStopped) return;
+ // eslint-disable-next-line no-console
+ console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...');
+ this._initAsync().catch((err) => {
+ // eslint-disable-next-line no-console
+ console.error('[LuaSharedSandbox] FATAL init error:', err);
+ this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` });
+ });
+ }
+
+ async _initAsync() {
+ const factory = new LuaFactory();
+ this.vm = await factory.createEngine({ openStandardLibs: true });
+ // eslint-disable-next-line no-console
+ console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...');
+
+ // Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait.
+ const send = (cmd, payload) => this._emit(cmd, payload);
+
+ this.api = registerRobloxShim(this.vm, {
+ send,
+ getSceneSnapshot: () => this._scenes,
+ getGuiTree: () => this._guiTree,
+ scheduleWait: () => null,
+ });
+
+ // eslint-disable-next-line no-console
+ console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {}));
+
+ // Применим snapshot если он есть
+ if (this._scenes && this.api?.onSceneSnapshot) {
+ try { this.api.onSceneSnapshot(this._scenes); } catch (e) {
+ console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
+ }
+ }
+
+ this._isReady = true;
+ this._kickoff();
+ }
+
+ _kickoff() {
+ if (this._isKickedOff || this._isStopped) return;
+ this._isKickedOff = true;
+ // eslint-disable-next-line no-console
+ console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`);
+ const pending = this._pendingScripts;
+ this._pendingScripts = [];
+ // Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines.
+ this._lastTickAt = performance.now();
+ this._startMainLoop();
+ // Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался.
+ const BATCH_SIZE = 5;
+ let idx = 0;
+ const initBatch = () => {
+ if (this._isStopped) return;
+ const end = Math.min(idx + BATCH_SIZE, pending.length);
+ for (let i = idx; i < end; i++) {
+ try { this._startSingleScript(pending[i]); }
+ catch (e) {
+ // eslint-disable-next-line no-console
+ console.error('[LuaSharedSandbox] init batch err:', e);
+ }
+ }
+ idx = end;
+ if (idx < pending.length) {
+ setTimeout(initBatch, 20);
+ } else {
+ // eslint-disable-next-line no-console
+ console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`);
+ // После того как все скрипты подключили хендлеры — фейрим
+ // events для уже существующих сущностей. Roblox-конвенция:
+ // если игрок уже на сервере когда скрипт подключается,
+ // Players.PlayerAdded не сработает повторно. Юзеру нужно
+ // делать ручной обход GetPlayers() — но это редко кто помнит.
+ // Мы дублируем событие через короткую задержку.
+ setTimeout(() => {
+ try {
+ if (this.api?.fireExistingPlayers) {
+ this.api.fireExistingPlayers();
+ }
+ } catch (e) {
+ console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e);
+ }
+ }, 100);
+ }
+ };
+ setTimeout(initBatch, 0);
+ }
+
+ _startSingleScript(entry) {
+ if (!this.vm || !entry || typeof entry.code !== 'string') return;
+ let primId = null;
+ if (typeof entry.target === 'number') primId = entry.target;
+ else if (entry.target && typeof entry.target === 'object') {
+ if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref;
+ }
+ const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_');
+ const scriptName = entry.name || `Script_${safeId}`;
+ // Скрипт оборачиваем в coroutine — это позволяет task.wait через yield.
+ // Резюмим coroutine из main-loop когда наступило время.
+ // Регистрируем coroutine в __rbxl_coroutines с id для возобновления.
+ // Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает
+ // delay из resume → планируем следующий resume через scheduleResume.
+ // Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) —
+ // подсовываем виртуальный Tool как script.Parent. Иначе primitive по id,
+ // иначе workspace.
+ let parentExpr;
+ if (entry.toolName) {
+ // Tool создаётся в shim как Instance.new('Tool'). По имени достаём.
+ // Если не нашли — fallback на новый Tool того же имени.
+ const safeName = JSON.stringify(entry.toolName);
+ parentExpr = `(function()
+ local existing = __rbxl_get_tool_by_name(${safeName})
+ if existing then return existing end
+ local t = Instance.new("Tool")
+ t.Name = ${safeName}
+ return t
+ end)()`;
+ } else if (primId != null) {
+ parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`;
+ } else {
+ parentExpr = 'workspace';
+ }
+ const wrapped = `
+ do
+ -- Если parentExpr вернул primitive — у него уже есть :FindFirstChild и пр.
+ -- Если ничего не вернёт — workspace (всегда валидный).
+ -- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace).
+ local _scriptParent = ${parentExpr}
+ if _scriptParent == nil then _scriptParent = workspace end
+ if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end
+ local script = setmetatable({
+ Name = ${JSON.stringify(scriptName)},
+ Parent = _scriptParent,
+ ClassName = "Script",
+ Disabled = false,
+ Source = nil,
+ }, {
+ -- Любой доступ к несуществующему полю → workspace
+ -- (на случай script.Foo:Bar() в старом коде)
+ __index = function(t, k)
+ if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then
+ return function() return nil end
+ end
+ return workspace[k]
+ end,
+ })
+ local co = coroutine.create(function()
+ -- WATCHDOG: каждые 100000 инструкций — yield 1 кадр.
+ -- НЕ оборачиваем в pcall — внутри C-call boundary yield
+ -- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть.
+ debug.sethook(function()
+ coroutine.yield(0.016)
+ end, "", 20000)
+ -- pcall защищает от runtime-ошибок которые иначе крашат
+ -- coroutine и могут повредить WASM-стейт. Возвраты
+ -- handler'а намеренно поглощаются.
+ local ok_, err_ = pcall(function()
+ ${entry.code}
+ end)
+ if not ok_ then
+ __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_))
+ end
+ end)
+ __rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
+ local ok, ret = coroutine.resume(co)
+ if not ok then
+ __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret))
+ __rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
+ elseif type(ret) == 'number' then
+ -- скрипт yield'нул с delay (через task.wait) — планируем resume
+ __rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret)
+ elseif coroutine.status(co) == 'dead' then
+ __rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
+ end
+ end
+ `;
+ try {
+ this.vm.doStringSync(wrapped);
+ // eslint-disable-next-line no-console
+ console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`);
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err);
+ this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` });
+ }
+ }
+
+ _startMainLoop() {
+ const tick = () => {
+ if (this._isStopped) return;
+ try {
+ const now = performance.now();
+ const dt = Math.min(0.1, (now - this._lastTickAt) / 1000);
+ this._lastTickAt = now;
+ if (this.api?.tickScheduler) this.api.tickScheduler(dt);
+ if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error('[LuaSharedSandbox tick]', e);
+ }
+ this._loopHandle = setTimeout(tick, 16);
+ };
+ this._loopHandle = setTimeout(tick, 16);
+ }
+
+ _emit(cmd, payload) {
+ if (typeof this._onCommand === 'function') {
+ try { this._onCommand({ cmd, payload }); } catch (_) {}
+ }
+ }
+
+ // ----- API совместимый с ScriptSandbox -----
+ sendEvent(payload) {
+ if (!this.api?.fireTargetEvent || !this._isReady) return;
+ try { this.api.fireTargetEvent(payload); } catch (e) {
+ console.error('[LuaSharedSandbox] sendEvent:', e);
+ }
+ }
+
+ sendGlobalEvent(payload) {
+ if (!this.api?.fireGlobalEvent || !this._isReady) return;
+ try { this.api.fireGlobalEvent(payload); } catch (e) {
+ console.error('[LuaSharedSandbox] sendGlobalEvent:', e);
+ }
+ }
+
+ sendSceneSnapshot(snapshot) {
+ this._scenes = snapshot;
+ if (this.api?.onSceneSnapshot && this._isReady) {
+ try { this.api.onSceneSnapshot(snapshot); } catch (e) {
+ console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
+ }
+ }
+ }
+
+ sendGuiSnapshot(snapshot) {
+ this._guiTree = snapshot;
+ if (this.api?.onGuiSnapshot && this._isReady) {
+ try { this.api.onGuiSnapshot(snapshot); } catch (_) {}
+ }
+ }
+
+ sendDataSnapshot(snapshot) {
+ if (this.api?.onDataSnapshot && this._isReady) {
+ try { this.api.onDataSnapshot(snapshot); } catch (_) {}
+ }
+ }
+
+ sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ }
+ sendTerrainHeightmap(_) { /* no-op */ }
+
+ stop() {
+ this._isStopped = true;
+ if (this._loopHandle) {
+ clearTimeout(this._loopHandle);
+ this._loopHandle = null;
+ }
+ if (this.vm) {
+ try { this.vm.global.close(); } catch (_) {}
+ this.vm = null;
+ }
+ this.api = null;
+ }
+}
+
+export default LuaSharedSandbox;
diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js
new file mode 100644
index 0000000..02d5bd4
--- /dev/null
+++ b/src/editor/engine/lua/RobloxShim.js
@@ -0,0 +1,2500 @@
+/**
+ * RobloxShim v3 (для main-thread sandbox) — Roblox API + DataModel.
+ *
+ * Этап 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 команда
+ *
+ * ВАЖНО про wasmoon: не используем Object.defineProperty getters на JS-объектах
+ * передаваемых в Lua — wasmoon их некорректно оборачивает (js_promise). Вместо
+ * этого — обычные поля, которые юзер читает напрямую. Запись свойств происходит
+ * через `__rbxl_part_set(part, prop, value)` — она шлёт partSet и обновляет поле.
+ */
+
+// ---------- Scheduler (для task.delay/defer) ----------
+const SCHEDULER = {
+ sleeping: [], // [{wakeAt, run}]
+ now: () => performance.now(),
+};
+
+// ---------- Базовые сигналы ----------
+const HEARTBEAT_SIGNAL = makeSignal();
+const STEPPED_SIGNAL = makeSignal();
+
+// Очередь handler'ов которые надо запустить на следующем tickScheduler.
+// Этим мы выходим из C-boundary — wait() внутри handler'а становится
+// безопасным yield в собственной coroutine, потому что handler стартует
+// уже из main loop, а не из синхронного JS-callback.
+const _pendingHandlerQueue = [];
+
+function makeSignal() {
+ const sig = {
+ __isSignal: true,
+ 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]) {
+ // Кладём в очередь, чтобы handler стартовал не в текущем
+ // JS-callback (откуда yield запрещён), а из tickScheduler
+ // в своей coroutine. Безопасно для wait() внутри.
+ _pendingHandlerQueue.push({ fn, args });
+ }
+ };
+ sig.fire = sig.Fire;
+ // 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;
+}
+
+// ---------- Vector3 / Color3 / UDim2 / Vector2 / CFrame ----------
+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); }
+ 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() { 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(
+ 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,
+ );
+ }
+}
+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);
+
+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);
+ }
+ 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,
+ this.G + (b.G - this.G) * t,
+ 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);
+ }
+}
+
+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); }
+}
+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);
+ this.p = this.Position;
+ }
+ static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); }
+ 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) {
+ // В 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 = [];
+ let p = this;
+ while (p && p.ClassName !== 'DataModel') {
+ parts.unshift(p.Name);
+ p = p.Parent;
+ }
+ return parts.join('.');
+ },
+ 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);
+ this.Parent = 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 = {};
+ this.Attributes[n] = v;
+ },
+ GetPropertyChangedSignal: function () { return this.Changed; },
+ };
+ return _instanceMethods;
+}
+
+// Создаёт stub-signal который ничего не делает — для unknown свойств Instance
+// которые скрипты пытаются использовать как сигнал (script.Parent.Selected:Connect).
+function makeStubSignal() {
+ const sig = makeSignal();
+ // Помечаем чтобы знать что это stub (для возможной отладки)
+ sig.__stub = true;
+ 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 undefined; };
+ fn.wait = fn.Wait;
+ return fn;
+}
+
+// Эвристика: какие имена свойств вероятно сигналы?
+// В 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);
+}
+
+// Универсальный 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];
+ }
+ 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 = makeObjectStub(prop);
+ t[prop] = child;
+ return child;
+ },
+ set(t, prop, value) { t[prop] = value; return true; },
+ });
+}
+
+function newInstance(className, name) {
+ const m = makeInstanceMethods();
+ const target = {
+ 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,
+ };
+ // Proxy: для unknown свойств возвращаем stub чтобы скрипты не падали.
+ let proxyRef;
+ proxyRef = new Proxy(target, {
+ get(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 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;
+ }
+ // 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;
+ },
+ 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 (_) {}
+ }
+ // Спец-регистрация для 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;
+ }
+ t[prop] = value;
+ return true;
+ },
+ has(t, prop) {
+ // Для in-проверок отвечаем true почти всегда (чтобы Lua не падал на
+ // условиях вроде if obj.SomeField then ...)
+ return true;
+ },
+ });
+ return proxyRef;
+}
+
+/**
+ * Создать 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.__sendDestroy = (id) => sendFn('sceneDelete', { primId: id });
+ p.Touched = makeSignal();
+ p.TouchEnded = makeSignal();
+ p.Material = 'Plastic';
+
+ // Внутренний 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.
+ 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;
+}
+
+// ---------- Регистрация в Lua ----------
+export function registerRobloxShim(lua, opts) {
+ const { send } = opts;
+ const global = lua.global;
+
+ // === Базовые типы ===
+ 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,
+ FromNormalId: () => new RbxVector3(),
+ });
+ 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) => 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),
+ 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 ===
+ const mkE = (arr) => Object.fromEntries(arr.map(k => [k, { Name: k, Value: k }]));
+ global.set('Enum', {
+ 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']),
+ // Часто используемые в туториалах
+ 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 ===
+ 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) => {
+ send('log', { level: 'info', text: args.map(stringify).join('\t') });
+ });
+ global.set('warn', (...args) => {
+ 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
+ // Это работает потому что **скрипт сам запускается как coroutine**
+ // (см. LuaSharedSandbox._startSingleScript → мы оборачиваем код в pcall,
+ // НО для yield нам нужно завернуть в coroutine.create). Делаем это
+ // через Lua-prelude: глобальная функция `_run_in_coroutine(fn)`.
+ global.set('task', {
+ spawn: (fn) => {
+ try { if (typeof fn === 'function') fn(); } catch (e) {
+ send('log', { level: 'error', text: `[task.spawn] ${e?.message || e}` });
+ }
+ },
+ delay: (sec, fn) => {
+ if (typeof fn !== 'function') return;
+ 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') return;
+ SCHEDULER.sleeping.push({
+ wakeAt: SCHEDULER.now(),
+ run: () => { try { fn(); } catch (_) {} },
+ });
+ },
+ synchronize: () => {},
+ desynchronize: () => {},
+ });
+ // task.wait и wait определяются через Lua coroutine.yield в prelude (см. ниже)
+
+ // === 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;
+ // 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';
+ 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 () {
+ // Респаун: возвращаем 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 каждого спавнящегося игрока.
+ 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 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),
+ 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);
+ localPlayer.Character = character;
+ localPlayer.CharacterAdded = makeSignal();
+ localPlayer.CharacterRemoving = makeSignal();
+ localPlayer.CharacterAppearanceLoaded = makeSignal();
+
+ const humanoid = newInstance('Humanoid', 'Humanoid');
+ humanoid.Parent = character;
+ 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();
+ 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) {
+ // 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 () {};
+ 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);
+ // Реактивные 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;
+
+ // === Сервисы ===
+ 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');
+
+ // 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();
+ uis.InputEnded = makeSignal();
+
+ // TweenService — реальная интерполяция через Heartbeat
+ const tw = makeService('TweenService');
+ 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 '{}'; } };
+ 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);
+ });
+
+ 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; };
+ let _lastLightSent = 0;
+ lighting.SetMinutesAfterMidnight = function (m) {
+ lighting._minutes = (Number(m) || 0) % 1440;
+ lighting.ClockTime = lighting._minutes / 60;
+ // Тротлинг: не чаще раза в 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); };
+ lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); };
+ makeService('Chat');
+ const soundService = makeService('SoundService');
+ soundService.PlayLocalSound = function (sound) {
+ if (sound && typeof sound.Play === 'function') sound.Play();
+ };
+ makeService('PathfindingService');
+
+ // 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');
+ 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.IsClient = () => true;
+ runService.IsServer = () => true;
+ runService.IsRunning = () => true;
+ runService.IsStudio = () => false;
+
+ game.GetService = function (name) {
+ if (name === 'Workspace') return workspace;
+ 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);
+ global.set('workspace', workspace);
+ 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. Но проще: даём свой
+ // негативный 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') {
+ // Реальный примитив на сцене: шлём 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();
+ 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 === '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 === '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();
+ 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;
+ 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).
+ 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 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();
+ 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 = '';
+ // Виртуальный 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'
+ || className === 'Vector3Value' || className === 'Color3Value'
+ || className === 'BrickColorValue' || className === 'RayValue') {
+ inst = newInstance(className, className);
+ 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.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;
+ 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();
+ 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);
+ }
+ if (parent) {
+ inst.Parent = parent;
+ if (parent.Children) {
+ parent.Children.push(inst);
+ if (parent.ChildAdded) parent.ChildAdded.Fire(inst);
+ }
+ }
+ return inst;
+ },
+ });
+
+ // === 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);
+ 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'}` });
+ });
+
+ // === 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-prelude: task.wait через coroutine.yield + готовая resume-функция.
+ // Главное: __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
+ -- Минимум 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
+
+ -- Глобальный безопасный yield для любых stub-сигналов / любых
+ -- "ждунов". Используется в Lua-обёртках вокруг WaitForChild и т.п.
+ function __rbxl_yield_frame()
+ coroutine.yield(0.05)
+ end
+ -- 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
+ 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)
+ 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-handler'а из очереди в собственной coroutine.
+ -- Вызывается из JS tickScheduler — мы УЖЕ вышли из C-callback,
+ -- так что wait() внутри handler'а — yield в свою coroutine.
+ __rbxl_next_handler_id = 0
+ 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()
+ debug.sethook(function()
+ coroutine.yield(0.016)
+ end, "", 20000)
+ __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
+ end
+ `);
+ // Кешируем ссылку на 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 || '') });
+ });
+ // === Хелперы паритета с 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',
+ });
+ });
+ // Установка/удаление 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).
+ let _nextNpcRef = 0;
+ const _localToRealNpc = new Map();
+ 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,
+ });
+ });
+ 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 || '') });
+ });
+ 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}
+ 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 || ''),
+ 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);
+ });
+ 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();
+ 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.damage(amount).
+ // У игрока есть i-frames (~0.5с), так что урон не каждый кадр.
+ 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.ui.score = N. null → скрыть.
+ global.set('__rbxl_score_set', (value) => {
+ 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 });
+ });
+ // Точка возрождения — паритет с 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.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 неудобен.
+ 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: parse(pointsFlat),
+ lookAt: lookAtFlat ? parse(lookAtFlat) : [],
+ 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) => {
+ 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) => {
+ send('scene.particles', {
+ type: String(kind || 'confetti'),
+ position: { x: +x, y: +y, z: +z },
+ duration: Number(duration) || 2,
+ 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', () => {
+ 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', () => {
+ 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');
+
+ // === 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. api объявляется заранее, чтобы closures
+ // вроде __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));
+ },
+ // 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 || [];
+ // Сохраняем 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); // setters внутри шлют через send
+ part.Parent = workspace;
+ 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, ${teams.Children.length} teams`);
+ } catch (e) {
+ send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` });
+ }
+ },
+ 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() внутри безопасен.
+ if (_pendingHandlerQueue.length > 0) {
+ const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length);
+ for (const h of queue) {
+ try {
+ // ПРЯМОЙ вызов 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((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-sync-err]', e?.message || e);
+ }
+ }
+ }
+ // 0b. Tweens
+ _stepTweens(_dt);
+ const now = SCHEDULER.now();
+ // 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 (_) {}
+ }
+ }
+ // 2. Резюм coroutine'ов которые task.wait()
+ const dueCoros = [];
+ for (let i = waitingCoros.length - 1; i >= 0; i--) {
+ if (waitingCoros[i].wakeAt <= now) {
+ dueCoros.push(waitingCoros[i]);
+ waitingCoros.splice(i, 1);
+ }
+ }
+ for (const entry of dueCoros) {
+ const co = coroutines.get(entry.coId);
+ if (!co) continue;
+ try {
+ const result = luaResumeCo(co);
+ if (result === null || result === undefined) {
+ coroutines.delete(entry.coId);
+ } else if (typeof result === 'number') {
+ waitingCoros.push({
+ 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}` });
+ coroutines.delete(entry.coId);
+ }
+ }
+ },
+ fireHeartbeat(dt) {
+ try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {}
+ try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {}
+ // Авто-детект Touched на спавненных частях (id >= 800000):
+ // Спавненные через __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;
+ 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 + 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 &&
+ 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;
+ 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);
+ } else if (p.kind === 'click') {
+ // ClickDetector — стрельба по 3D-объектам.
+ // Фейерим без аргумента (передача объектов в Lua через wasmoon
+ // может крашить с null.then).
+ try {
+ const cd = part._clickDetector;
+ if (cd && cd.MouseClick) cd.MouseClick.Fire();
+ } catch (_) {}
+ try {
+ if (part.Clicked) part.Clicked.Fire();
+ } catch (_) {}
+ }
+ },
+ 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));
+ // НЕ фейерим part.Touched — это делает fireTargetEvent
+ // в routeEvent('touch'). Иначе двойной счёт.
+ 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;
+ // Ищем локальный ref по реальному
+ let localRef = null;
+ for (const [k, v] of _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;
+ const guiEl = guiByLocalRef.get(ref);
+ if (guiEl?.MouseButton1Click) {
+ 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
+ // 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') {
+ 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 (_) {}
+ // Также фейерим 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();
+ 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' || p.type === 'keyup') {
+ 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)
+ getToolByName(name) {
+ 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 };
+ },
+ // Синхронизация позиций спавненных физических частей (падающие кубы).
+ // 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,
+ };
+ return api;
+}
diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js
index 7b6caa9..093b50a 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) {
@@ -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: {} };
@@ -80,37 +94,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-сцене.
*/
@@ -122,28 +105,78 @@ 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 {
+ if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch);
+ else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
+ else if (typeof pm.update === 'function') pm.update(primId, patch);
+ } catch (e) {
+ console.error('[partSet] updateInstance failed:', e);
+ }
+ return;
+ }
+ if (cmd === 'sceneCreate') {
+ // Lua: Instance.new("Part") + part.Parent = workspace → создание примитива.
+ // payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored }
try {
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.applyPatch === 'function') pm.applyPatch(primId, patch);
- else if (typeof pm.update === 'function') pm.update(primId, patch);
- } catch (e) {}
+ 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);
+ // Если unanchored — регистрируем в физике на лету, иначе он не падает.
+ if (opts.anchored === false) {
+ try {
+ const dm = runtime.scene3d?.dynamics;
+ const data = pm.instances?.get?.(opts.id);
+ if (dm && data && typeof dm.registerPrimitive === 'function') {
+ dm.registerPrimitive(data);
+ }
+ } catch (e) {
+ console.warn('[sceneCreate] registerPrimitive failed', e);
+ }
+ }
+ } catch (e) {
+ console.error('[sceneCreate]', e);
+ }
+ return;
+ }
+ if (cmd === 'sceneDelete') {
+ // Lua: part:Destroy() → удаление примитива.
+ try {
+ const pm = runtime.scene3d?.primitiveManager;
+ if (!pm || typeof pm.removeInstance !== 'function') return;
+ const id = payload?.primId;
+ if (id != null) pm.removeInstance(Number(id));
+ } catch (e) {
+ console.error('[sceneDelete]', e);
+ }
return;
}
if (cmd === 'partVel') {
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 '