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)` — детект по `N` — особый случай: в старом XML цвет + лежит как int с именем `BrickColor`, заворачиваем в `BrickColor(code=N)`. + +В `app.py` добавлен автодетект формата: +```python +is_binary = blob.lstrip().startswith(b' Падение.** Лучше пустой stub-метод чем `nil error`. +2. **Сигналы (`Connect`/`Fire`) всегда есть на любом объекте.** +3. **Coloncall совместимость.** Если есть `Foo.Bar`, обычно делаем и `Foo:Bar` + (lowercase) как alias. +4. **При добавлении нового Instance-типа** — давай ему **все типичные поля** + сразу, не только те что нужны прямо сейчас (Equipped + Unequipped + Activated + вместе, даже если скрипт юзает только Equipped). +5. **Логировать сюда после каждой итерации** — что было добавлено и из какой игры. diff --git a/RUBLOX_LUA_SUPPORT_PLAN.md b/RUBLOX_LUA_SUPPORT_PLAN.md new file mode 100644 index 0000000..3ac4702 --- /dev/null +++ b/RUBLOX_LUA_SUPPORT_PLAN.md @@ -0,0 +1,434 @@ +# План: Полная поддержка Lua-скриптов в Рублоксе + +**Цель:** Пользователь Рублокс-студии создаёт скрипт → выбирает язык **JS** или **Lua** → пишет код → в плеере оба языка работают параллельно. Lua-скрипты совместимы с Roblox API настолько, чтобы код из Roblox-игр работал без модификаций. + +**Зачем:** В Roblox экосистеме сотни тысяч разработчиков, привыкших к Lua + Roblox API (Vector3, CFrame, Instance, RemoteEvent, etc.). Сейчас они не могут перенести свои игры. С этой фичей — могут. + +**Срок:** ~6 недель полного времени. Можно делать поэтапно (после каждого этапа есть полезный результат). + +**Юр.риск:** "юр риски беру на себя" (МИН, 2026-06-08). + +--- + +## Архитектурное решение + +### Текущая ситуация +- Скрипты хранятся как `{id, code, target, name}` в БД +- `GameRuntime` запускает каждый JS-скрипт в Web Worker `ScriptSandboxWorker.js` +- API игре доступен через `game.*` объект (события, scene, player, gui, save, etc.) +- Скриптов в игре могут быть **сотни**, каждый — отдельный Worker + +### Целевая +- Скрипты получают поле `language: 'js' | 'lua'` (default `'js'`) +- В редакторе — переключатель в `ScriptEditor.jsx` в шапке: **JS / Lua** +- Monaco-editor подсвечивает Lua (есть встроенный язык в `monaco-editor`) +- В runtime: JS-скрипты идут через старый sandbox, Lua — через новый `LuaSandbox.js` +- Lua-runtime построен поверх **wasmoon** (Lua 5.4 в WebAssembly) +- Lua-скрипты делятся на **один shared VM** на игру (а не Web Worker на скрипт) — иначе OOM при 100+ скриптах +- Lua-API проксирует Roblox API → нашу game.* плюс полную **DataModel** иерархию + +### Ключевая идея +**Lua-скрипты не вызывают `game.move(...)`. Они вызывают `script.Parent.Position = Vector3.new(1,2,3)`** — как в Roblox. Lua-shim под капотом переводит это в `partSet` команды для движка Рублокса. Юзер пишет идиоматичный Roblox-Lua, движок Рублокса исполняет. + +--- + +## Этап 1: UI и хранение языка (3 дня) + +### 1.1 Миграция БД +- Добавить поле `language VARCHAR(8) DEFAULT 'js' NOT NULL` в таблицу `scripts` (есть на storys API) +- API endpoints (`POST/PATCH /scripts/...`) принимают и сохраняют `language` +- Старые скрипты без поля → `'js'` по умолчанию + +### 1.2 ScriptEditor.jsx +- Переключатель в шапке редактора: `[ JS | Lua ]` (segmented control) +- При смене языка — confirm("Сменить язык? Текущий код будет очищен"), затем код сбрасывается на шаблон-заглушку нового языка +- Monaco language switch: `javascript` ↔ `lua` +- Подсветка/автодополнение Lua встроены в Monaco (`monaco-editor/esm/vs/basic-languages/lua`) +- Линтер ошибок Lua через **luaparse** (npm пакет, ~50KB, parse-only) — показываем красные подчёркивания +- Шаблон Lua для нового скрипта на target=Part: + ```lua + -- Скрипт на части. script.Parent = эта часть. + local part = script.Parent + part.Touched:Connect(function(hit) + print("Касание:", hit.Name) + end) + ``` +- Шаблон Lua для глобального скрипта (target=nil): + ```lua + local Players = game:GetService("Players") + Players.PlayerAdded:Connect(function(player) + print("Игрок зашёл:", player.Name) + end) + ``` + +### 1.3 Иконка языка в HierarchyPanel +- Рядом с именем скрипта — маленький бейдж `JS` или `Lua` (синий / голубой) +- Помогает не путаться при сотне скриптов + +### Что готово в конце Этапа 1 +- Юзер может **создать Lua-скрипт**, написать код, сохранить +- Код **не исполняется** — пока только хранится и редактируется +- В плеере Lua-скрипты молча игнорируются + +--- + +## Этап 2: Базовый Lua-runtime (5 дней) + +### 2.1 LuaSharedSandbox.js — новый sandbox-класс + +Полная архитектура шаринг-VM (один wasmoon-state на все Lua-скрипты игры): + +``` +GameRuntime +├── ScriptSandbox (JS-скрипт, Web Worker, как сейчас) +├── ScriptSandbox (JS-скрипт) +├── LuaSharedSandbox ← НОВЫЙ +│ ├── LuaSharedWorker.js +│ │ └── wasmoon VM (один на всю игру) +│ │ ├── Roblox API shim +│ │ ├── DataModel tree (game.Workspace, Players, ...) +│ │ └── все Lua-скрипты как сопрограммы (coroutines) +│ └── проксирует partSet/sceneCreate/event обратно в main thread +``` + +Файлы: +- `src/editor/engine/LuaSharedSandbox.js` (main thread): API совместимый с ScriptSandbox (`sendSceneSnapshot`, `sendGlobalEvent`, etc.) +- `src/editor/engine/LuaSharedWorker.js` (Web Worker): держит wasmoon, исполняет скрипты, шлёт командные сообщения +- `src/editor/engine/RobloxLuaShim.js` (worker side): объявление всех Roblox-классов и сервисов + +### 2.2 Минимальный Roblox shim в первой итерации +- `Vector3.new(x,y,z)`, `+`, `-`, `*`, `:Magnitude()`, `:Dot()`, `:Cross()`, `:Lerp()`, `:Normalize()` +- `Color3.new(r,g,b)`, `Color3.fromRGB(r,g,b)`, `:Lerp()` +- `CFrame.new(x,y,z)`, `CFrame.lookAt()`, `CFrame.fromEulerAnglesXYZ()`, операторы `*` и `:Inverse()`, `:ToWorldSpace()` +- `UDim2.new(sx,ox,sy,oy)`, `UDim.new(s,o)` +- `Enum.KeyCode.W`, `Enum.UserInputType.MouseButton1`, etc. (через generated table) +- `print()` → console + onLog event в студии +- `wait(secs)` / `task.wait(secs)` — через coroutine + scheduler в main loop +- `tick()`, `os.time()`, `os.clock()`, `math.*`, `string.*`, `table.*` (стандартные Lua) +- `pcall`, `xpcall`, `error`, `assert` (стандартные) + +### 2.3 GameRuntime интеграция +- При старте игры — пробежать по `scripts[]`, разделить на `jsScripts` и `luaScripts` +- Для JS — старый путь (по сэндбоксу на скрипт) +- Для Lua — один `LuaSharedSandbox`, в него `addScript(luaSource)` каждым +- LuaSharedSandbox шлёт назад те же команды что JS sandbox: `partSet`, `sceneCreate`, `chatSay`, `guiSet`, etc. + +### Что готово в конце Этапа 2 +- Lua-скрипты **исполняются** +- Можно использовать `Vector3`, `Color3`, `print()`, `wait()`, `math/string/table` +- Скрипт **ещё не видит** Workspace, Player, GUI + +--- + +## Этап 3: DataModel — game.Workspace и иерархия (5 дней) + +### 3.1 Что такое DataModel +В Roblox любая игра — **дерево объектов**. Корень = `game`. У него детки = сервисы: `Workspace`, `Players`, `ReplicatedStorage`, `Lighting`, `StarterGui`, `RunService`, etc. У каждого деток — свои детки. + +У нас сейчас сцена плоская: `primitives`, `blocks`, `models`. Нужно **виртуальное дерево DataModel** поверх плоской сцены. + +### 3.2 Виртуальное дерево +Файл: `src/editor/engine/datamodel/DataModelTree.js` + +При старте Lua-runtime, для текущей сцены строится виртуальное дерево: + +``` +game (RbxGame) +├── Workspace (RbxWorkspace) +│ ├── Part_0 ← обёртка над primitive id=0 +│ ├── Part_1 ← обёртка над primitive id=1 +│ ├── Model_5 ← обёртка над model id=5 (с Children) +│ │ └── Part_inner +│ ├── Camera +│ └── Terrain +├── Players (RbxPlayers) +│ └── LocalPlayer (RbxPlayer) +│ ├── Character (RbxCharacter) +│ │ ├── Humanoid (RbxHumanoid) +│ │ └── HumanoidRootPart (RbxPart) +│ └── PlayerGui (RbxScreenGui-контейнер) +│ └── (Lua-скрипты могут спавнить GUI через Instance.new) +├── ReplicatedStorage (RbxFolder) +├── ServerStorage (RbxFolder) +├── Lighting (RbxLighting) +├── StarterGui (RbxFolder) +├── StarterPlayer (RbxFolder) +│ └── StarterCharacter (RbxFolder) +├── RunService (RbxRunService с :Heartbeat, :Stepped, :RenderStepped) +├── UserInputService (события InputBegan/Ended/Changed) +├── TweenService (:Create, :GetService для tweens) +├── HttpService (заглушка либо проксируем через нашего бэка) +├── DataStoreService (проксируем через game.save) +└── MarketplaceService (заглушка) +``` + +### 3.3 Instance-классы +`src/editor/engine/datamodel/Instance.js`: + +```js +class RbxInstance { + Name = "Instance"; + ClassName = "Instance"; + Parent = null; + Children = []; + + // Свойства которые юзер может ставить через __newindex (metatable) + // отслеживаются — при изменении посылается команда в main thread + // для синхронизации с Babylon-сценой + + GetChildren() { return [...this.Children]; } + FindFirstChild(name, recursive) { ... } + WaitForChild(name, timeout) { ... } // через coroutine + yield + FindFirstAncestor(name) { ... } + FindFirstChildOfClass(class) { ... } + Destroy() { ... } + Clone() { ... } + IsA(class) { ... } + GetFullName() { ... } + GetAttribute(name) / SetAttribute(name, value) + GetPropertyChangedSignal(name) → RbxSignal +} + +class RbxPart extends RbxInstance { + Position // setter: партиклс в Babylon (primitiveManager.setPosition) + Size // setter + Color // setter + Material // setter (mapping Roblox materials → наши) + Anchored // setter + CanCollide // setter + Touched // RbxSignal — Fire когда BabylonScene детектит overlap + TouchEnded // RbxSignal + CFrame // computed property — Position + rotation +} + +class RbxModel extends RbxInstance { + PrimaryPart + GetPrimaryPartCFrame() / SetPrimaryPartCFrame() + PivotTo(cframe) // MoveTo + Rotate + GetBoundingBox() +} + +class RbxHumanoid extends RbxInstance { + Health = 100 + MaxHealth = 100 + WalkSpeed = 16 + JumpPower = 50 + Died, HealthChanged, Touched, StateChanged — signals + TakeDamage(amount) + MoveTo(pos) // simulates Roblox NPC pathing + LoadAnimation(anim) → RbxAnimationTrack +} + +class RbxScript extends RbxInstance { + Source // источник Lua (read-only обычно) + Disabled // bool + RunContext // Server / Client / Legacy +} + +class RbxRemoteEvent extends RbxInstance { + OnServerEvent : RbxSignal + OnClientEvent : RbxSignal + FireServer(...args) + FireClient(player, ...args) + FireAllClients(...args) +} +``` + +### 3.4 Метатаблицы Lua +Каждая JS-обёртка `RbxPart` экспортируется в Lua как **table с metatable**: +- `__index` — чтение свойства, либо метод +- `__newindex` — запись свойства, триггерит side-effects (синхронизация сцены) +- `__tostring` — для `print(part)` показывает `"Part_0"` + +Это **критично** для совместимости с Roblox-скриптами. + +### 3.5 script.Parent для каждого Lua-скрипта +- Если скрипт привязан к `target=42` (primitive id 42) — `script.Parent = workspace:FindFirstChild по primId(42)` +- Если глобальный — `script.Parent = nil` +- В скриптовом контексте: `script` это таблица `{Name=..., Parent=..., ClassName="Script"}` + +### Что готово в конце Этапа 3 +- Lua-скрипт может пройтись по `game.Workspace:GetChildren()` +- `script.Parent.Touched:Connect(...)` работает (KillBrick = реально) +- `local player = game.Players.LocalPlayer; player.Character.Humanoid.Health = 0` убивает игрока +- `Instance.new("Part", workspace)` создаёт примитив + +--- + +## Этап 4: Полный Roblox API (10 дней) + +Закрываем "длинный хвост" API. Каждый день — 1-2 сервиса. + +### 4.1 Services +- **RunService**: `.Heartbeat`, `.Stepped`, `.RenderStepped` — RbxSignals, фаер в main loop tick +- **UserInputService**: `.InputBegan`, `.InputChanged`, `.InputEnded` — события KeyCode/MouseButton/Touch +- **TweenService**: `:Create(instance, TweenInfo, propertyTable)` → возвращает RbxTween; `Tween:Play()/Pause()/Cancel()`; интерполируется в main loop +- **DataStoreService**: проксируем через наш `game.save` (sync version) + - `:GetDataStore(name)` → объект с `:GetAsync(key)`, `:SetAsync(key, value)`, `:UpdateAsync(key, fn)` + - Async-методы через coroutine.yield + наш save API +- **MarketplaceService**: заглушки `PromptPurchase`, `GetProductInfo` (бизнес-логика через наш интерфейс) +- **HttpService**: `:JSONEncode`, `:JSONDecode`, `:GenerateGUID` — простая встроенная реализация; `:GetAsync/PostAsync` проксируем через ограниченный список разрешённых доменов +- **Players**: `LocalPlayer`, `:GetPlayers()`, `PlayerAdded`/`PlayerRemoving` сигналы +- **Lighting**: read-only сейчас (через `Lighting.Ambient`, `Lighting.OutdoorAmbient` ставить значения нашему envManager) +- **Workspace**: `:Raycast(origin, direction, params)` → реальный raycast через PhysicsAABB; `:GetServerTimeNow()`; `CurrentCamera` + +### 4.2 GUI (важно!) +- `Instance.new("ScreenGui")` → если `Parent = playerGui`, регистрируется в нашем GuiManager +- `TextLabel`, `TextButton`, `ImageLabel`, `ImageButton`, `Frame` — все мапятся на наш GuiOverlay +- `MouseButton1Click`, `MouseEnter`, `MouseLeave`, `Activated` — сигналы +- `UDim2`, `Vector2` для позиций/размеров +- При установке `gui.Position = UDim2.new(0.5, 0, 0.5, 0)` — пересылается в GuiManager и обновляется DOM + +### 4.3 Sound +- `Instance.new("Sound", part)` с `SoundId = "rbxassetid://12345"` или с нашим URL +- `:Play()`, `:Stop()`, `:Pause()`, `Volume`, `Pitch`, `Looped` +- Под капотом — наш SoundManager + +### 4.4 Animation +- `Instance.new("Animation")` с `AnimationId` (наши собственные ID анимаций R15) +- `humanoid:LoadAnimation(anim) → AnimationTrack` +- `:Play()`, `:Stop()`, `:AdjustSpeed()`, `:GetMarkerReachedSignal()` +- Связь с нашим R15Animator + +### 4.5 Tools / Backpack +- `Tool` Instance: `Activated`, `Equipped`, `Unequipped` сигналы +- `player.Backpack:GetChildren()` — Lua видит инвентарь +- Реализация через наш HotbarManager + InventoryService + +### 4.6 ProximityPrompt, ClickDetector +- ProximityPrompt: `Triggered` сигнал, `:Show/:Hide`, ActionText, ObjectText +- ClickDetector: `MouseClick`, `MouseHoverEnter/Leave` сигналы + +### 4.7 Networking-эмуляция (single-player) +- RemoteEvent / RemoteFunction работают **локально** (поскольку у нас singleplayer на client-only) +- `FireServer/InvokeServer` запускают handlers в том же VM, но в other-side контексте +- Это позволяет копировать многопользовательские скрипты Roblox без изменений (хоть мультиплеера и нет) +- Когда у Рублокса появится мультиплеер — `RemoteEvent` уже будет работать «по-настоящему» без изменений в скриптах юзера + +### Что готово в конце Этапа 4 +- **~90% типовых Roblox-скриптов работают** без модификаций +- DataStore сохраняет прогресс +- TweenService плавно двигает объекты +- GUI создаётся скриптом +- Анимации играются + +--- + +## Этап 5: Импорт .rbxl + конвертер юзер-кода (5 дней) + +### 5.1 Изменение импортера +Сейчас импортер сохраняет Lua-исходник в JS-комментарии и пытается завернуть в JS-обёртку. **Это устаревает.** + +Новый путь: +- Импортер сохраняет Lua-source **как есть** (без обёрток) +- В записи скрипта `language = 'lua'` +- target = primitiveId или null +- В GameRuntime Lua-скрипт идёт сразу в LuaSharedSandbox + +### 5.2 Конвертация ассетов +- Roblox MeshId/TextureId через наш ImageProxy → ассеты сохраняются в minio + на CDN +- `rbxassetid://12345` → resolve в наш asset_id +- Сохранение в БД ссылок на ассеты + +### 5.3 Поведение при импорте +- При импорте .rbxl карты — все Lua-скрипты сохраняются как Lua-скрипты Рублокса (не пытаемся конвертить в JS) +- Юзер открывает игру → редактирует → видит в Hierarchy `Script (Lua)` рядом с `Script (JS)` — может писать на любом + +### Что готово в конце Этапа 5 +- Импорт Roblox-карты работает **бесшовно** +- Юзер может править Lua-код в редакторе и видеть результат + +--- + +## Этап 6: Производительность и стабильность (5 дней) + +### 6.1 Profiling +- Замерить FPS на картах с 100/500/1000 Lua-скриптов +- Если падает — переезд на **fengari** (pure-JS Lua interp, в 5-10× медленнее wasmoon но без WASM overhead на старте) либо на **собственный Lua-bytecode runtime** для горячих скриптов + +### 6.2 Memory +- Каждый wasmoon VM ~10-15MB. Один на игру = ОК. Если придётся разделять на части (server/client), нужно бенчить. + +### 6.3 Песочница (security) +- Lua не должен дёргать `io.*`, `os.execute`, `loadstring(внешний код)`, etc. +- Whitelist стандартной библиотеки. Запрещаем всё что может выйти из браузера. + +### 6.4 Ошибки +- Lua-ошибки в скрипте — показываются в Output-панели студии (как JS-ошибки) +- Stack trace с правильными номерами строк (не из обёртки) +- Если скрипт зациклился — kill через `debug.sethook` после N инструкций без yield + +### 6.5 Тесты +- 50 unit-тестов на Roblox API (Vector3 операции, CFrame, Instance.new, Touched, RunService, TweenService) +- 10 интеграционных: импортировать тест-rbxl, запустить, проверить что нужное случилось +- CI: тесты прогоняются в Gitea Actions при PR + +### Что готово в конце Этапа 6 +- Lua-runtime production-ready +- Можно объявить публично «теперь в Рублоксе пишут на Lua с Roblox-совместимостью» + +--- + +## Этап 7: Документация (3 дня) + +### 7.1 Раздел вики +- `wiki/lua-intro` — введение в Lua для Рублокса (для пользователей, которые приходят с Roblox — короткое) +- `wiki/lua-vs-js` — таблица: «то же самое на JS и на Lua» для типичных задач +- `wiki/roblox-api-supported` — список того что работает / не работает / отличается +- `wiki/lua-examples` — 20 готовых сниппетов (KillBrick, TeleportPad, Checkpoint, Coin, NPCFollower, etc.) + +### 7.2 Migration guide +- «Как перенести свою Roblox-игру в Рублокс» — пошаговое +- Список известных несовместимостей и их обходов + +### 7.3 PR-материал +- Пост в /developer на team.rublox.pro: «Lua-поддержка теперь GA» +- Если есть бюджет — короткий ролик на YouTube/TikTok + +--- + +## Этапы целиком + +| Этап | Длительность | Содержание | +|------|--------------|------------| +| 1 | 3 дня | UI и хранение языка | +| 2 | 5 дней | Базовый Lua-runtime + минимальный shim | +| 3 | 5 дней | DataModel (game.Workspace и иерархия) | +| 4 | 10 дней | Полный Roblox API (services, GUI, Sound, Animation) | +| 5 | 5 дней | Импорт .rbxl и асетов | +| 6 | 5 дней | Производительность, безопасность, тесты | +| 7 | 3 дня | Документация и публикация | +| **Итого** | **~36 рабочих дней** | **~6 недель полного времени** | + +--- + +## Что-то можно делать раньше, чтобы получить пользу + +- **MVP (Этапы 1+2):** через **8 дней** — юзер может писать Lua-скрипты с минимальным API (Vector3, Color3, print, wait). Уже видит что фича есть. +- **Beta (Этапы 1-3):** через **13 дней** — KillBrick'и работают, можно делать простые игры на Lua. +- **GA (все этапы):** через **6 недель** — продакшен. + +После каждого этапа можно делать релиз и собирать фидбек. + +--- + +## Решения которые нужны от тебя перед стартом + +1. **wasmoon vs fengari** — wasmoon быстрее но WASM-heavy, fengari проще но медленнее. Предлагаю wasmoon (уже используем для импорта). +2. **Один shared VM на игру** — согласен или разделять server/client? Предлагаю один в singleplayer-фазе, разделение — позже когда будет мультиплеер. +3. **Бэкенд изменения** — нужна миграция БД (поле `language`). У нас сейчас S2 + S1 + auto-backup, ничего страшного, но согласовать момент апдейта. +4. **Roblox API trademark/copyright** — мы делаем API-compatible runtime. Названия классов `Workspace`, `Humanoid`, etc. это API names. Юр.риск есть. Ты сказал берёшь — фиксируем. +5. **Приоритет** — этот план делать **вместо** других задач (тогда параллельные фичи стопаются) или **после** текущего бэклога? + +--- + +## Связанные документы + +- `RUBLOX_PROJECT.md` — общий план Рублокса +- `RUBLOX_EDITOR_ROADMAP.md` — куда движется редактор +- `INFO_PROCESS.md` — лог реализации (будет апдейтиться по ходу) + +--- + +**Создано:** 2026-06-08, Claude (Opus 4.7) совместно с МИНом. +**Статус:** план готов, ждём решения по 5 вопросам перед стартом. diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc new file mode 100644 index 0000000..052a1c9 Binary files /dev/null and b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc new file mode 100644 index 0000000..946eaaf Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc new file mode 100644 index 0000000..6d971cf Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc new file mode 100644 index 0000000..b0a3d0d Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc new file mode 100644 index 0000000..b54fe31 Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc differ diff --git a/rbxl-importer/src/app.py b/rbxl-importer/src/app.py index 00a113c..9da2929 100644 --- a/rbxl-importer/src/app.py +++ b/rbxl-importer/src/app.py @@ -122,12 +122,23 @@ def analyze(): blob = upload.read() if len(blob) > MAX_RBXL_SIZE: return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413 - if not blob.startswith(b'... + stripped = blob.lstrip() + is_binary = stripped.startswith(b' None: snd['url'] = asset_map[rid] +def _apply_gui_mode(project_data: dict, mode: str) -> None: + """Фильтрует scene.gui[] по режиму. + + 'all' — оставить всё (default). + 'screen-only' — оставить только ScreenGui-HUD, удалить billboard/surface. + Карты с 200+ BillboardGui (Robloxity) перестают тормозить. + 'skip' — удалить gui[] совсем. + """ + scene = project_data.get('scene', {}) + if mode == 'skip': + scene['gui'] = [] + return + if mode == 'screen-only': + gui = scene.get('gui', []) + scene['gui'] = [g for g in gui + if g.get('gui_container_kind', 'screen') == 'screen'] + return + # 'all' — без изменений + + +def _apply_scripts_mode(project_data: dict, mode: str) -> None: + """Применяет режим scripts_mode к проекту. + + mode='disabled' (default): для каждого скрипта меняем JSON-метадату + на 2-й строке packed-кода — выставляем enabled=False. GameRuntime + уже умеет уважать этот флаг и не запускает. + mode='enabled': оставляем как было (как пришло из конвертера). + mode='skip': удаляем все scripts из scene.scripts полностью. + """ + scene = project_data.get('scene', {}) + scripts = scene.get('scripts', []) + if not scripts: + return + + if mode == 'skip': + scene['scripts'] = [] + return + + if mode == 'enabled': + return # ничего не делаем + + # mode == 'disabled' — патчим метадату каждого скрипта. + # Формат packed-кода (см. converter._convert_script): + # "// @roblox-lua\n// {JSON}\n/* lua_source:\n...source...\n*/\n" + for s in scripts: + code = s.get('code', '') + lines = code.split('\n', 2) + if len(lines) < 2 or not lines[0].startswith('// @roblox-lua'): + continue + meta_line = lines[1] + if not meta_line.startswith('// '): + continue + try: + meta = json.loads(meta_line[3:]) + meta['enabled'] = False + new_meta_line = '// ' + json.dumps(meta, ensure_ascii=False) + s['code'] = lines[0] + '\n' + new_meta_line + '\n' + (lines[2] if len(lines) > 2 else '') + except (json.JSONDecodeError, ValueError): + continue + + if __name__ == '__main__': app.run(host='0.0.0.0', port=8690, debug=False) diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index 8785b7f..ce15ca2 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -103,19 +103,42 @@ SHAPE_TO_PRIMITIVE = { # ────── BrickColor таблица (упрощённая) ────── # Roblox использует old BrickColor enum (числа 1-1032). Только распространённые: BRICKCOLOR_TO_HEX = { - 1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea', - 21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e', - 28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', - 101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', - 105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', - 111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76', - 141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91', - 199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8', + # Базовые тона + 1: '#f2f3f3', 2: '#a1a5a2', 3: '#f9e999', 5: '#d9e4f7', + 9: '#9c9e9c', 11: '#e8eaea', 18: '#cc8e69', 21: '#c4281c', + 23: '#0d69ac', 24: '#f5cd30', 26: '#1b2a35', 28: '#293f1a', + 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', 101: '#dab8a3', + 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', 105: '#cf8b3e', + 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', 111: '#a7a6a6', + 115: '#c7d23c', 116: '#56fff0', 118: '#b4d2e4', 119: '#aac84a', + 120: '#d4f0a6', 123: '#cf6b6f', 124: '#9c54a6', 125: '#e8b486', + 126: '#a6c2e3', 127: '#deb87b', 128: '#a37e5b', 131: '#9ba19d', + 133: '#cc7c39', 134: '#de8b5f', 135: '#74859c', 136: '#876a7a', + 137: '#e6a262', 138: '#8a8a76', 140: '#234770', 141: '#26462b', + 143: '#bdc3e3', 145: '#5c8aa1', 146: '#75718b', 147: '#9a8a64', + 148: '#5a605a', 149: '#1b2a47', 150: '#9ea1a3', + # ВАЖНО: 151 — Earth green (тёмная трава Crossroads) + 151: '#7c9b53', + 153: '#9b605a', 154: '#7a2d2d', 157: '#f5e09c', 158: '#b58c9c', + 168: '#3c3a37', 176: '#a39989', 178: '#aa724c', 180: '#cc9555', + 190: '#f7b830', 191: '#e69138', + 192: '#5a3019', 193: '#f59d24', 194: '#9c9b91', 195: '#447ba6', + 196: '#283970', 198: '#7b4b85', 199: '#3c3e3f', 200: '#7a854b', + 208: '#dbdcdc', 209: '#a4733f', 210: '#7d8a8e', 211: '#9da3b3', + 212: '#a5cce0', 213: '#6584b5', 215: '#7c8aa4', 216: '#8a5040', + 217: '#7a5443', 218: '#94748a', 219: '#5c5a8a', 220: '#a3a8c4', + 221: '#cc4488', 222: '#e8a8e0', 223: '#dd7790', 224: '#f3e3a5', + 225: '#e8b685', 226: '#fff8a8', 232: '#bce0f0', 268: '#3c2e74', + 301: '#73584b', + # Бипалитра 1001-1032 — стандартные яркие цвета 1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000', 1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00', 1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff', 1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0', 1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80', + 1021: '#80c0ff', 1022: '#80ffff', 1023: '#80ff00', 1024: '#00ff80', + 1025: '#ff4040', 1026: '#8a0028', 1027: '#001f80', 1028: '#4d4d4d', + 1029: '#9d9d9d', 1030: '#5e3923', 1031: '#7a4f30', 1032: '#cca5a5', } @@ -241,12 +264,49 @@ class Converter: 'sounds': [], 'glbModels': [], 'scripts': [], + # Команды PvP (Roblox Battle): {id, name, color_hex, auto_assignable} + 'teams': [], + # Spawn-точки команд (для SpawnLocation.TeamColor) + 'team_spawns': [], # {team_color_hex, x, y, z} } + # Эвристика для Roblox Battle: Model с именем "TeamBeacon X" → + # команда X. PvP-карты часто используют этот паттерн вместо Team-инстансов. + TEAM_BEACON_COLORS = { + 'Black': '#1f1f1f', 'Blue': '#0d69ac', 'Red': '#c4281c', + 'Green': '#4b9740', 'White': '#f2f3f3', 'Yellow': '#f5cd30', + 'Orange': '#d97e29', 'Purple': '#6b327a', + } + for inst in self.model.instances: + name = inst.properties.get('Name', '') + if (inst.class_name == 'Model' and isinstance(name, str) + and name.startswith('TeamBeacon ')): + team_name = name.replace('TeamBeacon ', '').strip() + color = TEAM_BEACON_COLORS.get(team_name, '#cccccc') + scene['teams'].append({ + 'id': f'team_{len(scene["teams"]) + 1}', + 'name': team_name, + 'color_hex': color, + 'auto_assignable': True, + }) + # Обходим все instances и конвертим for inst in self.model.instances: self._convert_one(inst, scene) + # Spawn fallback: если SpawnLocation в карте НЕ был (или дефолт 0,2,0 + # остался) — поднимаем выше самой высокой Part'ы. Иначе игрок + # появляется внутри Anchored=True геометрии и не может двигаться. + sp = scene.get('spawnPoint', {'x': 0, 'y': 2, 'z': 0}) + if sp.get('x') == 0 and sp.get('y') == 2 and sp.get('z') == 0: + prims = scene.get('primitives', []) + if prims: + max_top = max( + (p['y'] + p.get('sy', 1) / 2) for p in prims + if isinstance(p.get('y'), (int, float)) + ) + scene['spawnPoint'] = {'x': 0, 'y': max_top + 5, 'z': 0} + # Финальный отчёт о скипнутых классах for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]: self.stats.warnings.append(f"skipped {n}× {cls}") @@ -308,8 +368,12 @@ class Converter: elif cls == 'Workspace': # Workspace = root, его свойства мапим на scene.worldSize и т.п. pass + elif cls == 'Team': + # PvP-команда: имя + цвет в scene.teams[]. + self._convert_team(inst, scene) elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui', 'StarterPack', 'StarterCharacterScripts', 'Players', + 'Teams', 'ReplicatedStorage', 'ServerScriptService', 'ServerStorage', 'SoundService', 'TweenService', 'RunService', 'UserInputService', 'HttpService', 'DataStoreService', @@ -374,7 +438,9 @@ class Converter: 'canCollide': bool(props.get('CanCollide', True)), 'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else True, 'opacity': max(0.0, 1.0 - (props.get('Transparency', 0) or 0)), - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], } @@ -405,7 +471,9 @@ class Converter: 'material': material_to_string(props.get('Material')), 'canCollide': bool(props.get('CanCollide', True)), 'visible': True, - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], }) @@ -434,7 +502,9 @@ class Converter: 'material': material_to_string(props.get('Material')), 'canCollide': bool(props.get('CanCollide', True)), 'visible': True, - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], }) @@ -506,7 +576,9 @@ class Converter: 'material': material_to_string(props.get('Material')), 'canCollide': bool(props.get('CanCollide', True)), 'visible': True, - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'note': f'MeshPart (no GLB) rbxid={rbx_id}', @@ -527,7 +599,9 @@ class Converter: 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'color': get_part_color(props), 'canCollide': bool(props.get('CanCollide', True)), - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'origin': 'roblox-meshpart', 'rbxAssetId': rbx_id, }) @@ -567,7 +641,9 @@ class Converter: 'material': material_to_string(props.get('Material')), 'canCollide': bool(props.get('CanCollide', True)), 'visible': True, - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'mass': 1.0, 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'note': f'Union (no CSG GLB) rbxid={rbx_id}', @@ -586,7 +662,9 @@ class Converter: 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'color': get_part_color(props), 'canCollide': bool(props.get('CanCollide', True)), - 'anchored': bool(props.get('Anchored', False)), + # FORCE-ANCHORED — Welds импортируем как заглушки, без них + # физика 700+ unanchored Part'ов = карта рассыпается. + 'anchored': True, 'origin': 'roblox-union', 'rbxAssetId': rbx_id, }) @@ -594,15 +672,43 @@ class Converter: # ─── Spawn ─── + def _convert_team(self, inst: Instance, scene: Dict) -> None: + """Roblox Team → scene.teams[].""" + props = inst.properties + name = str(props.get('Name', 'Team')) + # TeamColor — BrickColor код, мапим в hex через существующую таблицу + team_color = props.get('TeamColor') + color_hex = '#ffffff' + if isinstance(team_color, BrickColor): + color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc') + scene['teams'].append({ + 'id': f'team_{len(scene["teams"]) + 1}', + 'name': name, + 'color_hex': color_hex, + 'auto_assignable': bool(props.get('AutoAssignable', True)), + }) + def _convert_spawn(self, inst: Instance, scene: Dict) -> None: props = inst.properties cf = props.get('CFrame') pos, _ = cframe_to_pos_rot(cf, self.scale) - # Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита, - # юзер появляется на её верхней грани. + + # TeamColor (если есть) → spawn для команды. + team_color = props.get('TeamColor') + if isinstance(team_color, BrickColor): + color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc') + scene['team_spawns'].append({ + 'team_color_hex': color_hex, + 'x': pos['x'], 'y': pos['y'] + 1.5, 'z': pos['z'], + 'neutral': not bool(props.get('Neutral', True)) and team_color.code != 0, + }) + + # Spawn должен быть значительно выше — старые Roblox-карты часто имеют + # толстый Floor выше плиты, юзер появляется внутри стены/пола если + # не дать запас. +5 единиц достаточно — гравитация уронит на пол. scene['spawnPoint'] = { 'x': pos['x'], - 'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите + 'y': pos['y'] + 5, 'z': pos['z'], } @@ -719,9 +825,13 @@ class Converter: if not hasattr(self, '_screen_gui_refs'): self._screen_gui_refs = set() self._screen_gui_enabled = {} + self._screen_gui_kind = {} # ref → 'screen' | 'billboard' | 'surface' self._screen_gui_refs.add(inst.referent) enabled = inst.properties.get('Enabled', True) self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else True + # Сохраняем тип контейнера — потом отфильтруем 3D-GUI если выбрано screen-only + kind = {'ScreenGui': 'screen', 'BillboardGui': 'billboard', 'SurfaceGui': 'surface'}.get(inst.class_name, 'screen') + self._screen_gui_kind[inst.referent] = kind def _gui_parent_id(self, parent_ref) -> Optional[str]: if parent_ref is None: @@ -815,12 +925,14 @@ class Converter: # элемент тоже невидим. parent_ref = inst.parent_referent screen_enabled = True + container_kind = 'screen' # default if hasattr(self, '_screen_gui_refs'): cur = parent_ref depth = 0 while cur is not None and depth < 50: if cur in self._screen_gui_refs: screen_enabled = self._screen_gui_enabled.get(cur, True) + container_kind = self._screen_gui_kind.get(cur, 'screen') break # Поиск родителя cur в instances (если есть) cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None @@ -873,6 +985,10 @@ class Converter: 'imageAsset': None, 'zIndex': int(props.get('ZIndex', 1) or 1), 'origin': 'roblox-' + cls.lower(), + # 'screen' — обычный HUD; 'billboard' — 3D-табличка над частью; + # 'surface' — на грани Part. Last 2 рендерятся в 3D-сцене и + # сильно тормозят если их сотни. + 'gui_container_kind': container_kind, } scene['gui'].append(element) diff --git a/rbxl-importer/src/rbxl_types.py b/rbxl-importer/src/rbxl_types.py index 4113753..21784e5 100644 --- a/rbxl-importer/src/rbxl_types.py +++ b/rbxl-importer/src/rbxl_types.py @@ -113,18 +113,28 @@ class CFrame: matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22) def to_euler_xyz(self) -> tuple: - """Конверт 3x3 rotation matrix в Euler XYZ (radians). + """Конверт 3x3 rotation matrix в Euler YXZ (Babylon convention). - Использует стандартную intrinsic XYZ rotation extraction: - Rx = atan2(r21, r22) - Ry = atan2(-r20, sqrt(r21² + r22²)) - Rz = atan2(r10, r00) + Babylon mesh.rotation = Vector3(rx, ry, rz) применяется в порядке YXZ + (rotate Y first, then X, then Z). Чтобы извлечь Euler из матрицы под + этот convention, используем формулу YXZ-extraction: + Rx = asin(-r12) + Ry = atan2(r02, r22) + Rz = atan2(r10, r11) + (имя метода to_euler_xyz сохраняем для совместимости вызовов.) """ import math r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix - rx = math.atan2(r21, r22) - ry = math.atan2(-r20, math.sqrt(r21*r21 + r22*r22)) - rz = math.atan2(r10, r00) + # Edge case: r12 близко к ±1 (gimbal lock на X = ±90°) + clamped = max(-1.0, min(1.0, -r12)) + rx = math.asin(clamped) + if abs(clamped) > 0.99999: + # Gimbal lock — z = 0, y = atan2(-r20, r00) + ry = math.atan2(-r20, r00) + rz = 0.0 + else: + ry = math.atan2(r02, r22) + rz = math.atan2(r10, r11) return (rx, ry, rz) @@ -551,19 +561,30 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple: Источник: https://dom.rojo.space/binary#cframe-orientation-ids - Это полная таблица 24-х валидных orientation id для cube symmetries. - Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22). + Формула из rbx-dom: + orientation_id = (rx_axis * 6) + ry_axis + 1 + где rx_axis, ry_axis ∈ {0..5} = (R0, R1, R2, R3, R4, R5): + R0 = +X, R1 = +Y, R2 = +Z, R3 = -X, R4 = -Y, R5 = -Z + + rx — это направление куда смотрит локальная +X ось куба (правая грань), + ry — направление куда смотрит локальная +Y ось (верхняя грань). + + Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22) row-major. + Матрица собирается так: rx, ry, rz это столбцы. """ - # Таблица из rbx-dom. Каждое значение — пара (rx_axis, ry_axis) где - # значения в {0,1,2,3,4,5} = +X, -X, +Y, -Y, +Z, -Z + # Правильный порядок axes (rbx-dom): + # 0=+X, 1=+Y, 2=+Z, 3=-X, 4=-Y, 5=-Z AXES = [ - (1, 0, 0), (-1, 0, 0), - (0, 1, 0), (0, -1, 0), - (0, 0, 1), (0, 0, -1), + (1, 0, 0), # +X + (0, 1, 0), # +Y + (0, 0, 1), # +Z + (-1, 0, 0), # -X + (0, -1, 0), # -Y + (0, 0, -1), # -Z ] - # orientation_id = 1..24 (1-based) - if not (1 <= orientation_id <= 24): - # Неверный id — возвращаем identity + # orientation_id = 1..36 (некоторые комбинации rx==ry невалидны, в файлах + # не встречаются — но id может доходить до 6*6 = 36, не 24). + if not (1 <= orientation_id <= 36): return (1, 0, 0, 0, 1, 0, 0, 0, 1) idx = orientation_id - 1 @@ -571,16 +592,14 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple: ry_idx = idx % 6 rx = AXES[rx_idx] ry = AXES[ry_idx] - # rz = rx × ry (cross product) + # rz = rx × ry (cross product) — третий столбец rz = ( rx[1] * ry[2] - rx[2] * ry[1], rx[2] * ry[0] - rx[0] * ry[2], rx[0] * ry[1] - rx[1] * ry[0], ) - # Матрица: первые 3 — first row (R_xx, R_yx, R_zx) - # Сложновато; берём из rbx-dom convention: первые три — основа R*XAxis, - # затем R*YAxis, затем R*ZAxis. Расширяем в row-major form. - # На практике: orientation вектора (rx, ry, rz) — это **столбцы** матрицы. + # rx, ry, rz — это СТОЛБЦЫ матрицы. + # row-major: [r00=rx[0], r01=ry[0], r02=rz[0], r10=rx[1], r11=ry[1], r12=rz[1], ...] r00, r10, r20 = rx r01, r11, r21 = ry r02, r12, r22 = rz diff --git a/rbxl-importer/src/rbxl_xml_parser.py b/rbxl-importer/src/rbxl_xml_parser.py new file mode 100644 index 0000000..0807320 --- /dev/null +++ b/rbxl-importer/src/rbxl_xml_parser.py @@ -0,0 +1,342 @@ +""" +rbxl_xml_parser.py — парсер XML-формата .rbxl (старые карты до 2010 года). + +Roblox-XML формат — текстовый предок бинарного .rbxl. Файл начинается с + и содержит дерево .... + +Возвращает тот же `RobloxModel` что и rbxl_parser.parse — чтобы converter.py +работал без изменений. + +Пример входного файла: + + + + Workspace + + + + + 0100 + 1...1 + + 412 + 4286611584 + 21 + + + + + +Поддерживает все типичные property-теги: string, bool, int, float, double, +token, Vector3, Vector2, CoordinateFrame, Color3, Color3uint8, BrickColor, +Content, ProtectedString, Ref, BinaryString, UDim, UDim2, Rect2D. +""" +from __future__ import annotations +from typing import Dict, List, Any, Optional, Tuple +import xml.etree.ElementTree as ET +import re +import base64 +import struct + +from rbxl_parser import Instance, RobloxModel +from rbxl_types import ( + Vector3, Vector2, Color3, CFrame, BrickColor, + EnumValue, PhysicalProperties, OptionalCFrame, +) + + +# Magic для XML-формата +XML_MAGIC = b' bool: + """Проверяет XML это или нет. Бинарный начинается с str: + """Текст элемента (None → default).""" + return (el.text if el.text is not None else default).strip() + + +def _f(el: ET.Element, default: float = 0.0) -> float: + """Float из text.""" + try: + return float(_text(el, '0')) + except (ValueError, TypeError): + return default + + +def _i(el: ET.Element, default: int = 0) -> int: + """Int из text.""" + try: + s = _text(el, '0') + # Roblox иногда пишет '1.0' где ожидается int + return int(float(s)) if '.' in s else int(s) + except (ValueError, TypeError): + return default + + +def _parse_vector3(el: ET.Element) -> Vector3: + x = _f(el.find('X')) + y = _f(el.find('Y')) + z = _f(el.find('Z')) + return Vector3(x, y, z) + + +def _parse_vector2(el: ET.Element) -> Vector2: + x = _f(el.find('X')) + y = _f(el.find('Y')) + return Vector2(x, y) + + +def _parse_cframe(el: ET.Element) -> CFrame: + """CoordinateFrame: 3 позиции + 9 элементов матрицы ротации.""" + pos = Vector3(_f(el.find('X')), _f(el.find('Y')), _f(el.find('Z'))) + matrix = tuple(_f(el.find(f'R{i}{j}'), 1.0 if i == j else 0.0) + for i in range(3) for j in range(3)) + return CFrame(position=pos, matrix=matrix) + + +def _parse_color3(el: ET.Element) -> Color3: + """.........""" + r = _f(el.find('R')) + g = _f(el.find('G')) + b = _f(el.find('B')) + return Color3(r, g, b) + + +def _parse_color3uint8(el: ET.Element) -> Color3: + """4286611584 — packed RGB как uint32.""" + val = _i(el, 0) + # uint32 = 0xFFRRGGBB (alpha=FF). r=byte2, g=byte1, b=byte0 + b = (val & 0xff) / 255.0 + g = ((val >> 8) & 0xff) / 255.0 + r = ((val >> 16) & 0xff) / 255.0 + return Color3(r, g, b) + + +def _parse_property(prop_el: ET.Element) -> Tuple[str, Any]: + """Парсит один <тип name="имя">значение. Возвращает (name, value).""" + tag = prop_el.tag + name = prop_el.attrib.get('name', '') + + if tag == 'string' or tag == 'ProtectedString' or tag == 'Content': + return name, _text(prop_el) + + if tag == 'bool': + return name, _text(prop_el).lower() == 'true' + + if tag in ('int', 'int64'): + val = _i(prop_el) + # В старом XML цвет хранится как 21, + # а converter ожидает BrickColor-объект с .code. + if name == 'BrickColor': + return name, BrickColor(code=val) + return name, val + + if tag in ('float', 'double'): + return name, _f(prop_el) + + if tag == 'token': + # token — int-значение enum + return name, EnumValue(value=_i(prop_el)) + + if tag == 'Vector3': + return name, _parse_vector3(prop_el) + + if tag == 'Vector2': + return name, _parse_vector2(prop_el) + + if tag == 'CoordinateFrame': + return name, _parse_cframe(prop_el) + + if tag == 'Color3': + return name, _parse_color3(prop_el) + + if tag == 'Color3uint8': + return name, _parse_color3uint8(prop_el) + + if tag == 'BrickColor': + return name, BrickColor(code=_i(prop_el)) + + if tag == 'Ref': + # Ссылка на другой Item по referent (например "RBX42" или "null") + txt = _text(prop_el, 'null') + if txt in ('null', 'nil', ''): + return name, None + return name, txt # храним как строку-referent + + if tag == 'BinaryString': + # base64 → bytes + try: + return name, base64.b64decode(_text(prop_el)) + except Exception: + return name, b'' + + if tag == 'UDim': + scale = _f(prop_el.find('S')) + offset = _i(prop_el.find('O')) + return name, {'scale': scale, 'offset': offset} + + if tag == 'UDim2': + xs = _f(prop_el.find('XS')) + xo = _i(prop_el.find('XO')) + ys = _f(prop_el.find('YS')) + yo = _i(prop_el.find('YO')) + return name, {'x_scale': xs, 'x_offset': xo, 'y_scale': ys, 'y_offset': yo} + + if tag == 'Rect2D': + # min/max + min_el = prop_el.find('min') + max_el = prop_el.find('max') + return name, { + 'min': _parse_vector2(min_el) if min_el is not None else Vector2(0, 0), + 'max': _parse_vector2(max_el) if max_el is not None else Vector2(0, 0), + } + + if tag == 'OptionalCoordinateFrame': + cf_el = prop_el.find('CFrame') + return name, OptionalCFrame(cframe=_parse_cframe(cf_el)) if cf_el is not None else OptionalCFrame(cframe=None) + + if tag == 'PhysicalProperties': + cust = prop_el.find('CustomPhysics') + custom = cust is not None and _text(cust).lower() == 'true' + return name, PhysicalProperties( + custom_physics=custom, + density=_f(prop_el.find('Density'), 0.7), + friction=_f(prop_el.find('Friction'), 0.3), + elasticity=_f(prop_el.find('Elasticity'), 0.5), + friction_weight=_f(prop_el.find('FrictionWeight'), 1.0), + elasticity_weight=_f(prop_el.find('ElasticityWeight'), 1.0), + ) + + if tag == 'NumberRange': + return name, {'min': _f(prop_el.find('Min')), 'max': _f(prop_el.find('Max'))} + + # SharedString / Uri / другие незнакомые — оставляем как текст + return name, _text(prop_el) + + +# Регекс для извлечения referent из строк типа "RBX42" +_REF_RE = re.compile(r'^RBX(\d+)$') + + +def _ref_to_int(ref: Optional[str]) -> Optional[int]: + """RBX42 → 42, null → None. Если уникальной номер не найден — None.""" + if ref is None or ref == 'null': + return None + m = _REF_RE.match(str(ref)) + if m: + return int(m.group(1)) + return None + + +def parse_xml(blob: bytes) -> RobloxModel: + """Главный entry: bytes → RobloxModel.""" + try: + text = blob.decode('utf-8', errors='replace') + except Exception: + text = blob.decode('latin-1', errors='replace') + + # XML может иметь BOM или leading whitespace + text = text.lstrip('').lstrip() + + root = ET.fromstring(text) + + instances: List[Instance] = [] + by_referent: Dict[int, Instance] = {} + roots: List[Instance] = [] + + # Auto-increment id для Item'ов без referent (старые форматы) + next_id_counter = [100000] + + def _walk(item_el: ET.Element, parent_ref: Optional[int]) -> None: + """Рекурсивный обход элементов.""" + cls = item_el.attrib.get('class', 'Unknown') + + # Referent из атрибута (например referent="RBX42") + ref_attr = item_el.attrib.get('referent') or item_el.attrib.get('Referent') + ref_int = _ref_to_int(ref_attr) if ref_attr else None + if ref_int is None: + # Назначаем auto-id чтобы converter мог отслеживать parent_referent + ref_int = next_id_counter[0] + next_id_counter[0] += 1 + + # Парсим properties + props: Dict[str, Any] = {} + props_el = item_el.find('Properties') + if props_el is not None: + for prop_el in props_el: + try: + pname, pval = _parse_property(prop_el) + if pname: + props[pname] = pval + except Exception: + continue + + # Roblox в старых картах использовал имена с маленькой первой буквы: + # name → Name, size → Size, shape → Shape, и т.д. Converter ожидает + # PascalCase. Делаем алиасы (старое имя остаётся, новое добавляется). + _ALIAS_TO_PASCAL = { + 'name': 'Name', + 'size': 'Size', + 'shape': 'Shape', + 'archivable': 'Archivable', + 'shape3d': 'Shape', + } + for old, new in _ALIAS_TO_PASCAL.items(): + if old in props and new not in props: + props[new] = props[old] + + # Convert Ref-properties (string "RBX42") в parent_referent если нужно + # — пока оставляем как строки. + + inst = Instance( + referent=ref_int, + class_name=cls, + properties=props, + parent_referent=parent_ref, + children=[], + ) + instances.append(inst) + by_referent[ref_int] = inst + if parent_ref is None: + roots.append(inst) + + # Рекурсивно дочерние Item'ы + for child in item_el.findall('Item'): + _walk(child, ref_int) + + # Roblox-XML: top-level идут под + for item in root.findall('Item'): + _walk(item, None) + + # Заполняем children после полного прохода (для удобства converter'а) + for inst in instances: + if inst.parent_referent is not None: + parent = by_referent.get(inst.parent_referent) + if parent is not None: + parent.children.append(inst) + + # Версия из атрибута + version_attr = root.attrib.get('version', '4') + try: + version = int(version_attr) + except ValueError: + version = 4 + + return RobloxModel( + version=version, + class_count=len(set(i.class_name for i in instances)), + instance_count=len(instances), + instances=instances, + by_referent=by_referent, + roots=roots, + shared_strings=[], + meta={}, + warnings=[], + ) diff --git a/src/api/API.js b/src/api/API.js index 8fa4139..3e27ac8 100644 --- a/src/api/API.js +++ b/src/api/API.js @@ -10,7 +10,7 @@ export const USER_addres = BASE + '/api-user'; export const ACHIVES_addres = BASE + '/api-achievs'; export const COMMENTS_addres = BASE + '/api-comments'; export const STORYS_addres = BASE + '/api-storys'; -// rbxl-importer: только для МИНа (тест-фича импорта .rbxl карт Roblox) +// rbxl-importer: импорт .rbxl карт Roblox (см. вики «Импорт из Roblox») export const RBXL_addres = BASE + '/api-rbxl'; export const NOTICES_addres = BASE + '/api-notices'; export const HELP_addres = BASE + '/api-help'; diff --git a/src/api/rbxlImporterApi.js b/src/api/rbxlImporterApi.js index 68675f2..9fbe53a 100644 --- a/src/api/rbxlImporterApi.js +++ b/src/api/rbxlImporterApi.js @@ -52,11 +52,20 @@ export async function analyzeRbxl(file) { /** * Создаёт проект из preview_hash. Возвращает { project_id, redirect, assets_downloaded, assets_failed }. */ -export async function createRbxlProject(previewHash, title) { +export async function createRbxlProject(previewHash, title, opts = {}) { const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, { method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ preview_hash: previewHash, title: title || '' }), + body: JSON.stringify({ + preview_hash: previewHash, + title: title || '', + // 'disabled' (default) — импортнуть выключенными, читать можно + // 'enabled' — попытаться запустить (может вешать карту) + // 'skip' — не импортировать совсем + scripts_mode: opts.scriptsMode || 'disabled', + // 'all' (default) / 'screen-only' (только HUD) / 'skip' (без GUI) + gui_mode: opts.guiMode || 'all', + }), }); if (!resp.ok) { const text = await resp.text(); diff --git a/src/community/KubikonDocs.jsx b/src/community/KubikonDocs.jsx index 8f386d4..53fa92a 100644 --- a/src/community/KubikonDocs.jsx +++ b/src/community/KubikonDocs.jsx @@ -13,6 +13,8 @@ import { GAMES, GAME_GROUPS } from './docsGames'; import { LESSONS, hasLesson } from './docsLessons'; import { buildGameProject } from './docsGamesBuilders'; import DocIcon from './docsIcons'; +import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang'; +import { LUA_OVERRIDES } from './docsGamesBuildersLua'; /** * KubikonDocs — вика редактора Рублокс. @@ -76,6 +78,7 @@ const KubikonDocs = () => { return (
+ {/* === Левая боковая панель === */}
+ {state === 'choosing' && ( + createCopyWithLang(lang)} + onCancel={() => setState('idle')} + /> + )} {state === 'error' && (
Не получилось открыть игру. Проверь, что ты вошёл в аккаунт, @@ -484,14 +501,134 @@ const LessonPage = ({ game, navigate }) => {
)} - {/* Тело урока */} + {/* Тело урока с переключателем JS/Lua */}
-
{lesson.body}
+ + + +
{lesson.body}
+
); }; +// При выбранном Lua показывает плашку с готовыми Lua-скриптами для урока +// (если они есть в LUA_OVERRIDES). Скрипты ниже в основном теле остаются +// на JS как референс — Lua-версия здесь сверху для копирования. +const LuaLessonBanner = ({ gameId }) => { + const { lang } = useDocsLang(); + if (lang !== 'lua') return null; + const overrides = LUA_OVERRIDES[gameId]; + if (!overrides) { + return ( +
+ Lua-версия в работе. +

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

+
+ ); + } + const entries = Object.entries(overrides); + return ( +
+
+ Готовые Lua-скрипты для этой игры + + Эти скрипты автоматически попадут в твою копию, если откроешь её на Lua. + +
+ {entries.map(([id, codeOrFn]) => { + const code = typeof codeOrFn === 'function' ? codeOrFn({ id }) : codeOrFn; + return ( +
+ {id} +
{code}
+
+ ); + })} +
+ ); +}; + +// ══════════════════════════════════════════════════════════════════ +// Модалка выбора языка скриптов при «Открыть копию» +// ══════════════════════════════════════════════════════════════════ +const LangChoiceModal = ({ onPick, onCancel }) => { + return ( +
+
e.stopPropagation()}> +

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

+

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

+
+ + +
+ +
+
+ ); +}; + +/** + * Конвертирует все JS-скрипты в project_data в Lua-эквивалент. + * Сейчас простая стратегия: если в скрипте есть code_lua слот, делает его + * активным. Иначе ставит флаг language='lua' и пустой Lua-шаблон с TODO. + * Полноценная транспиляция JS→Lua невозможна без AST-анализа. + */ +function convertProjectScriptsToLua(projectData) { + const scene = projectData?.scene; + if (!scene || !Array.isArray(scene.scripts)) return projectData; + scene.scripts = scene.scripts.map(s => { + if (s.language === 'lua') return s; + // Если уже есть готовый Lua-слот — используем его + if (s.code_lua && s.code_lua.trim()) { + return { + ...s, + language: 'lua', + code: s.code_lua, + code_js: s.code_js || s.code, + code_lua: s.code_lua, + }; + } + // Иначе ставим заглушку с подсказкой + const luaStub = `-- TODO: версия этого скрипта на Lua пока не готова. +-- Оригинальный JS-код сохранён ниже (переключи язык назад на JS в редакторе). +-- Доступные API: game:GetService("Players"), game.Workspace, script.Parent +-- +-- Например, простой пример: +local Players = game:GetService("Players") +print("Привет от Lua-скрипта") +`; + return { + ...s, + language: 'lua', + code: luaStub, + code_js: s.code_js || s.code, + code_lua: luaStub, + }; + }); + return projectData; +} + // ══════════════════════════════════════════════════════════════════ // Инлайн-стили // ══════════════════════════════════════════════════════════════════ @@ -732,13 +869,14 @@ const INLINE_STYLES = ` .docsSectionBody b { color: #0f172a; font-weight: 800; } .docsSectionBody h4 { font-family: inherit; } .docsSectionBody code { - background: #e0e8ff; - color: #3357ff; + background: #fff5e0; + color: #b14400; padding: 2px 7px; border-radius: 6px; font-family: Consolas, Menlo, "Courier New", monospace; font-size: 13px; font-weight: 700; + border: 1px solid #f5d8a8; } /* kbd */ @@ -770,6 +908,7 @@ const INLINE_STYLES = ` .docCode code { background: none; color: inherit; padding: 0; font-weight: 500; font-size: 13px; white-space: pre; + border: none; } /* Скриншот интерфейса с подписью. diff --git a/src/community/KubikonStudio.jsx b/src/community/KubikonStudio.jsx index 65e4d90..993b36f 100644 --- a/src/community/KubikonStudio.jsx +++ b/src/community/KubikonStudio.jsx @@ -390,18 +390,16 @@ const KubikonStudio = () => { ВИКИ - {/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */} - {getCurrentUserId() === 1 && ( - - )} + {/* Импорт Roblox .rbxl — доступно всем */} + ( -
{children}
-); +// ── Код-блок с подсветкой синтаксиса ────────────────────────────── +// 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
{text}
; + } + const resolved = lang || ( + /\blocal\b|\bthen\b|\bend\b|\b:Connect\b|\bfunction\(|--\s/.test(text) ? 'lua' : 'js' + ); + return ( +
+            
+        
+ ); +}; // ── Плашка «куда писать скрипт» ─────────────────────────────────── // 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(() => { <>

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

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

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

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

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

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

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

+

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

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

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

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

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

+

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

- Две одинарные кавычки '1234' означают, - что это текст, а не число. Игрок печатает в поле - всегда текст, поэтому и сравнивать нужно с текстом. + Кавычки "1234" означают, что это + текст, а не число. Игрок печатает в поле всегда + текст, поэтому и сравнивать нужно с текстом. ), @@ -1122,6 +1411,119 @@ game.gui.onSubmit(boxId, (text) => { title: 'Скрипты — основы', summary: 'Самый важный раздел: что такое скрипт, переменные, события, таймеры.', sections: [ + { + id: 'js-or-lua', + title: 'D0. Скриптинг: JS или Lua — что выбрать?', + body: ( + <> +

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

+

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

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

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

+

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

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

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

+ +

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

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

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

+

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

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

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

+

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

+ + ), + }, { id: 'what-is-script', title: 'D1. Что такое скрипт и как его создать', @@ -1134,9 +1536,11 @@ game.gui.onSubmit(boxId, (text) => { нужен скрипт.

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

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

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

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

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

Скрипт на объекте относится к конкретному кубу, модели или кнопке. Внутри такого скрипта работает волшебное слово - 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"). + } + /> ), }, @@ -1218,29 +1657,61 @@ game.gui.onSubmit(boxId, (text) => { скрипт хранит значение. Например, количество очков, имя игрока, выбранный уровень.

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

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

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

} + lua={

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

} + />

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

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

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

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

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

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

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

+

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

+ } + />

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

-

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

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

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

-

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

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

} + lua={

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

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

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

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

} + lua={

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

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

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

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

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

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

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

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

} + lua={

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

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

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

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

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

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

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

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

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

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

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

    } + lua={

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

    } + /> ), }, @@ -1458,23 +2093,40 @@ game.after(10, () => { title: 'E1. Управление игроком: скорость, прыжок, гравитация', body: ( <> -

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

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

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

    + +

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

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

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

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

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

    - {`game.self.onTouch(() => { + {`game.self.onTouch(() => { // ускоряем игрока в 2 раза game.player.setSpeed(2); game.ui.showText('Скорость x2 на 5 секунд!', 2); @@ -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). Иначе игрок останется быстрым - навсегда — а это может сломать твой уровень. + Не забывай возвращать скорость обратно. Иначе игрок + останется быстрым навсегда — а это может сломать твой уровень. ), @@ -1501,21 +2171,47 @@ game.after(10, () => { title: 'E2. Анимации-эмоции персонажа', body: ( <> -

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

    +

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

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

    } + lua={

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

    } + /> - {`// При победе персонаж радуется + {`// При победе персонаж радуется game.player.playAnimation('cheer'); // Через 3 секунды перестать game.after(3, () => { game.player.stopAnimation(); -});`} +});`}} + lua={{`local Players = game:GetService("Players") +local player = Players.LocalPlayer +local humanoid = player.Character:WaitForChild("Humanoid") +local animator = humanoid:FindFirstChildOfClass("Animator") + +-- Создаём анимацию (упрощённый шаблон — в реальности нужен AnimationId) +local anim = Instance.new("Animation") +-- anim.AnimationId = "rbxassetid://..." -- свой Animation ID +local track = animator:LoadAnimation(anim) + +track:Play() + +-- Через 3 секунды перестать +task.delay(3, function() + track:Stop() +end)`}} + /> ), }, @@ -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-й арг — секундысколько длится
    EasingStyleLinear / Quad / Bounce / Elastic
    EasingDirectionIn / Out / InOut
    repeatCountсколько раз повторить (-1 = бесконечно)
    reversestrue = туда-обратно
    tween.Completed:Connectсобытие «закончен»
    + {`-- Платформа вечно ездит вверх-вниз +local plat = workspace:WaitForChild("Качалка") +local info = TweenInfo.new( + 2, -- секунды + Enum.EasingStyle.Quad, -- плавно + Enum.EasingDirection.InOut, + -1, -- бесконечно + true -- туда-обратно +) +local goal = { Position = plat.Position + Vector3.new(0, 8, 0) } +TweenService:Create(plat, info, goal):Play()`} + } + /> ), }, @@ -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.hpтекущее здоровье + game.player.damage(n)нанести урон + game.player.heal(n)вылечить + game.player.kill()мгновенно убить + game.player.respawn()воскресить на спавне + game.player.setSpawn(точка)новая точка возрождения + + } + lua={ + + + + + + + + +
    humanoid.Healthтекущее здоровье
    humanoid:TakeDamage(n)нанести урон
    humanoid.Health = humanoid.Health + nвылечить
    humanoid.Health = 0мгновенно убить
    player:LoadCharacter()воскресить
    player.RespawnLocation = spawnновая точка возрождения
    } + />

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

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

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

    - {`game.self.onTouch(() => { + {`game.self.onTouch(() => { game.player.heal(50); // добавить 50 здоровья game.ui.showText('+50 HP', 1.5); game.self.delete(); // аптечка исчезает -});`} +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local humanoid = hit.Parent:FindFirstChild("Humanoid") + if not humanoid then return end + + -- добавить 50 здоровья, но не выше MaxHealth + humanoid.Health = math.min(humanoid.Health + 50, humanoid.MaxHealth) + print("+50 HP") + part:Destroy() -- аптечка исчезает +end)`}} + /> ), }, @@ -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={<> +

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

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

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

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

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

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

    } + lua={

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

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

    @@ -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('звезда')все объекты с тегом + + } + lua={ + + + + + + +
    CollectionService:AddTag(part, "звезда")повесить тег
    CollectionService:RemoveTag(part, "звезда")снять тег
    CollectionService:HasTag(part, "звезда")есть ли тег
    CollectionService:GetTagged("звезда")все объекты с тегом
    } + />

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

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

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

    - {`game.self.onInteract(() => { + {`game.self.onInteract(() => { game.ui.showText('Сундук открыт!', 2); game.scene.spawnParticles('sparks', game.self.position, { duration: 1 }); @@ -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 запустит функцию.

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

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

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

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

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

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

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

    - {`const swing = game.scene.findOne('Качели'); + {`const swing = game.scene.findOne('Качели'); -// делаем качели на петле const h = game.constraints.hinge(swing, { - pivotX: 0, pivotZ: 0, // ось вращения - angle: 30 // наклон на 30 градусов + pivotX: 0, pivotZ: 0, + angle: 30 }); // раскачиваем в другую сторону каждую секунду let dir = -30; game.every(1, () => { h.setAngle(dir); - dir = -dir; // меняем знак: 30 → -30 → 30 ... -});`} + dir = -dir; +});`}} + lua={{`-- В Roblox HingeConstraint — стандартный способ +local swing = workspace:WaitForChild("Качели") +local mount = workspace:WaitForChild("Опора") -- неподвижная точка + +-- Attachment'ы (точки крепления) +local a0 = Instance.new("Attachment", mount) +local a1 = Instance.new("Attachment", swing) + +local hinge = Instance.new("HingeConstraint") +hinge.Attachment0 = a0 +hinge.Attachment1 = a1 +hinge.ActuatorType = Enum.ActuatorType.Servo +hinge.ServoMaxTorque = 10000 +hinge.AngularSpeed = 2 +hinge.Parent = swing + +-- Раскачиваем +local dir = 30 +while true do + hinge.TargetAngle = dir + task.wait(1) + dir = -dir +end`}} + /> ), }, @@ -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`} -

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

    - {`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 + AnimNPC (вручную)
    Instance.new("ParticleEmitter", part)частицы
    } + /> -

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

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

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

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

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

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

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

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

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

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

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

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

    physics, fx, constraints

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

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

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

    camera, sound

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

    Камера и звук

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

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

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

    Утилиты

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

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

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

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

    + + + game.scene.setSkybox/fadeToпресеты неба + game.scene.setFog/setCloudsтуман и облака + game.environment.setTimeOfDay(0..24)время суток + game.items.define(список)описать предметы + game.inventory.give/remove/has/listинвентарь + game.modal.dialog/confirmation/lootboxмодальные окна + game.mainMenu.show/hideглавное меню + game.loading.show/onHideэкран загрузки + + } + lua={ + + + + + + + + + + +
    Lighting + Sky / Atmosphereпресеты неба (вручную)
    Lighting.FogColor / FogEnd / Atmosphereтуман и облака
    Lighting:SetMinutesAfterMidnight(N)время суток
    Свои Tool'ы в ServerStorageпредметы (вручную)
    player.Backpack:GetChildren() / Tool.Parent = Backpackинвентарь
    ScreenGui + Frame + Buttonмодалки (вручную, см. G11)
    ScreenGui + Frameглавное меню (вручную)
    ReplicatedFirst + loading screenэкран загрузки
    } + /> ), }, @@ -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. - + game.ui.score = score; + if (score >= 10) game.ui.showText('Собрал все!', 3); +});`}} + lua={{`-- В Lua счёт уже в leaderstats игрока (см. код на монетке выше). +-- Проверим достижение цели в глобальном скрипте: +local Players = game:GetService("Players") + +Players.PlayerAdded:Connect(function(player) + -- Создаём leaderstats папку при заходе + local stats = Instance.new("Folder", player) + stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats) + coins.Name = "Монеты" + coins.Value = 0 + + coins.Changed:Connect(function(newVal) + if newVal >= 10 then + print("Собрал все!") + end + end) +end)`}} + /> ), }, @@ -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)`}} + /> +

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

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

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

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

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

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

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

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

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

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

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

    +

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

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

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

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

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

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

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

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

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

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

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

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

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

    + {`let on = false; game.every(0.5, () => { on = !on; game.self.setColor(on ? '#ff0000' : '#330000'); -});`} +});`}} + lua={{`local part = script.Parent +local on = false + +while true do + task.wait(0.5) + on = not on + part.Color = on and Color3.fromRGB(255, 0, 0) + or Color3.fromRGB(51, 0, 0) +end`}} + /> ), }, @@ -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 одну секунду. ), }, { id: 'recipes-gui-timer', - title: 'S9. Надписи на экране, таймер, кнопки GUI', + title: 'S9. HUD надписи, таймер, кнопки', body: ( <> -

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

    +

    HUD-надписи:

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

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

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

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

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

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

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

    Кнопка GUI:

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

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

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

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

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

    Финиш:

    - {`game.self.onTouch(() => { - game.ui.showText('🏁 ПОБЕДА!', 4); + {`game.self.onTouch(() => { + game.ui.showText('ПОБЕДА!', 4); game.sound.play('win'); - game.player.setInputBlocked(true); // заморозить управление -});`} + game.player.setInputBlocked(true); +});`}} + lua={{`local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent:FindFirstChild("Humanoid") + if not h then return end + print("ПОБЕДА!") + h.WalkSpeed = 0 -- заморозить + h.JumpPower = 0 +end)`}} + /> ), }, @@ -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). - +});`}} + lua={{`-- Враг должен быть Model с Humanoid и HumanoidRootPart в workspace. +-- Например workspace.Зомби. +local Players = game:GetService("Players") +local enemy = workspace:WaitForChild("Зомби") +local humanoid = enemy:WaitForChild("Humanoid") +local hrp = enemy:WaitForChild("HumanoidRootPart") + +-- Преследование игрока +task.spawn(function() + while enemy.Parent and humanoid.Health > 0 do + local player = Players:GetPlayers()[1] + if player and player.Character then + local target = player.Character:FindFirstChild("HumanoidRootPart") + if target then + humanoid:MoveTo(target.Position) + end + end + task.wait(0.5) + end +end) + +humanoid.Died:Connect(function() + print("Враг повержен!") +end) + +-- Урон когда близко +task.spawn(function() + while enemy.Parent and humanoid.Health > 0 do + task.wait(0.5) + local player = Players:GetPlayers()[1] + if player and player.Character then + local target = player.Character:FindFirstChild("HumanoidRootPart") + local playerHum = player.Character:FindFirstChild("Humanoid") + if target and playerHum then + local dist = (target.Position - hrp.Position).Magnitude + if dist < 2 then + playerHum:TakeDamage(10) + end + end + end + end +end)`}} + /> ), }, @@ -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`}} + /> +

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

    + {`game.save.merge('progress', { + patch: { level: 3 }, + increment: { coins: 10 }, + max: { bestScore: 5000 } }); -// прочитать при старте game.save.get('progress', (data) => { if (data) { game.ui.showText('С возвращением! Уровень ' + data.level, 3); } -});`} +});`}} + lua={{`-- В Roblox сохранение через DataStoreService (требует онлайн-игру) +local DataStoreService = game:GetService("DataStoreService") +local progress = DataStoreService:GetDataStore("Progress") +local Players = game:GetService("Players") + +Players.PlayerAdded:Connect(function(player) + -- Прочитать при входе + local success, data = pcall(function() + return progress:GetAsync(player.UserId) + end) + if success and data then + print("С возвращением! Уровень " .. (data.level or 1)) + -- Применить прогресс: leaderstats.Монеты.Value = data.coins и т.п. + end +end) + +Players.PlayerRemoving:Connect(function(player) + -- Сохранить при выходе + local data = { + level = 3, + coins = 10, + bestScore = 5000, + } + pcall(function() + progress:SetAsync(player.UserId, data) + end) +end)`}} + /> - Собери всё вместе: монетки шлют broadcast → глобальный скрипт - считает в leaderstats → раз в N монет сохраняет через - game.save. Получится игра с прогрессом как в настоящем Roblox. + Собери всё вместе: монетки добавляются в leaderstats → + глобальный скрипт раз в N монет вызывает save.merge + или DataStore:SetAsync. Получится игра с прогрессом. ), @@ -3128,6 +5073,350 @@ game.save.get('progress', (data) => { ], }, + // ════════════════════════════════════════════════════ + // РАЗДЕЛ — ИМПОРТ ИЗ ROBLOX (.rbxl) + // ════════════════════════════════════════════════════ + { + id: 'rbxl-import', + icon: 'package', + title: 'Импорт из Roblox', + summary: 'Загрузи .rbxl-файл готовой Roblox-карты в Рублокс: геометрия, цвета и материалы переносятся хорошо, скрипты лучше отключить при первом импорте и включать постепенно.', + sections: [ + { + id: 'rbxl-overview', + title: 'I1. Что это и зачем', + body: ( + <> +

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

    +

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

    +

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

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

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

    +

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

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

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

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

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

    +

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

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

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

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

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

    + +

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

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

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

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

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

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

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

    + +

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

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

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

    +

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

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

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

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

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

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

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

    +

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

    + + ), + }, + ], + }, + // ════════════════════════════════════════════════════ // РАЗДЕЛ — СОВМЕСТНОЕ РЕДАКТИРОВАНИЕ (Team Create) // ════════════════════════════════════════════════════ @@ -3267,8 +5556,10 @@ game.save.get('progress', (data) => { Открой статью «AI2. Контекст — скопируй в нейросеть» ниже.
    - Выдели и скопируй весь текст из серого блока (он - описывает все команды Рублокса). + Сверху статьи переключи язык (JS или Lua) — зависит от + того, на каком пишешь скрипты в своей игре. + Выдели и скопируй весь текст из серого блока (он + описывает все команды Рублокса для выбранного языка). Вставь его в нейросеть первым сообщением. Затем добавь @@ -3293,10 +5584,14 @@ game.save.get('progress', (data) => { body: ( <>

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

    - {AI_CONTEXT} + {AI_CONTEXT}} + lua={{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 в статьях вики. + * + * Компоненты: + * — оборачивает страницу статьи, хранит выбранный язык + * в localStorage 'rublox.docs.lang' ('js' | 'lua'). + * — большой переключатель JS/Lua над статьёй. + * — вкладка-переключатель внутри статьи. Показывает + * либо js, либо lua, согласно текущему языку. + * useDocsLang() — хук: возвращает {lang, setLang}. + * + * Если в статье нет ни одного — она одинаково выглядит на обоих + * языках (общая теория, не зависящая от языка скриптов). + */ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +// ══════════════════════════════════════════════════════════════════ +// Простая подсветка синтаксиса для JS и Lua +// ══════════════════════════════════════════════════════════════════ + +const JS_KEYWORDS = new Set([ + 'let', 'const', 'var', 'function', 'return', 'if', 'else', 'for', 'while', + 'do', 'switch', 'case', 'break', 'continue', 'new', 'this', 'class', + 'extends', 'super', 'true', 'false', 'null', 'undefined', 'try', 'catch', + 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of', 'async', 'await', + 'import', 'export', 'from', 'default', 'delete', 'void', +]); +const JS_BUILTINS = new Set([ + 'game', 'Math', 'Object', 'Array', 'String', 'Number', 'Boolean', 'JSON', + 'console', 'setTimeout', 'setInterval', 'Promise', 'document', 'window', +]); + +const LUA_KEYWORDS = new Set([ + 'local', 'function', 'end', 'if', 'then', 'else', 'elseif', 'for', 'while', + 'do', 'repeat', 'until', 'return', 'break', 'and', 'or', 'not', 'true', + 'false', 'nil', 'in', 'goto', +]); +const LUA_BUILTINS = new Set([ + 'game', 'workspace', 'script', 'Instance', 'Vector3', 'Vector2', 'Color3', + 'CFrame', 'UDim2', 'UDim', 'BrickColor', 'Enum', 'math', 'string', 'table', + 'task', 'print', 'warn', 'pairs', 'ipairs', 'pcall', 'tostring', 'tonumber', + 'TweenInfo', 'wait', 'tick', 'type', 'require', 'next', 'setmetatable', + 'getmetatable', 'rawget', 'rawset', +]); + +function escapeHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +/** Возвращает HTML-строку с раскрашенным кодом. lang: 'js' | 'lua'. */ +export function highlightCode(text, lang) { + if (typeof text !== 'string') return escapeHtml(String(text || '')); + const isLua = lang === 'lua'; + const keywords = isLua ? LUA_KEYWORDS : JS_KEYWORDS; + const builtins = isLua ? LUA_BUILTINS : JS_BUILTINS; + // Регулярки для токенов — порядок важен: сначала комменты и строки, + // потом числа, потом identifier'ы. + // JS: //... и /*...*/. Lua: --... и --[[...]]. + const commentRe = isLua + ? /--\[\[[\s\S]*?\]\]|--[^\n]*/g + : /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g; + // Строки: одинарные, двойные, в JS ещё бэктики. + const stringRe = isLua + ? /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\[\[[\s\S]*?\]\]/g + : /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`/g; + const numRe = /\b\d+(?:\.\d+)?\b/g; + const idRe = /[A-Za-zА-Яа-я_$][A-Za-zА-Яа-я0-9_$]*/g; + + // Берём весь текст, делим на токены через одну общую регулярку. + const tokens = []; + const combined = new RegExp( + commentRe.source + '|' + stringRe.source + '|' + numRe.source + '|' + idRe.source, + 'g' + ); + let lastIndex = 0; + let match; + while ((match = combined.exec(text)) !== null) { + const start = match.index; + const tok = match[0]; + if (start > lastIndex) { + tokens.push({ type: 'raw', text: text.slice(lastIndex, start) }); + } + // Классифицируем + if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) { + tokens.push({ type: 'comment', text: tok }); + } else if (/^["'`\[]/.test(tok)) { + tokens.push({ type: 'string', text: tok }); + } else if (/^\d/.test(tok)) { + tokens.push({ type: 'number', text: tok }); + } else if (keywords.has(tok)) { + tokens.push({ type: 'keyword', text: tok }); + } else if (builtins.has(tok)) { + tokens.push({ type: 'builtin', text: tok }); + } else { + // Идентификатор — проверим, идёт ли за ним ( → функция + const rest = text.slice(start + tok.length); + if (/^\s*\(/.test(rest)) { + tokens.push({ type: 'fn', text: tok }); + } else { + tokens.push({ type: 'ident', text: tok }); + } + } + lastIndex = start + tok.length; + } + if (lastIndex < text.length) { + tokens.push({ type: 'raw', text: text.slice(lastIndex) }); + } + return tokens.map(t => { + const safe = escapeHtml(t.text); + if (t.type === 'raw' || t.type === 'ident') return safe; + return `${safe}`; + }).join(''); +} + + +// v2 — раньше при первом включении lua-режима сохранялся в LS и юзер +// потом всегда видел Lua-таб по умолчанию. Бамп ключа = сброс на JS +// у всех уже-открытых вкладок. +const LS_KEY = 'rublox.docs.lang.v2'; +const LS_KEY_OLD = 'rublox.docs.lang'; +const DEFAULT_LANG = 'js'; + +const DocsLangContext = createContext({ + lang: DEFAULT_LANG, + setLang: () => {}, +}); + +export function DocsLangProvider({ children }) { + const [lang, setLangState] = useState(() => { + try { + // Очищаем старый ключ — у части юзеров там залип 'lua' + localStorage.removeItem(LS_KEY_OLD); + const v = localStorage.getItem(LS_KEY); + return v === 'lua' ? 'lua' : 'js'; + } catch (_) { + return DEFAULT_LANG; + } + }); + const setLang = (next) => { + const v = next === 'lua' ? 'lua' : 'js'; + setLangState(v); + try { localStorage.setItem(LS_KEY, v); } catch (_) {} + }; + useEffect(() => { + // Слушаем смену из других вкладок + const onStorage = (e) => { + if (e.key === LS_KEY && (e.newValue === 'js' || e.newValue === 'lua')) { + setLangState(e.newValue); + } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + return ( + + {children} + + ); +} + +export function useDocsLang() { + return useContext(DocsLangContext); +} + +/** Большой переключатель над статьёй: «На каком языке смотреть код?» */ +export function DocsLangPicker() { + const { lang, setLang } = useDocsLang(); + return ( +
    +
    + Язык скриптов в этой статье: +
    +
    + + +
    +
    + Не знаешь что выбрать? Смотри статью D0. Скриптинг: JS или Lua? +
    +
    + ); +} + +/** + * Локальный переключатель вкладок внутри статьи. Если js/lua — + * прямой контент (children), если на странице нет — + * показываем оба заголовками. + * + * Использование: + * game.log('Привет')} + * lua={print('Привет')} + * /> + */ +export function LangTabs({ js, lua }) { + const { lang, setLang } = useDocsLang(); + const hasJs = js !== undefined && js !== null; + const hasLua = lua !== undefined && lua !== null; + if (!hasJs && !hasLua) return null; + // Если есть только один язык — показываем без переключателя + if (hasJs && !hasLua) return <>{js}; + if (!hasJs && hasLua) return <>{lua}; + return ( +
    +
    + + +
    +
    + {lang === 'lua' ? lua : js} +
    +
    + ); +} + +export const DOCS_LANG_STYLES = ` +.docsLangPicker { + background: linear-gradient(135deg, #1a1d2e 0%, #14172b 100%); + border: 1px solid #2a3050; + border-radius: 10px; + padding: 14px 18px; + margin: 16px 0 24px; + display: flex; + flex-direction: column; + gap: 10px; +} +.docsLangPicker__label { + font-size: 13px; + font-weight: 600; + color: #c8cce0; +} +.docsLangPicker__tabs { + display: flex; + gap: 8px; +} +.docsLangPicker__tab { + flex: 1; + padding: 10px 16px; + border-radius: 6px; + border: 1px solid transparent; + background: #232842; + color: #aab0c8; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; +} +.docsLangPicker__tab:hover { background: #2a304f; color: #fff; } +.docsLangPicker__tab--js.is-active { + background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%); + color: #1a1a1c; + border-color: #d4b500; +} +.docsLangPicker__tab--lua.is-active { + background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%); + color: #fff; + border-color: #1565c0; +} +.docsLangPicker__hint { + font-size: 12px; + color: #8a90a8; + font-style: italic; +} + +.docsLangTabs { + margin: 12px 0; + border-radius: 10px; + overflow: hidden; + border: 1px solid #e0e6f0; + background: #fff; +} +.docsLangTabs__head { + display: flex; + background: #f4f6fb; + border-bottom: 1px solid #e0e6f0; +} +.docsLangTabs__tab { + padding: 9px 18px; + border: none; + background: transparent; + color: #64748b; + font-size: 12px; + font-weight: 700; + cursor: pointer; + letter-spacing: 0.5px; + border-bottom: 2px solid transparent; +} +.docsLangTabs__tab:hover { color: #1e293b; } +.docsLangTabs__tab.is-active { + color: #1e3a8a; + border-bottom-color: #3357ff; + background: #fff; +} +.docsLangTabs__body { + padding: 0; + background: #fff; +} +.docsLangTabs__body > pre, +.docsLangTabs__body > .docCode { margin: 0; border-radius: 0; } + +/* Заголовки колонок таблицы (th) — в основных стилях вики не определены. + Делаем светлыми чтобы не сливались с фоном таблицы. */ +.docTable th { + padding: 9px 14px; + background: #eef2ff; + color: #1e3a8a; + font-size: 13px; + font-weight: 700; + text-align: left; + border-bottom: 1px solid #d4dcef; + border-right: 1px solid #eef2f7; +} +.docTable th:last-child { border-right: none; } +.docTable thead tr:first-child th:first-child { border-top-left-radius: 12px; } +.docTable thead tr:first-child th:last-child { border-top-right-radius: 12px; } + +.langChoiceOverlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.75); + display: flex; align-items: center; justify-content: center; + z-index: 10000; +} +.langChoiceDialog { + background: #1a1d2e; + border: 1px solid #2a3050; + border-radius: 14px; + padding: 28px; + width: 100%; + max-width: 520px; + box-shadow: 0 20px 60px rgba(0,0,0,0.6); +} +.langChoiceTitle { + font-size: 20px; + margin: 0 0 8px; + color: #fff; +} +.langChoiceSub { + margin: 0 0 20px; + font-size: 13px; + color: #aab0c8; + line-height: 1.5; +} +.langChoiceBtns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 14px; +} +.langChoiceBtn { + padding: 18px 16px; + border-radius: 10px; + border: 2px solid transparent; + text-align: left; + cursor: pointer; + transition: all 0.15s; + color: #fff; +} +.langChoiceBtn--js { + background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%); + color: #1a1a1c; +} +.langChoiceBtn--js:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(247,223,30,0.3); } +.langChoiceBtn--lua { + background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%); +} +.langChoiceBtn--lua:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(33,150,243,0.4); } +.langChoiceBtn__name { + font-size: 18px; + font-weight: 800; + margin-bottom: 4px; +} +.langChoiceBtn__hint { + font-size: 12px; + font-weight: 400; + opacity: 0.85; +} +.langChoiceCancel { + width: 100%; + padding: 10px; + background: transparent; + border: 1px solid #2a3050; + color: #aab0c8; + border-radius: 8px; + font-size: 13px; + cursor: pointer; +} +.langChoiceCancel:hover { background: #232842; color: #fff; } + +/* ══════════════════════════════════════════════════════════════════ + Подсветка синтаксиса в код-блоках + ══════════════════════════════════════════════════════════════════ */ +.docCode .hl-keyword { color: #ff79c6; font-weight: 600; } /* let/const/local/function */ +.docCode .hl-builtin { color: #8be9fd; } /* game / workspace / Math */ +.docCode .hl-string { color: #f1fa8c; } /* 'строки' "строки" */ +.docCode .hl-number { color: #bd93f9; } /* 42, 3.14 */ +.docCode .hl-comment { color: #6272a4; font-style: italic; } /* // или -- */ +.docCode .hl-fn { color: #50fa7b; } /* myFunc() */ + +/* ══════════════════════════════════════════════════════════════════ + Баннер «Lua-скрипты для урока» + ══════════════════════════════════════════════════════════════════ */ +.luaLessonBanner { + background: #eef4ff; + border: 1px solid #c7d8f5; + border-radius: 10px; + padding: 14px 18px; + margin: 14px 0 22px; +} +.luaLessonBanner--missing { + background: #fff7e0; + border-color: #f0d599; + color: #5a4500; +} +.luaLessonBanner--missing p { margin: 4px 0 0; font-size: 13px; } +.luaLessonBanner__head { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } +.luaLessonBanner__head b { font-size: 14px; color: #1e3a8a; } +.luaLessonBanner__hint { font-size: 12px; color: #475569; font-style: italic; } +.luaLessonBanner__script { margin: 6px 0; } +.luaLessonBanner__script summary { + cursor: pointer; + padding: 8px 12px; + background: #fff; + border-radius: 6px; + border: 1px solid #d0dcf0; + font-family: Consolas, monospace; + font-size: 13px; + color: #1e3a8a; + font-weight: 600; +} +.luaLessonBanner__script summary:hover { background: #f4f8ff; } +.luaLessonBanner__script[open] summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } +.luaLessonBanner__script pre { margin: 0; border-top-left-radius: 0; border-top-right-radius: 0; } +`; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 66d563f..680450a 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -1,5 +1,35 @@ import React from 'react'; import { Code, ScriptKind, Step, Note, Try, Shot } from './docsData'; +import { LangTabs, useDocsLang } from './docsLang'; +import { LUA_OVERRIDES } from './docsGamesBuildersLua'; + +/** + * Хелпер: оборачивает JS-код в LangTabs с Lua-параллелью из LUA_OVERRIDES. + * {`// JS код...`} + * → если открыт JS — показывает JS код + * → если открыт Lua — показывает Lua-код из LUA_OVERRIDES[game][script] + */ +function CodeBoth({ game, script, children }) { + const luaCode = LUA_OVERRIDES[game]?.[script]; + const luaResolved = typeof luaCode === 'function' ? luaCode({ id: script }) : luaCode; + return ( + {children}} + lua={luaResolved ? {luaResolved} : null} + /> + ); +} + +/** + * Инлайн-API-имена в тексте уроков, меняющиеся в зависимости от JS/Lua вкладки. + * + * Если lua не задан — показывает js в обоих режимах. + */ +function Api({ js, lua }) { + const { lang } = useDocsLang(); + const txt = lang === 'lua' && lua ? lua : js; + return {txt}; +} /** * docsLessons.jsx — тексты уроков для 50 мини-игр (раздел K вики). @@ -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', () => {

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

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

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

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

      Следит за падением и победой — как в уроке 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 }); -});`} +});`}

      Шаг 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(() => {

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

        - {`// === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт === + {`// === ИГРА «КНОПКА-ОТКРЫВАШКА» — главный скрипт === 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 }); -});`} +});`}

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

        @@ -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(() => {

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

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

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

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

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

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

            - {`// === ИГРА «ЛАБИРИНТ» — главный скрипт === + {`// === ИГРА «ЛАБИРИНТ» — главный скрипт === 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'); // сообщаем главному скрипту о победе -});`} +});`}

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

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

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

              - {`// === ИГРА «ЦВЕТНЫЕ ПЛИТКИ» — главный скрипт === + {`// === ИГРА «ЦВЕТНЫЕ ПЛИТКИ» — главный скрипт === 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', () => {

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

              - {`// === Скрипт цветной плитки === + {`// === Скрипт цветной плитки === 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(() => {

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

                - {`// === ИГРА «ПОЙМАЙ ПАДАЮЩЕЕ» — главный скрипт === + {`// === ИГРА «ПОЙМАЙ ПАДАЮЩЕЕ» — главный скрипт === 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) => {

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

                  - {`// === ИГРА «БЕГИ К ФИНИШУ» — главный скрипт === + {`// === ИГРА «БЕГИ К ФИНИШУ» — главный скрипт === 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', () => {

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

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

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

                      @@ -1114,7 +1144,7 @@ game.self.onTouch(() => {

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

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

                      - {`// === ИГРА «СВЕТОФОР» — главный скрипт === + {`// === ИГРА «СВЕТОФОР» — главный скрипт === 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', () => {

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

                        - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                        • светофор мигает зелёный/красный;
                        • на зелёный беги, на красный замри;
                        • @@ -1268,7 +1298,7 @@ game.self.onTouch(() => {

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

                          - {`// === ИГРА «ПРЫЖОК-ПРУЖИНА» — главный скрипт === + {`// === ИГРА «ПРЫЖОК-ПРУЖИНА» — главный скрипт === 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 }); -});`} +});`}

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

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

                          - {`// === Скрипт батута === + {`// === Скрипт батута === // Игрок встал на батут — мощный подброс вверх. 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(() => {

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

                          - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                          • встал на батут — подлетел вверх;
                          • попал на этаж — иди к следующему батуту;
                          • @@ -1386,7 +1416,7 @@ game.self.onTouch(() => {

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

                            - {`// === ИГРА «ЭХО-КОМНАТА» — главный скрипт === + {`// === ИГРА «ЭХО-КОМНАТА» — главный скрипт === 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 }); -});`} +});`}

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

                            @@ -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(() => {

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

                              - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('finish'); // сообщаем главному скрипту о финише -});`} +});`}
                              • наступаешь на плитку — звук и искры;
                              • прошёл все 6 — появится подсказка идти на финиш;
                              • @@ -1526,7 +1556,7 @@ game.self.onTouch(() => { Здесь самое интересное — проверка кода. Разберём подробно.

                                - {`// === ИГРА «ДВЕРЬ ПО КОДУ» — главный скрипт === + {`// === ИГРА «ДВЕРЬ ПО КОДУ» — главный скрипт === // СЕКРЕТНЫЙ КОД — порядок кнопок. Поменяй на свой! 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(() => {

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

                                  - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                                  • подходи к кнопкам, жми E;
                                  • нажал по коду 3-1-4-2 — дверь открылась;
                                  • @@ -1691,7 +1721,7 @@ game.self.onTouch(() => {

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

                                    - {`// === ИГРА «ТОРГОВЕЦ» — главный скрипт === + {`// === ИГРА «ТОРГОВЕЦ» — главный скрипт === 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', () => {

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

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

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

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

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

                                      - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                                      • подойди к прилавку, нажми E — торговец заговорит и даст ключ;
                                      • @@ -1867,7 +1897,7 @@ game.self.onTouch(() => { а потом считает, сколько звёзд осталось.

                                        - {`// === ИГРА «СОБЕРИ ПО ТЕГАМ» — главный скрипт === + {`// === ИГРА «СОБЕРИ ПО ТЕГАМ» — главный скрипт === 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', () => {

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

                                          Этот скрипт повесь на каждую из 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(() => {

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

                                            - {`// === ИГРА «ТИР» — главный скрипт === + {`// === ИГРА «ТИР» — главный скрипт === 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 — счётчик попаданий;
                                            • @@ -2053,7 +2083,7 @@ game.onMessage('hit', () => { мишень-сферу.

                                              - {`// === Скрипт мишени === + {`// === Скрипт мишени === // Клик по 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(() => {

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

                                                - {`// === ИГРА «ЛАВА-ПОЛ» — главный скрипт === + {`// === ИГРА «ЛАВА-ПОЛ» — главный скрипт === 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 следит, не провалился ли игрок совсем низко — тогда возвращает его на старт;
                                                • @@ -2207,14 +2237,14 @@ game.onMessage('win', () => {

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

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

                                                  Разберём:

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

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

                                                    - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); // сообщаем главному скрипту о победе -});`} +});`}
                                                    • прыгай с островка на островок;
                                                    • попал в лаву — здоровье тает, слышен звук урона;
                                                    • @@ -2302,7 +2332,7 @@ game.self.onTouch(() => {

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

                                                      - {`// === ИГРА «КЛЮЧ И СУНДУК» — главный скрипт === + {`// === ИГРА «КЛЮЧ И СУНДУК» — главный скрипт === 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', () => {

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

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

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

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

                                                        - {`// === Скрипт сундука === + {`// === Скрипт сундука === 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'); -});`} +});`}
                                                          • качели сами качаются туда-сюда;
                                                          • запрыгни на них с возвышенности, поймай момент;
                                                          • @@ -2600,7 +2630,7 @@ game.self.onTouch(() => {

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

                                                            - {`// === ИГРА «ЛИФТ» — главный скрипт === + {`// === ИГРА «ЛИФТ» — главный скрипт === 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'); -});`} +});`}
                                                              • лифт ездит вверх-вниз сам по себе;
                                                              • встань на него внизу и дождись верха;
                                                              • @@ -2728,7 +2758,7 @@ game.self.onTouch(() => { над ними метки и обрабатывает удары.

                                                                - {`// === ИГРА «ИМЕНА НАД ВРАГАМИ» — главный скрипт === + {`// === ИГРА «ИМЕНА НАД ВРАГАМИ» — главный скрипт === 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', () => {

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

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

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

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

                                                                    - {`// === ИГРА «ЗОНА ОПАСНОСТИ» — главный скрипт === + {`// === ИГРА «ЗОНА ОПАСНОСТИ» — главный скрипт === 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', () => {

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

                                                                      - {`// === Скрипт зоны опасности === + {`// === Скрипт зоны опасности === // 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(() => {

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

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

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

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

                                                                      - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
                                                                      • подбери зелёную аптечку — +60 HP;
                                                                      • в красной зоне здоровье тает каждые 0.6 секунды;
                                                                      • @@ -3190,7 +3220,7 @@ game.self.onTouch(() => { каждое нажатие.

                                                                        - {`// === ИГРА «ПЕРЕКЛЮЧАТЕЛИ» — главный скрипт === + {`// === ИГРА «ПЕРЕКЛЮЧАТЕЛИ» — главный скрипт === // правильный порядок рычагов 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(() => {

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

                                                                          - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
                                                                          • подойди к рычагу — появится подсказка «Дёрнуть рычаг»;
                                                                          • дёргай E в верном @@ -3347,7 +3377,7 @@ game.self.onTouch(() => {

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

                                                                            - {`// === ИГРА «ПАДАЮЩИЙ МОСТ» — главный скрипт === + {`// === ИГРА «ПАДАЮЩИЙ МОСТ» — главный скрипт === 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', () => {

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

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

                                                                            - {`// === Скрипт доски моста === + {`// === Скрипт доски моста === 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(() => {

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

                                                                              - {`// === Скрипт финиша === + {`// === Скрипт финиша === game.self.onTouch(() => { game.broadcast('win'); -});`} +});`}
                                                                              • встал на доску — щелчок, через секунду она рушится;
                                                                              • стоишь на месте — проваливаешься, респаун на старте;
                                                                              • @@ -3482,7 +3512,7 @@ game.self.onTouch(() => { закончится.

                                                                                - {`// === ИГРА «КАМЕРА-ОБЛЁТ» — главный скрипт === + {`// === ИГРА «КАМЕРА-ОБЛЁТ» — главный скрипт === 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'); -});`} +});`}
                                                                                  • с началом игры камера облетает уровень;
                                                                                  • облёт закончился — управление вернулось, появилась @@ -3602,7 +3632,7 @@ game.self.onTouch(() => {

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

                                                                                    - {`// === ИГРА «МАГНИТ МОНЕТ» — главный скрипт === + {`// === ИГРА «МАГНИТ МОНЕТ» — главный скрипт === 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', () => {

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

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

                                                                                    - {`// === Скрипт магнитной монетки === + {`// === Скрипт магнитной монетки === 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(() => {

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

                                                                                      - {`// === ИГРА «ДВОЙНОЙ ПРЫЖОК» — главный скрипт === + {`// === ИГРА «ДВОЙНОЙ ПРЫЖОК» — главный скрипт === 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'); -});`} +});`}
                                                                                        • прыгай Space, в воздухе жми ещё раз — второй прыжок;
                                                                                        • @@ -3873,7 +3903,7 @@ game.self.onTouch(() => {

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

                                                                                          - {`// === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» — главный скрипт === + {`// === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» — главный скрипт === 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', () => {

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

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

                                                                                          - {`// === Скрипт призрачной стены === + {`// === Скрипт призрачной стены === 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', () => {

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

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

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

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

                                                                                            И прилавок, и дверь — это объекты с взаимодействием по E. Подошёл к прилавку и нажал @@ -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', () => {

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

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

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

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

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

                                                                                                @@ -4336,7 +4366,7 @@ game.self.onTouch(() => {

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

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

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

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

                                                                                                • game.every(2, ...) — каждые 2 секунды @@ -4490,7 +4520,7 @@ game.every(2, () => {

                                                                                                  Шаг 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(() => {

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

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

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

                                                                                                    • game.onTick следит за падением: упал @@ -4792,7 +4822,7 @@ game.onTick(() => {

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

                                                                                                      - {`// === ИГРА «СБОР УРОЖАЯ» — главный скрипт === + {`// === ИГРА «СБОР УРОЖАЯ» — главный скрипт === 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', () => {

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

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

                                                                                                      - {`// === Скрипт растения === + {`// === Скрипт растения === 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(() => {

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

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

                                                                                                        - {`// === ИГРА «ПРЯТКИ ОТ 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);
                                                                                                            • @@ -5224,7 +5254,7 @@ game.self.onInteract(() => {

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

                                                                                                              - {`// === ИГРА «ПОЛОСА ПРЕПЯТСТВИЙ» — главный скрипт === + {`// === ИГРА «ПОЛОСА ПРЕПЯТСТВИЙ» — главный скрипт === 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', () => {

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

                                                                                                                - {`// === Скрипт шипа === + {`// === Скрипт шипа === 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 ''; -} - -export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal }; diff --git a/src/editor/engine/roblox-tween.js b/src/editor/engine/roblox-tween.js deleted file mode 100644 index 4c55fd6..0000000 --- a/src/editor/engine/roblox-tween.js +++ /dev/null @@ -1,204 +0,0 @@ -/** - * roblox-tween.js — TweenService для Roblox Lua-shim. - * - * Использование в Lua: - * local TS = game:GetService("TweenService") - * local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out) - * local tween = TS:Create(part, info, {Position = Vector3.new(0, 10, 0)}) - * tween:Play() - * tween.Completed:Connect(function() print("done") end) - * - * Реализация: - * - Все активные tween'ы держатся в этом модуле. - * - На каждом tick() прогрессируется alpha = (now - startTime) / duration. - * - Применяется easing-кривая, и обновляется свойство объекта через __sendFn. - * - При alpha >= 1 — fire Completed signal и удаляем tween. - */ - -import { RbxSignal, RbxVector3, RbxColor3, RbxCFrame, RbxUDim2 } from './roblox-shim.js'; - -/* ──────── EasingStyle / Direction ──────── */ - -const EASING_FNS = { - 'Linear': (t) => t, - 'Quad': (t) => t * t, - 'Cubic': (t) => t * t * t, - 'Quart': (t) => t * t * t * t, - 'Quint': (t) => t * t * t * t * t, - 'Sine': (t) => 1 - Math.cos((t * Math.PI) / 2), - 'Bounce': (t) => { - const n1 = 7.5625, d1 = 2.75; - if (t < 1 / d1) return n1 * t * t; - if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; } - if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; } - t -= 2.625 / d1; return n1 * t * t + 0.984375; - }, - 'Elastic': (t) => { - if (t === 0) return 0; - if (t === 1) return 1; - return -(2 ** (10 * (t - 1))) * Math.sin((t - 1.1) * 5 * Math.PI); - }, - 'Back': (t) => t * t * (2.70158 * t - 1.70158), - 'Exponential': (t) => t === 0 ? 0 : 2 ** (10 * (t - 1)), -}; - -function applyDirection(t, direction) { - if (direction === 'In') return t; - if (direction === 'Out') return 1 - (1 - t); - if (direction === 'InOut') { - return t < 0.5 ? t * 2 : (1 - (1 - t) * 2); - } - return t; -} - -function easeValue(alpha, style, direction) { - const styleFn = EASING_FNS[style] || EASING_FNS.Linear; - if (direction === 'In') return styleFn(alpha); - if (direction === 'Out') return 1 - styleFn(1 - alpha); - // InOut - if (alpha < 0.5) return styleFn(alpha * 2) / 2; - return 1 - styleFn((1 - alpha) * 2) / 2; -} - -/* ──────── TweenInfo ──────── */ - -class RbxTweenInfo { - constructor(time = 1, easingStyle = 'Quad', easingDirection = 'Out', - repeatCount = 0, reverses = false, delayTime = 0) { - this.Time = +time || 0; - this.EasingStyle = typeof easingStyle === 'object' ? easingStyle.Name : easingStyle; - this.EasingDirection = typeof easingDirection === 'object' ? easingDirection.Name : easingDirection; - this.RepeatCount = repeatCount | 0; - this.Reverses = !!reverses; - this.DelayTime = +delayTime || 0; - } -} - -/* ──────── Tween ──────── */ - -class RbxTween { - constructor(instance, info, goalProps, manager) { - this.Instance = instance; - this.TweenInfo = info; - this.GoalProps = goalProps; - this._manager = manager; - this._startTime = null; - this._fromProps = null; - this._playing = false; - this._completed = false; - this.Completed = new RbxSignal('Completed'); - this.PlaybackState = 'Begin'; - } - - Play() { - if (this._playing) return; - // Снимок старых значений - this._fromProps = {}; - for (const k of Object.keys(this.GoalProps)) { - this._fromProps[k] = this.Instance[k]; // через getter Part'а - } - this._startTime = this._manager.time; - this._playing = true; - this.PlaybackState = 'Playing'; - this._manager._add(this); - } - - Pause() { this._playing = false; this.PlaybackState = 'Paused'; } - Cancel() { - this._playing = false; - this.PlaybackState = 'Cancelled'; - this._manager._remove(this); - } - - /** internal — вызывается из manager.tick */ - _step(now) { - if (!this._playing) return false; - const elapsed = now - this._startTime; - const dur = this.TweenInfo.Time || 0.001; - let alpha = Math.min(1, Math.max(0, elapsed / dur)); - const ea = easeValue(alpha, this.TweenInfo.EasingStyle, this.TweenInfo.EasingDirection); - for (const k of Object.keys(this.GoalProps)) { - const from = this._fromProps[k]; - const to = this.GoalProps[k]; - const interp = interpolate(from, to, ea); - // Set через setter в Part — он отправит partSet в main - try { this.Instance[k] = interp; } catch (e) {} - } - if (alpha >= 1) { - this._playing = false; - this._completed = true; - this.PlaybackState = 'Completed'; - this.Completed.Fire('Completed'); - return true; // удалить из активных - } - return false; - } -} - -function interpolate(from, to, a) { - if (from instanceof RbxVector3 && to instanceof RbxVector3) { - return from.Lerp(to, a); - } - if (from instanceof RbxColor3 && to instanceof RbxColor3) { - return from.Lerp(to, a); - } - if (from instanceof RbxCFrame && to instanceof RbxCFrame) { - return from.Lerp(to, a); - } - if (typeof from === 'number' && typeof to === 'number') { - return from + (to - from) * a; - } - // Иначе ничего не интерполируем - return a >= 1 ? to : from; -} - -/* ──────── Manager ──────── */ - -export class RobloxTweenManager { - constructor() { - this.active = new Set(); - this.time = 0; - } - install(lua) { - const self = this; - // TweenInfo конструктор - lua.global.set('TweenInfo', { - new: (time, style, direction, repeat_, reverses, delay_) => - new RbxTweenInfo(time, style, direction, repeat_, reverses, delay_), - }); - // Сервис: добавляем в services через game:GetService('TweenService') - // (services map передаётся в shim — но мы не имеем к нему доступа здесь; - // делаем по-другому: регистрируем сразу глобал TweenService который - // совместим с GetService('TweenService')) - const tweenService = { - ClassName: 'TweenService', - Name: 'TweenService', - Create(instance, info, goalProps) { - return new RbxTween(instance, info, goalProps, self); - }, - }; - lua.global.set('__tweenService', tweenService); - // и в game.GetService — мы делаем монки-патч если игра уже есть: - const game = lua.global.get('game'); - if (game && typeof game.GetService === 'function') { - const origGetService = game.GetService; - game.GetService = function(svc) { - if (svc === 'TweenService') return tweenService; - return origGetService.call(this, svc); - }; - } - } - - _add(tween) { this.active.add(tween); } - _remove(tween) { this.active.delete(tween); } - - tick(dtSec) { - this.time += +dtSec || 0; - for (const t of [...this.active]) { - const done = t._step(this.time); - if (done) this.active.delete(t); - } - } -} - -export { RbxTweenInfo, RbxTween }; diff --git a/src/editor/lua-monaco-setup.js b/src/editor/lua-monaco-setup.js new file mode 100644 index 0000000..ece634d --- /dev/null +++ b/src/editor/lua-monaco-setup.js @@ -0,0 +1,249 @@ +/** + * lua-monaco-setup — регистрация Lua-фич в Monaco: + * 1) Подсветка через встроенный 'lua' language (Monaco поставляется с basic-languages/lua) + * 2) Автодополнение Roblox-API (Vector3.new, Color3.fromRGB, script.Parent, game.Players, ...) + * 3) Hover-документация (наведя на Vector3 — описание + пример) + * 4) Подсветка ошибок через luaparse (на этапе 7, опционально) + * + * Регистрируется ОДИН раз глобально через флаг monaco.__rbxLuaRegistered. + */ + +const ROBLOX_LUA_API = [ + // === Глобальные функции === + { kind: 'function', name: 'print', insertText: 'print($0)', doc: 'Выводит сообщения в Output-панель.\n```lua\nprint("Привет", x, y)\n```' }, + { kind: 'function', name: 'warn', insertText: 'warn($0)', doc: 'Выводит предупреждение (жёлтым).\n```lua\nwarn("Что-то не так")\n```' }, + { kind: 'function', name: 'error', insertText: 'error(${1:"сообщение"})', doc: 'Бросает ошибку, останавливая текущий скрипт.\n```lua\nerror("Здоровье < 0")\n```' }, + { kind: 'function', name: 'wait', insertText: 'wait(${1:1})', doc: 'Приостанавливает скрипт на N секунд (заменяется на `task.wait` в новом коде).' }, + { kind: 'function', name: 'tick', insertText: 'tick()', doc: 'Возвращает количество секунд с эпохи (как `os.time()`, но дробное).' }, + { kind: 'function', name: 'pcall', insertText: 'pcall(${1:fn}, $0)', doc: 'Защищённый вызов. Возвращает `success, result|error`.\n```lua\nlocal ok, err = pcall(function() risky() end)\nif not ok then warn(err) end\n```' }, + { kind: 'function', name: 'xpcall', insertText: 'xpcall(${1:fn}, ${2:handler})', doc: 'Защищённый вызов с кастомным обработчиком ошибки.' }, + { kind: 'function', name: 'tostring', insertText: 'tostring($0)', doc: 'Преобразует значение в строку.' }, + { kind: 'function', name: 'tonumber', insertText: 'tonumber($0)', doc: 'Преобразует строку в число. Возвращает nil если не число.' }, + { kind: 'function', name: 'type', insertText: 'type($0)', doc: 'Возвращает строку с типом: "nil", "number", "string", "boolean", "table", "function", "userdata".' }, + { kind: 'function', name: 'typeof', insertText: 'typeof($0)', doc: 'Расширенная версия type — для Roblox-типов вернёт "Vector3", "CFrame", "Color3", "Instance".' }, + { kind: 'function', name: 'ipairs', insertText: 'ipairs(${1:t})', doc: 'Итератор по числовым ключам массива.\n```lua\nfor i, v in ipairs(arr) do ... end\n```' }, + { kind: 'function', name: 'pairs', insertText: 'pairs(${1:t})', doc: 'Итератор по всем ключам таблицы.\n```lua\nfor k, v in pairs(t) do ... end\n```' }, + { kind: 'function', name: 'next', insertText: 'next(${1:t}, $0)', doc: 'Возвращает следующую пару ключ-значение в таблице.' }, + { kind: 'function', name: 'select', insertText: 'select(${1:1}, $0)', doc: 'select("#", ...) — количество аргументов. select(n, ...) — n-й и далее аргументы.' }, + { kind: 'function', name: 'unpack', insertText: 'unpack(${1:t})', doc: 'Распаковывает массив в значения. (В Lua 5.4 — `table.unpack`)' }, + { kind: 'function', name: 'setmetatable', insertText: 'setmetatable(${1:t}, ${2:mt})', doc: 'Устанавливает metatable для таблицы.' }, + { kind: 'function', name: 'getmetatable', insertText: 'getmetatable($0)', doc: 'Возвращает metatable или nil.' }, + { kind: 'function', name: 'rawget', insertText: 'rawget(${1:t}, ${2:key})', doc: 'Чтение без вызова __index metatable.' }, + { kind: 'function', name: 'rawset', insertText: 'rawset(${1:t}, ${2:key}, ${3:value})', doc: 'Запись без вызова __newindex metatable.' }, + + // === task.* === + { kind: 'module', name: 'task', insertText: 'task', doc: 'Современный API планировщика Roblox-Lua.\nЗаменяет `wait`, `spawn`, `delay`, `defer` из старого API.' }, + { kind: 'function', name: 'task.wait', insertText: 'task.wait(${1:1})', doc: 'Приостанавливает на N секунд.\nВозвращает фактическое время ожидания.\n```lua\nlocal dt = task.wait(0.5)\n```' }, + { kind: 'function', name: 'task.spawn', insertText: 'task.spawn(${1:function() end})', doc: 'Немедленно запускает функцию как coroutine.\n```lua\ntask.spawn(function() heavy() end)\n```' }, + { kind: 'function', name: 'task.delay', insertText: 'task.delay(${1:1}, ${2:function() end})', doc: 'Отложенный запуск функции через N секунд.\n```lua\ntask.delay(3, function() print("через 3 сек") end)\n```' }, + { kind: 'function', name: 'task.defer', insertText: 'task.defer(${1:function() end})', doc: 'Запуск в следующем кадре (после Heartbeat).' }, + + // === Vector3 === + { kind: 'class', name: 'Vector3', insertText: 'Vector3', doc: '3D-вектор в Roblox.\nКонструктор: `Vector3.new(x, y, z)`.\nКонстанты: `Vector3.zero`, `Vector3.one`, `Vector3.xAxis`, `Vector3.yAxis`, `Vector3.zAxis`.' }, + { kind: 'function', name: 'Vector3.new', insertText: 'Vector3.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт `Vector3(x, y, z)`.\n```lua\nlocal v = Vector3.new(10, 5, 0)\nprint(v.X, v.Y, v.Z, v.Magnitude)\n```' }, + { kind: 'function', name: 'Vector3.zero', insertText: 'Vector3.zero', doc: '`Vector3(0, 0, 0)`.' }, + { kind: 'function', name: 'Vector3.one', insertText: 'Vector3.one', doc: '`Vector3(1, 1, 1)`.' }, + { kind: 'function', name: 'Vector3.xAxis', insertText: 'Vector3.xAxis', doc: '`Vector3(1, 0, 0)`.' }, + { kind: 'function', name: 'Vector3.yAxis', insertText: 'Vector3.yAxis', doc: '`Vector3(0, 1, 0)`.' }, + { kind: 'function', name: 'Vector3.zAxis', insertText: 'Vector3.zAxis', doc: '`Vector3(0, 0, 1)`.' }, + + // === Color3 === + { kind: 'class', name: 'Color3', insertText: 'Color3', doc: 'Цвет RGB в Roblox.\nКомпоненты `R`, `G`, `B` в диапазоне [0, 1].' }, + { kind: 'function', name: 'Color3.new', insertText: 'Color3.new(${1:1}, ${2:1}, ${3:1})', doc: 'Создаёт `Color3(r, g, b)`, где компоненты в [0, 1].' }, + { kind: 'function', name: 'Color3.fromRGB', insertText: 'Color3.fromRGB(${1:255}, ${2:255}, ${3:255})', doc: 'Создаёт `Color3` из 0-255 RGB.\n```lua\nlocal red = Color3.fromRGB(255, 0, 0)\n```' }, + { kind: 'function', name: 'Color3.fromHSV', insertText: 'Color3.fromHSV(${1:0}, ${2:1}, ${3:1})', doc: 'Создаёт цвет из HSV-компонентов в [0, 1].' }, + { kind: 'function', name: 'Color3.fromHex', insertText: 'Color3.fromHex(${1:"#FF0000"})', doc: 'Создаёт цвет из hex-строки.' }, + + // === CFrame === + { kind: 'class', name: 'CFrame', insertText: 'CFrame', doc: 'Coordinate Frame — позиция + поворот в 3D.\nИспользуется для трансформаций Part.CFrame.' }, + { kind: 'function', name: 'CFrame.new', insertText: 'CFrame.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт CFrame в указанной позиции.' }, + { kind: 'function', name: 'CFrame.lookAt', insertText: 'CFrame.lookAt(${1:eye}, ${2:target})', doc: 'CFrame, направленный из eye на target.' }, + { kind: 'function', name: 'CFrame.Angles', insertText: 'CFrame.Angles(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame только с поворотом (в радианах).' }, + { kind: 'function', name: 'CFrame.fromEulerAnglesXYZ', insertText: 'CFrame.fromEulerAnglesXYZ(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame с поворотом по эйлеровым углам.' }, + + // === UDim2 / Vector2 === + { kind: 'class', name: 'UDim2', insertText: 'UDim2', doc: 'Размер/позиция GUI: процент + пиксели по обеим осям.' }, + { kind: 'function', name: 'UDim2.new', insertText: 'UDim2.new(${1:0}, ${2:0}, ${3:0}, ${4:0})', doc: '`UDim2.new(scaleX, offsetX, scaleY, offsetY)`.\n```lua\nframe.Position = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана\n```' }, + { kind: 'function', name: 'UDim2.fromScale', insertText: 'UDim2.fromScale(${1:0.5}, ${2:0.5})', doc: 'Только процентные размеры.' }, + { kind: 'function', name: 'UDim2.fromOffset', insertText: 'UDim2.fromOffset(${1:100}, ${2:100})', doc: 'Только пиксельные размеры.' }, + { kind: 'class', name: 'Vector2', insertText: 'Vector2', doc: '2D-вектор.' }, + { kind: 'function', name: 'Vector2.new', insertText: 'Vector2.new(${1:0}, ${2:0})', doc: '`Vector2(x, y)`.' }, + { kind: 'class', name: 'UDim', insertText: 'UDim', doc: 'Одномерная UDim (scale + offset).' }, + { kind: 'function', name: 'UDim.new', insertText: 'UDim.new(${1:0}, ${2:0})', doc: '`UDim.new(scale, offset)`.' }, + + // === Instance === + { kind: 'class', name: 'Instance', insertText: 'Instance', doc: 'Базовый класс всех объектов Roblox.' }, + { kind: 'function', name: 'Instance.new', insertText: 'Instance.new("${1:Part}", ${2:workspace})', doc: 'Создаёт новый объект указанного класса.\n```lua\nlocal part = Instance.new("Part", workspace)\npart.Size = Vector3.new(4, 1, 4)\npart.Position = Vector3.new(0, 10, 0)\n```' }, + + // === game / services === + { kind: 'variable', name: 'game', insertText: 'game', doc: 'Корень DataModel. `game:GetService("Players")` — доступ к сервисам.' }, + { kind: 'variable', name: 'workspace', insertText: 'workspace', doc: 'Сокращение для `game.Workspace`. Содержит все Part-объекты сцены.' }, + { kind: 'variable', name: 'script', insertText: 'script', doc: 'Текущий скрипт. `script.Parent` — объект-носитель.\n```lua\nlocal part = script.Parent\npart.Touched:Connect(function(hit) ... end)\n```' }, + + // === Enum === + { kind: 'enum', name: 'Enum', insertText: 'Enum', doc: 'Перечисления Roblox: KeyCode, Material, UserInputType, EasingStyle, EasingDirection, HumanoidStateType.' }, + { kind: 'enum', name: 'Enum.KeyCode', insertText: 'Enum.KeyCode.${1:W}', doc: 'Клавиши клавиатуры: W, A, S, D, Space, LeftShift, Q, E, F, R, T, ..., One, Two, ..., Up, Down.' }, + { kind: 'enum', name: 'Enum.UserInputType', insertText: 'Enum.UserInputType.${1:MouseButton1}', doc: 'Типы ввода: MouseButton1/2/3, Keyboard, Touch, MouseMovement, MouseWheel.' }, + { kind: 'enum', name: 'Enum.Material', insertText: 'Enum.Material.${1:Plastic}', doc: 'Материалы: Plastic, Wood, Metal, Neon, Glass, Sand, Ice, Grass, Concrete.' }, + { kind: 'enum', name: 'Enum.HumanoidStateType', insertText: 'Enum.HumanoidStateType.${1:Running}', doc: 'Состояния Humanoid: Running, Jumping, Freefall, Landed, Dead, Climbing, Swimming, Seated.' }, +]; + +// === Сниппеты быстрого старта (готовые шаблоны) === +const ROBLOX_LUA_SNIPPETS = [ + { + label: 'killbrick', + documentation: 'KillBrick — убивает игрока при касании.', + insertText: [ + 'local part = script.Parent', + 'part.Touched:Connect(function(hit)', + '\tlocal humanoid = hit.Parent:FindFirstChildOfClass("Humanoid")', + '\tif humanoid then', + '\t\thumanoid.Health = 0', + '\tend', + 'end)', + ].join('\n'), + }, + { + label: 'teleportpad', + documentation: 'TeleportPad — телепортирует игрока в указанную точку.', + insertText: [ + 'local destination = Vector3.new(${1:0}, ${2:50}, ${3:0})', + 'local pad = script.Parent', + 'pad.Touched:Connect(function(hit)', + '\tlocal root = hit.Parent:FindFirstChild("HumanoidRootPart")', + '\tif root then', + '\t\troot.CFrame = CFrame.new(destination)', + '\tend', + 'end)', + ].join('\n'), + }, + { + label: 'coin', + documentation: 'Coin — даёт игроку монету при касании, потом исчезает.', + insertText: [ + 'local coin = script.Parent', + 'local collected = false', + 'coin.Touched:Connect(function(hit)', + '\tif collected then return end', + '\tif hit.Parent:FindFirstChildOfClass("Humanoid") then', + '\t\tcollected = true', + '\t\tprint("Монета собрана!")', + '\t\tcoin:Destroy()', + '\tend', + 'end)', + ].join('\n'), + }, + { + label: 'heartbeat', + documentation: 'RunService.Heartbeat — кадровый callback.', + insertText: [ + 'local RunService = game:GetService("RunService")', + 'RunService.Heartbeat:Connect(function(dt)', + '\t${0:-- код, выполняется каждый кадр}', + 'end)', + ].join('\n'), + }, + { + label: 'playeradded', + documentation: 'PlayerAdded — реакция на захождение игрока.', + insertText: [ + 'local Players = game:GetService("Players")', + 'Players.PlayerAdded:Connect(function(player)', + '\tprint("Игрок зашёл:", player.Name)', + '\t${0:}', + 'end)', + ].join('\n'), + }, + { + label: 'spinpart', + documentation: 'SpinPart — вращающаяся платформа.', + insertText: [ + 'local RunService = game:GetService("RunService")', + 'local part = script.Parent', + 'local speed = ${1:2} -- радиан/сек', + 'RunService.Heartbeat:Connect(function(dt)', + '\tpart.CFrame = part.CFrame * CFrame.Angles(0, speed * dt, 0)', + 'end)', + ].join('\n'), + }, +]; + +export function registerLuaInMonaco(monaco) { + if (monaco.__rbxLuaRegistered) return; + monaco.__rbxLuaRegistered = true; + + // 1. CompletionProvider — автодополнение + monaco.languages.registerCompletionItemProvider('lua', { + triggerCharacters: ['.', ':', '"', "'"], + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + const suggestions = []; + const kindMap = { + 'function': monaco.languages.CompletionItemKind.Function, + 'class': monaco.languages.CompletionItemKind.Class, + 'module': monaco.languages.CompletionItemKind.Module, + 'enum': monaco.languages.CompletionItemKind.Enum, + 'variable': monaco.languages.CompletionItemKind.Variable, + }; + for (const item of ROBLOX_LUA_API) { + suggestions.push({ + label: item.name, + kind: kindMap[item.kind] || monaco.languages.CompletionItemKind.Text, + insertText: item.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: { value: item.doc, isTrusted: true }, + range, + }); + } + for (const snip of ROBLOX_LUA_SNIPPETS) { + suggestions.push({ + label: snip.label, + kind: monaco.languages.CompletionItemKind.Snippet, + insertText: snip.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: { value: snip.documentation, isTrusted: true }, + detail: 'Сниппет Roblox-Lua', + range, + }); + } + return { suggestions }; + }, + }); + + // 2. HoverProvider — подсказки при наведении + const lookupTable = new Map(); + for (const item of ROBLOX_LUA_API) lookupTable.set(item.name, item); + monaco.languages.registerHoverProvider('lua', { + provideHover: (model, position) => { + const word = model.getWordAtPosition(position); + if (!word) return null; + // Пробуем найти точное совпадение или с префиксом (Vector3.new) + let found = lookupTable.get(word.word); + if (!found) { + // Возможно курсор на середине A.B — попробуем собрать всю цепочку + const line = model.getLineContent(position.lineNumber); + // Ищем имя.имя.имя на позиции + const left = line.slice(0, word.endColumn - 1); + const m = left.match(/[A-Za-z_][\w.]*$/); + if (m) found = lookupTable.get(m[0]); + } + if (!found) return null; + return { + range: new monaco.Range( + position.lineNumber, word.startColumn, + position.lineNumber, word.endColumn, + ), + contents: [ + { value: `**${found.name}**` }, + { value: found.doc }, + ], + }; + }, + }); +}