Перейти к основному содержимому

8.04. Разработка на Roblox

Всем

Разработка на Roblox

Платформа Roblox представляет собой экосистему, объединяющую пользователей и разработчиков для создания, публикации и взаимодействия с виртуальными мирами. Её ключевая особенность — возможность разработки трёхмерных игр без необходимости владения профессиональными 3D-редакторами или сложными игровыми движками. Основным инструментом для разработки выступает Roblox Studio — приложение, предоставляющее доступ к редактору сцены, системе физики, средствам визуализации и полнофункциональному языку программирования Lua.

Разработка на Roblox ориентирована как на начинающих, так и на опытных программистов. Низкий порог входа позволяет освоить основы геймдева уже на начальных этапах обучения, однако масштабируемость платформы и поддержка скриптования открывают возможности для реализации сложных механик, многопользовательского взаимодействия и даже экономических систем.


Основы

Система Roblox

Roblox — платформа с клиент-серверной архитектурой и интегрированной средой разработки.
Она состоит из трёх уровней:

УровеньРольПримеры компонентов
Платформа (Roblox Cloud)Хостинг, аутентификация, платёжная система, DataStore, DevHubMarketplace, DataStoreService, Developer Products
Сервер игры (Game Server)Выполняет авторитетную логику: движение, коллизии, экономикаWorkspace, ServerScriptService, Players, физика
Клиент (Roblox Player)Отображает игру, обрабатывает ввод, показывает GUIStarterGui, LocalScript, рендер, звук

Важно: при запуске игры в Studio вы запускаете локальный сервер + один клиент — это эмуляция реального окружения. Но архитектура остаётся прежней: сервер и клиент — разные процессы.

Roblox Cloud - это глобальная распределённая система, управляемая Roblox Corporation. Не подвергается прямому влиянию разработчика игры, но предоставляет сервисы через API.

Roblox построена по принципу разделения ответственности между облачной инфраструктурой, игровым сервером и клиентом. Эта структура гарантирует безопасность, масштабируемость и согласованность состояния во всех сессиях.

Платформа (Roblox Cloud)

Это глобальная распределённая система, управляемая Roblox Corporation. Не подвергается прямому влиянию разработчика игры, но предоставляет сервисы через API.

Компоненты и их назначение

КомпонентОписаниеДоступ из игрыПримечания
Authentication & IdentityУправление аккаунтами, сессиями, ролевой моделью (RBAC)game.Players.LocalPlayer.UserId, UserOwnsGamePassAsync()Все Player объекты содержат только публичную информацию (Name, DisplayName); приватные данные (email, возраст) недоступны.
Game Hosting & MatchmakingВыделение и управление игровыми серверами (Game Server), балансировка нагрузки, регион-маршрутизацияgame.JobId, game.PlaceIdСервер игры существует не дольше 6 часов (лимит idle-time); при завершении все данные должны быть сохранены в DataStore.
DataStoreServiceПостоянное хранилище данных игроков и игры. Реализует ACID-подобную семантику с ограничениями.DataStoreService:GetDataStore()Три типа: GlobalDataStore, OrderedDataStore, LegacyDataStore. Ограничения: 60 чтений/записей в секунду на ключ, 4 MB на значение, 5 ключей в UpdateAsync за раз.
MarketplaceServiceОбработка покупок через Robux, возвратов, проверки владения Game Pass.MarketplaceService:PromptPurchase(), .PromptPurchaseFinishedВсе операции — асинхронные. Подтверждение приходит только после финальной авторизации на сервере Roblox.
DevHub & Creator DashboardСистема управления ассетами, монетизацией, аналитикой, Developer Products.Только вне игры (веб-интерфейс)Регистрация Developer Product требует: уникального ProductId, названия, описания, цены. После публикации — изменение цены невозможно (только создание нового продукта).
UGC & Asset DeliveryХостинг и доставка пользовательских ассетов (модели, анимации, текстуры).rbxassetid://..., rbxthumb://...Все ассеты проходят модерацию. Использование непроверенных внешних ресурсов (http://) запрещено политикой.
Telemetry & AnalyticsСбор метрик: retention, DAU, crash reports, performance stats.Только через встроенные события (.ChildAdded, HttpService запрещён для отправки)Данные доступны разработчику в DevHub. Прямой экспорт (например, в Google Analytics) блокируется.

Важные ограничения платформы

  • Нет доступа к файловой системе — даже для временных файлов.
  • Нет прямого HTTP-выходаHttpService разрешён только для ограниченного набора whitelisted-хостов (например, Discord webhooks — только с включённой опцией в Settings).
  • Время на сервере — UTC, без доступа к локальному времени игрока.
  • Память ограничена: ~1.5–2.5 GB RAM на сервер (в зависимости от региона и тарифа). Переполнение → OOM-kill.

Сервер игры (Game Server)

Это экземпляр движка Roblox, развернутый в облаке или локально (в Studio). Выполняет авторитетную (authoritative) логику — то есть любое его решение считается окончательным.

Компоненты

КомпонентНазначениеПримеры
DataModelКорень иерархии объектов. Содержит: Workspace, Players, Lighting, ReplicatedFirst и др.game, DataModel — синонимы.
WorkspaceСцена физического мира. Все BasePart, Model, Terrain, персонажи — здесь.Физика (PhysX), рендер, коллизии.
PlayersКоллекция подключённых игроков. Управляет жизненным циклом Player и Character.game.Players.PlayerAdded, player.CharacterAdded.
ServerScriptServiceКонтейнер только для серверных скриптов (Script). Автоматически запускается при загрузке.Логика победы, экономика, инициализация уровней.
ReplicatedStorageОбщий контейнер для объектов, доступных и серверу, и клиентам.Каталоги предметов, общие ModuleScript, RemoteEvent.
PhysicsServiceУправление группами коллизий, физическими параметрами.CreateCollisionGroup, CollisionGroupSetCollidable.
LogServiceЦентрализованный логгер для сервера.LogService.MessageOut:Connect(...).

Жизненный цикл сервера

  1. Инициализация
    Загружаются: DataModel, Workspace, Lighting, ReplicatedStorage, ServerScriptService. Выполняются Script в порядке создания.

  2. Ожидание игроков
    Сервер работает в фоне, обрабатывая события (например, таймеры), но не запускает Character до подключения.

  3. Подключение игрока

    • Создаётся Player в game.Players.
    • Вызывается PlayerAdded.
    • Клонируются StarterPlayer, StarterGui, StarterPack → в player.
    • Сервер создаёт player.Character (если не отключено player:LoadCharacter()).
  4. Игровой процесс

    • Физика, события, скрипты работают синхронно с тиком движка (~30–60 FPS).
    • Сервер отправляет обновления клиентам через репликацию (изменения свойств, событий, объектов).
  5. Отключение игрока

    • Character уничтожается → player.Character = nil.
    • Вызывается PlayerRemoving.
    • Данные игрока сохраняются (если предусмотрено).
  6. Завершение сессии
    Сервер останавливается через 6 часов бездействия или при ручной остановке. Все несохранённые данные теряются.

Ключевые принципы

  • Репликация идёт только «сверху вниз»:
    Server → Client, но не наоборот — кроме специально разрешённых RemoteEvent/RemoteFunction.

  • Сервер не имеет GUI — ни ScreenGui, ни TextLabel, ни рендер.
    Всё, что видит игрок, — результат репликации на клиент.

  • Нет доступа к LocalPlayergame.Players.LocalPlayer всегда nil на сервере.


Клиент (Roblox Player)

Это приложение, установленное на устройстве игрока (Windows, macOS, iOS, Android, Xbox, VR). Отвечает за локальную интерактивность и рендер.

Компоненты

КомпонентНазначениеПримеры
Renderer3D-рендеринг сцены (на основе Enlighten для освещения, Physically Based Rendering).Тени, отражения, пост-обработка.
InputManagerОбработка клавиатуры, мыши, тача, геймпада.UserInputService.InputBegan, ContextActionService.
SoundServiceВоспроизведение звуков и музыки.Sound:Play(), SoundService.RespectFilteringEnabled.
StarterGuiШаблон GUI. Не отображается — клонируется в player.PlayerGui при входе.ScreenGui, Frame, TextButton.
LocalScriptКлиентские скрипты. Должны быть в PlayerScripts, StarterGui, Backpack.Анимации камеры, HUD, обработка кликов.
PlayerScriptsСпециальный контейнер в StarterPlayerScripts. Загружается после Character.Animate.lua, пользовательские LocalScript.

Жизненный цикл клиента

  1. Загрузка
    Подключается к Game Server, запрашивает DataModel.

  2. Инициализация
    Принимает Workspace, Lighting, клонирует Starter* → в свой контекст.

  3. Создание персонажа
    После получения player.Character — запускаются LocalScript в PlayerScripts.

  4. Интерактивность

    • Обработка ввода → отправка RemoteEvent:FireServer().
    • Обновление GUI в ответ на RemoteEvent.OnClientEvent.
    • Рендер в реальном времени.
  5. Отключение
    При выходе — остановка всех скриптов, очистка памяти.

Ограничения клиента

  • Нет доступа к другим игрокам, кроме LocalPlayer.
    game.Players:GetPlayers() возвращает всех, но player.Character других игроков — только позиция и ориентация (без Humanoid.Health, если не реплицируется).

  • Нет прямого изменения Workspace — попытка part.Position = ... в LocalScript вызовет ошибку attempt to index nil или игнорируется.

  • Локальные данные не сохраняютсяPlayerGui, Backpack, leaderstats уничтожаются при выходе, если не синхронизированы с сервером.

  • Девконсоль (F9) позволяет:

    • Выполнять Lua-код (но только в локальной сессии);
    • Изменять свойства объектов (но только нереплицируемые);
    • Не даёт доступа к ServerScriptService, DataStore.

Взаимодействие уровней

Пример: покупка предмета за монеты

ЭтапПлатформаСерверКлиент
1. Инициацияbutton:Click() → BuyEvent:FireServer("Item_001", txId)
2. ВалидацияПроверка txId, баланса, наличия предмета
3. ТранзакцияCurrencyManager:Spend(); Inventory:Unlock(); DataStore:UpdateAsync()
4. ПодтверждениеBuyEvent:FireClient(player, "Success")Получает событие → обновляет GUI
5. СохранениеDataStore принимает запись, реплицирует в резервные нодыОперация завершена

⚠️ Ни один этап не может быть пропущен. Пропуск валидации на сервере = уязвимость к читерству.


Порядок инициализации

Процесс запуска делится на три фазы: инициализация мира (server-only), подключение игрока (server), инициализация клиента (client). Каждая фаза имеет строгий порядок и набор событий.


Фаза 1. Инициализация сервера (до подключения игроков)

Происходит при запуске game, локально в Studio (Play) или в облаке (Production Server). Выполняется один раз за сессию сервера.

Шаг 1.1. Загрузка DataModel

  • Создаётся корневой объект DataModel (game).
  • Инициализируются системные сервисы:
    • Workspace
    • Players
    • Lighting
    • ReplicatedStorage
    • ServerScriptService
    • SoundService
    • HttpService (если разрешён)
    • и др.

⚠️ ReplicatedFirst, StarterGui, StarterPlayerне создаются автоматически. Они появляются только если присутствуют в .rbxlx или были добавлены вручную.

Шаг 1.2. Выполнение Script в ServerScriptService

  • Скрипты запускаются в порядке их создания (не алфавитном, не по имени).
  • Параллельное выполнение:
    • Если скрипт содержит while true do task.wait(), он не блокирует загрузку других скриптов.
    • task.spawn() и coroutine позволяют инициировать фоновые процессы.
  • Доступные сервисы:
    • game:GetService("Workspace")
    • game.Players:GetPlayers(){} (пустая таблица)
    • game.Players.LocalPlayernil (сервер не имеет LocalPlayer)

Шаг 1.3. Инициализация Workspace

  • Физика ещё не запущена: Workspace:IsLoaded()false.
  • Коллизии, гравитация, Touched — неактивны.
  • Можно создавать объекты (Part, Model), но они не участвуют в симуляции, пока Workspace.Loaded не станет true.

Шаг 1.4. Событие Workspace.Loaded

  • Флаг Workspace.Loaded = true устанавливается.
  • Запускается физический движок (PhysX).
  • Объекты в Workspace становятся физическими:
    • Anchored = false → начинают падать.
    • Touched, TouchEnded — активны.
    • BasePart:GetConnectedParts() — работает.

✅ Рекомендуется: откладывать физически значимые операции (например, :MoveTo(), ApplyImpulse()) до Workspace.Loaded:Wait().

Шаг 1.5. Инициализация Lighting

  • Устанавливаются параметры по умолчанию (время суток, туман, Ambient).
  • После Workspace.Loaded — начинает влиять на рендер.

Фаза 2. Подключение игрока (серверная сторона)

Происходит при первом подключении игрока к серверу. Может повторяться для каждого игрока.

Шаг 2.1. Событие Players.PlayerAdded

  • Создаётся объект player (класс Player) и добавляется в game.Players.
  • Свойства player:
    • player.Name, player.DisplayName
    • player.UserId
    • player.Characternil
    • player.PlayerGuinil
    • player.Backpacknil

⚠️ Попытка доступа к player.Character.Humanoid здесь → attempt to index nil.

Шаг 2.2. Клонирование Starter-контейнеров

Последовательность строго фиксирована:

ПорядокДействиеКуда клонируется
1StarterPlayerScriptsplayer.PlayerScripts
2StarterCharacterScriptsplayer.CharacterScripts (только при создании Character)
3StarterPlayerplayer (свойства, атрибуты, скрипты)
4StarterGuiplayer.PlayerGui (создаётся пустой PlayerGui, затем клонируются дочерние элементы)
5StarterPackplayer.Backpack (создаётся Backpack, затем клонируются инструменты)

🔍 Важно:

  • StarterGui и StarterPack не клонируются напрямую — создаются контейнеры (PlayerGui, Backpack), и в них копируются дети.
  • Если StarterGui пуст — player.PlayerGui всё равно создаётся (как пустая папка).
  • Клонирование не включает LocalScript в StarterPlayer → они становятся Script в player.PlayerScripts.

Шаг 2.3. Инициализация PlayerGui и Backpack

  • player.PlayerGui и player.Backpack становятся не-nil.
  • Но: GUI ещё не отображается — рендер не начался.

Шаг 2.4. Создание персонажа (Character)

  • Вызывается внутренне player:LoadCharacter() (если player.Character = nil и не отключено в настройках).
  • Происходит асинхронно — обычно через 0.1–0.5 сек после PlayerAdded.
  • Этапы создания персонажа:
    1. Создаётся Modelplayer.Character.
    2. В него клонируется R15/R6 rig (в зависимости от настроек).
    3. Применяются ассеты (одежда, лицо, тело — из аккаунта игрока).
    4. Запускается Animator, Humanoid, HumanoidRootPart.
    5. Вызывается событие player.CharacterAdded.

⚠️ player.CharacterAdded не гарантирует, что:

  • HumanoidRootPart уже существует (может быть задержка из-за загрузки мешей);
  • Humanoid.Health > 0 (иногда Humanoid инициализируется с Health = 0, затем Health = 100).

Шаг 2.5. Событие player.CharacterAdded

  • Сигнализирует, что player.Characternil и содержит базовую иерархию.
  • Рекомендуемая практика:
    player.CharacterAdded:Connect(function(character)
    local hrp = character:WaitForChild("HumanoidRootPart", 5)
    if not hrp then return end
    local humanoid = character:WaitForChild("Humanoid", 5)
    if not humanoid or humanoid.Health <= 0 then
    -- Дождаться полной инициализации
    humanoid.HealthChanged:Wait()
    end
    -- Теперь можно: hrp.Position, humanoid:MoveTo(), и т.д.
    end)

Фаза 3. Инициализация клиента (локальная машина игрока)

Запускается после того, как сервер отправил клиенту DataModel и player.Character.

Шаг 3.1. Получение DataModel на клиенте

  • Клиент получает синхронизированную копию Workspace, Lighting, ReplicatedStorage.
  • game.ReplicatedFirst клонируется → game.StarterGuiPlayerGui (локальная копия).

Шаг 3.2. Создание LocalPlayer

  • game.Players.LocalPlayer → ссылка на собственного игрока.
  • Доступ к другим игрокам:
    • game.Players:GetPlayers()
    • player.Character других игроков — только PrimaryPart, Humanoid.Health (если разрешено), Position (реплицируется).

Шаг 3.3. Запуск LocalScript

  • LocalScript выполняются только в следующих контейнерах:
    • PlayerScripts
    • PlayerGui и его дети
    • Backpack и его дети
    • StarterPlayerScripts (клонируется в PlayerScripts)
  • LocalScript в Workspace, ReplicatedStorage, ServerScriptServiceигнорируются.

Шаг 3.4. Событие PlayerGui.Initialized

  • Гарантирует, что весь GUI загружен и отображается.
  • Аналог CharacterAdded для интерфейса:
    player.PlayerGui.Initialized:Connect(function()
    -- Безопасно искать ScreenGui, Frame, Button
    end)

Шаг 3.5. Запуск StarterCharacterScripts на клиенте

  • LocalScript, клонированные из StarterCharacterScripts, запускаются после CharacterAdded.
  • Именно здесь работает Animate.lua — анимации движения/прыжка.

Доступность объектов по времени

Временная меткаplayer.Characterplayer.PlayerGuiHumanoidHumanoidRootPartLocalPlayerWorkspace.Loaded
PlayerAddednilnilnil (сервер) / ✅ (клиент)?
После клонирования StarterGuinil✅ (пустой)?
CharacterAdded✅ (Model)✅ (но Health может быть 0)✅ (иногда с задержкой)
Humanoid.Health > 0

🔎 Примечание: Workspace.Loaded обычно происходит до первого PlayerAdded, но в сложных сценах (большие мешы, скрипты инициализации) — может быть позже.


Типичные ошибки и как их избежать

ОшибкаПричинаРешение
attempt to index nil with 'Humanoid'Доступ к player.Character.Humanoid в PlayerAddedИспользовать player.CharacterAdded:Wait() или :Connect()
GUI не отображаетсяLocalScript ищет ScreenGui сразу после PlayerAddedДождаться player.PlayerGui.Initialized или использовать player.PlayerGui:WaitForChild("MyGui")
Скрипт не запускаетсяLocalScript размещён в ReplicatedStorageПеренести в StarterGui или StarterPlayerScripts
RemoteEvent не срабатываетRemoteEvent создан на клиенте, а не в ReplicatedStorageСоздавать RemoteEvent в ReplicatedStorage на сервере до подключения игроков
Предмет не появляется в инвентареПопытка tool.Parent = player.Backpack до PlayerAddedВыполнять в PlayerAdded или CharacterAdded

Как проверить порядок самому?

Добавьте в ServerScriptService этот скрипт:

print("[Init] Server starting...")

game.Workspace.Loaded:Wait()
print("[Init] Workspace.Loaded = true")

game.Players.PlayerAdded:Connect(function(player)
print("[Event] PlayerAdded:", player.Name)
print(" → Character =", player.Character)
print(" → PlayerGui =", player.PlayerGui and "exists" or "nil")

player.CharacterAdded:Connect(function(char)
print("[Event] CharacterAdded for", player.Name)
print(" → Humanoid =", char:FindFirstChild("Humanoid") and "exists" or "nil")
print(" → HRP =", char:FindFirstChild("HumanoidRootPart") and "exists" or "nil")
end)

task.delay(0.2, function()
if player and player.Parent then
print("[Check 0.2s] Char =", player.Character ~= nil)
end
end)
task.delay(1, function()
if player and player.Parent then
local char = player.Character
if char then
print("[Check 1s] HRP =", char:FindFirstChild("HumanoidRootPart") ~= nil)
local hum = char:FindFirstChild("Humanoid")
print("[Check 1s] Humanoid.Health =", hum and hum.Health or "nil")
end
end
end)
end)

Instance

Instance — базовый класс (в терминах ООП — суперкласс), от которого наследуются все объекты в среде Roblox. Это не просто «контейнер с данными»: это активный субъект, участвующий в репликации, сериализации, событийной модели и управлении памятью.


Техническое определение

  • Тип: DataType.Instance (в Lua — typeof(obj) == "Instance").
  • Наследование: иерархия классов реализована в C++ ядре движка; в Lua доступна только через obj.ClassName, obj:IsA("Part").
  • Уникальный идентификатор: каждому Instance присваивается GUID при создании (obj:GetDebugId() → строка вида "1234567890"). Не путать с obj.Name (не уникален) или obj.UserId (только для Player).

✅ Проверка:

local part = Instance.new("Part")
print(typeof(part)) -- "Instance"
print(part.ClassName) -- "Part"
print(part:IsA("BasePart"))-- true (Part <: BasePart <: Instance)

Структура Instance

Каждый экземпляр состоит из четырёх взаимосвязанных компонентов. Нарушение целостности одного — делает объект неработоспособным.

КомпонентНазначениеРеализацияДоступность
Класс (Class Info)Определяет метаданные: список свойств, методов, событий, наследование.Загружается из rbxmx-манифестов при старте движка. Неизменяем.Только для чтения (ClassName, IsA(), GetFullName())
Состояние (State)Текущие значения свойств (Position, Name, Enabled и т.д.).Хранится в памяти; сериализуется в .rbxlx/.rbxl.Чтение/запись через obj.Property = value
Поведение (Methods & Events)Логика: методы (:Clone()) и точки расширения (.Touched).Часть класса; методы — вызовы C++ функций.Вызов через obj:Method() или obj.Event:Connect()
Контекст (Hierarchy)Положение в дереве (Parent, дети, Ancestors).Управляется через Parent-ссылку; критично для репликации и сборки мусора.Изменение через obj.Parent = parent

⚠️ Важно:

  • Свойства, методы и события не определяются динамически (как в JavaScript). Они зашиты в класс.
  • Попытка part.NewProperty = 42 не создаёт новое свойство — это создаёт локальную переменную в таблице Lua, не связанную с Instance.
  • Для хранения пользовательских данных используйте obj:SetAttribute("key", value) или Folder с IntValue/StringValue.

Класс, экземпляр, переменная

ПонятиеЧто этоГде хранитсяЖизненный цикл
Класс (Part, Script)Метаописание: какие свойства/методы есть, как сериализуется, как ведёт себя в физике.В C++ библиотеке движка (rbx-core.dll и др.). Загружается один раз при старте.Вечный — пока работает движок.
Экземпляр (workspace.Ground)Конкретный объект в сцене. Имеет состояние и позицию в иерархии.В куче (heap) движка. Имеет ссылочный счётчик.От Instance.new() / клонирования — до :Destroy() или сборки мусора.
Переменная в скрипте (local p = workspace.Ground)Ссылка (указатель) на экземпляр. Не содержит данных — только адрес.В Lua-стеке/куче.До выхода из области видимости или переприсваивания.

Пример, демонстрирующий разницу:

-- 1. Создаём экземпляр
local part1 = Instance.new("Part")
part1.Name = "Cube"
part1.Parent = workspace -- ← объект попадает в сцену

-- 2. Переменная — ссылка
local ref1 = part1
local ref2 = workspace.Cube

print(part1 == ref1) -- true
print(part1 == ref2) -- true (один и тот же экземпляр)

-- 3. Клонирование создаёт **новый экземпляр**
local part2 = part1:Clone()
part2.Parent = workspace
part2.Name = "Cube2"

print(part1 == part2) -- false (разные объекты в памяти)

🔍 При отладке в Studio:
— Выделение part1 и part2 в Explorer — разные элементы.
print(part1, part2) → разные DebugId.


Свойства

Типы свойств

ТипПримерыОсобенности
Базовые (Base)Name (string), Parent (Instance?), Archivable (bool)Есть у всех Instance. Изменение Name не влияет на репликацию.
Класс-специфичныеPart.Size (Vector3), Script.Source (string), RemoteEvent.OnServerEvent (RBXScriptSignal)Определены в классе. Недоступны в других классах (Part.Script → ошибка).
СериализуемыеПочти все, кроме LocalScript.Disabled, Player.UserIdСохраняются в .rbxlx. Несериализуемые — игнорируются при сохранении.
РеплицируемыеPart.Position, StringValue.Value, Player.DisplayNameИзменения отправляются клиентам. Не реплицируются: LocalScript, Player.UserId, Folder.ChildAdded и др.

Доступ к свойствам

  • Чтение: obj.Property — всегда возвращает текущее значение (может быть nil, если свойство не инициализировано).
  • Запись: obj.Property = value — валидируется типом:
    part.Size = "hello"  -- ошибка: "Unable to cast string to Vector3"
  • Отсутствующее свойство:
    print(part.NonExistent)  -- nil (без ошибки)
    part.NonExistent = 5 -- игнорируется (не создаёт свойство)

Событие .Changed

Срабатывает после успешного изменения любого свойства (включая Name, Parent).

part.Changed:Connect(function(property)
print("Свойство", property, "изменено на", part[property])
end)

part.Size = Vector3.new(2, 2, 2)
-- Вывод: "Свойство Size изменено на 2, 2, 2"
part.Name = "NewCube"
-- Вывод: "Свойство Name изменено на NewCube"

⚠️ Не используйте .Changed для критичной логики (например, сохранения):
— Оно срабатывает при любом изменении, включая репликацию.
— Не гарантирует порядок относительно других событий.


Методы

Методы — вызовы C++ функций через Lua-обёртку. Они не копируются при клонировании — поведение определяется классом.

Наиболее важные методы:

МетодНазначениеОсобенности
:Clone()Создаёт глубокую копию объекта и всех детей.Возвращает новый экземпляр с Parent = nil. Требует obj.Archivable = true (по умолчанию true).
:Destroy()Уничтожает объект и всех детей.Немедленно удаляет из памяти; события отключаются; Parent = nil. Безопасно вызывать повторно.
:FindFirstChild(name, recursive?)Поиск ребёнка по имени.Не вызывает ошибку, если не найден → возвращает nil. recursive = true — поиск во всей подветке.
:WaitForChild(name, timeout?)Блокирующий поиск (с ожиданием).Используется при инициализации (Character:WaitForChild("HumanoidRootPart")). При timeoutnil.
:GetChildren()Возвращает таблицу прямых детей.Не рекурсивно. Порядок — как в Explorer (по времени создания).
:IsDescendantOf(parent)Проверка принадлежности к ветке.Используется для проверки контекста (if tool:IsDescendantOf(player.Backpack) then).
:ClearAllChildren()Удаляет всех детей (но не сам объект).Эквивалентно for _, c in ipairs(obj:GetChildren()) do c:Destroy() end.

✅ Рекомендация:
Всегда используйте :Destroy(), а не obj.Parent = nil.
obj.Parent = nil оставляет объект в памяти, если есть ссылки (утечка).
:Destroy() гарантирует освобождение ресурсов.


События

События в Roblox — это экземпляры RBXScriptSignal. Они предоставляют два основных метода:

МетодНазначениеВозвращаемое значение
:Connect(callback)Подписка на событие.RBXScriptConnection — объект соединения.
:Wait()Блокирующее ожидание события.Значения, переданные в Fire(...).

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

local connection = workspace.Part.Touched:Connect(function(hit)
print("Коснулся", hit.Name)
end)

-- Отмена подписки
connection:Disconnect() -- событие больше не будет вызываться

-- Проверка состояния
print(connection.Connected) -- false после Disconnect()

⚠️ Утечки памяти:
— Если не вызвать :Disconnect(), соединение остаётся в памяти даже после :Destroy() объекта.
— Особенно критично в LocalScript: при выходе игрока соединения не отключаются автоматически.


Диагностика и отладка Instance

ЗадачаМетодПример
Узнать классobj.ClassName"Part"
Проверить типobj:IsA("BasePart")true для Part, MeshPart
Полный путьobj:GetFullName()"Workspace.Ground"
Отладочный IDobj:GetDebugId()"123456789" (уникален в сессии)
Проверить, уничтожен лиnot obj or not obj.ParentНенадёжно; лучше отслеживать вручную
Проверить, клонируемый лиobj.Archivabletrue по умолчанию для большинства объектов

🛠️ Совет: при отладке используйте warn(obj:GetFullName(), obj.ClassName, obj:GetDebugId()) — это выводит кликабельную ссылку в Output Studio.


Основные понятия

Instance

В Roblox всё — объект типа Instance:

  • Workspace, Part, Script, RemoteEvent, Player, Folder, DataModel.

Каждый Instance обладает:

  • Классом — определяет возможности объекта (например, у Part есть Size, Anchored; у RemoteEventFireServer, OnServerEvent).
  • Свойствами — данные, которые можно читать/писать (Name, Parent, Transparency, Position).
  • Методами — действия, которые можно вызвать (:Clone(), :Destroy(), :FindFirstChild()).
  • Событиями — сигналы, на которые можно подписаться (.Touched, .Changed, MouseButton1Click).

⚠️ Не путайте:

  • Класс — абстрактное описание (например, Part).
  • Экземпляр — конкретный объект в сцене (например, Ground в Workspace).
  • Переменная в скрипте — ссылка на экземпляр, а не его копия.

Иерархия

Любой Instance имеет ровно одного родителя (Parent) и может иметь много детей.

-- Создание объекта и привязка к иерархии
local part = Instance.new("Part")
part.Name = "MyCube"
part.Parent = workspace -- ← важнейшая строка: без неё объект "висит в воздухе"
КонтекстКак работает Parent
В ExplorerДочерние объекты отступлены, скрытие родителя скрывает детей
В кодеgame.Workspace.MyCube — синтаксический сахар для game:GetService("Workspace"):FindFirstChild("MyCube")
При клонировании:Clone() копирует всю подветку, но отвязывает от родителя — нужно вручную задать Parent.

✅ Пример ошибки:

local part = Instance.new("Part")
part.Position = Vector3.new(0, 10, 0)
part.Anchored = true
-- ... и ничего не происходит

Причина: part.Parent == nil. Объект создан, но не добавлен в сцену → не рендерится и не участвует в физике.


Контейнеры и сервисы

Что такое контейнер?

Контейнер — это Instance, который может содержать другие объекты.
Основные типы:

КонтейнерТипРольДоступ
WorkspaceModelВсе видимые объекты мираСервер + клиент
ReplicatedStorageFolderДанные и объекты для обменаСервер + клиент
ServerScriptServiceFolderСерверные скриптыТолько сервер
StarterGuiFolderШаблоны GUI для игроковТолько сервер (клонируется)
PlayersPlayersКоллекция подключённых игроковСервер + клиент (только свой игрок)

Сервис — специальный контейнер, предоставляющий API (например, MarketplaceService, PhysicsService).
Доступ к сервисам:

local ds = game:GetService("DataStoreService")
local phys = game:GetService("PhysicsService")

Как объект попадает в игру?

  1. Ручное размещение в Studio → сохраняется в .rbxlx → загружается сервером при старте.
  2. Клонирование из Starter* → при заходе игрока.
  3. Создание через Instance.new() → динамически, во время игры.

→ Только объекты с Parent ~= nil и находящиеся в Workspace, Lighting, Players и т.п. становятся активными.


Добавление скрипта к объекту

Этот раздел можно использовать как универсальный алгоритм для любого объекта: Part, Model, Tool, ScreenGui, ImageButton. Следуйте шагам строго — это гарантирует корректную работу и избегание распространённых ошибок.


Шаг 1. Создание объекта в сцене

Действия в Studio:

  1. Откройте вкладку Home → нажмите Part
    → в Workspace появится Part (по умолчанию: Position = (0, 5, 0), Size = (2, 2, 2), Anchored = false).

  2. В Explorer найдите объект:

    Workspace
    └── Part ── [выделите его]
  3. В Properties задайте:

    • Name = "Button" (для идентификации в коде),
    • Anchored = true (чтобы не падал),
    • Color = BrickColor.new("Bright green") (визуальная обратная связь),
    • CanCollide = false (чтобы игрок мог пройти сквозь него — опционально).

✅ Почему Anchored = true?
Если Anchored = false, объект подчиняется физике: упадёт, может улететь при столкновении → поведение непредсказуемо. Для интерактивных элементов (кнопок, триггеров) — почти всегда true.


Шаг 2. Добавление скрипта к объекту

Действия в Studio:

  1. Убедитесь, что Button выделен в Explorer.
  2. ПКМ по ButtonInsert Object… → выберите Script
    → создаётся Script внутри Button (в иерархии: Workspace.Button.Script).

⚠️ Критическое правило:
— Скрипт, помещённый внутрь объекта, имеет прямой доступ к нему через script.Parent.
— Это надёжнее, чем workspace:FindFirstChild("Button"), потому что:
  • не зависит от имени;
  • не требует поиска;
  • работает даже при переименовании объекта.

Что произошло технически:

  • Создан экземпляр класса Script (не LocalScript, не ModuleScript).
  • Его свойство Parent установлено в Button.
  • Скрипт автоматически запущен (сервером), так как находится в Workspace — зона видимости сервера.

Шаг 3. Базовый шаблон скрипта

Замените содержимое Script на следующий код — это универсальный шаблон, пригодный для 90 % простых интерактивных объектов:

-- Шаблон: интерактивный объект с реакцией на касание
-- Размещается внутри объекта (script.Parent = объект)

local object = script.Parent -- ← основное правило: привязка к родителю
local enabled = true -- флаг для безопасного отключения логики

-- 1. Проверка: объект существует и поддерживает события
if not object:IsA("BasePart") and not object:IsA("Model") then
warn("[Script] Ошибка: скрипт должен быть внутри Part/Model")
return
end

-- 2. Обработчик события
local function onTouched(hit)
if not enabled then return end

-- Проверка: коснулся ли игрок?
local character = hit:FindFirstChildOfClass("Model")
if not character then return end

local humanoid = character:FindFirstChild("Humanoid")
if not humanoid or humanoid.Health <= 0 then return end

-- Проверка: это не наш собственный объект (защита от self-touch)
if hit:IsDescendantOf(object) then return end

-- ✅ Здесь — ваша логика
print("Объект", object.Name, "активирован игроком", humanoid.Parent.Name)

-- Пример: смена цвета
object.BrickColor = BrickColor.Random()

-- Пример: вызов функции
activateEffect(object)
end

-- 3. Вспомогательная функция
local function activateEffect(target)
-- Изменение свойства
target.Transparency = 0.5
task.delay(0.2, function()
if enabled and target.Parent then
target.Transparency = 0
end
end)

-- Вызов метода
target:ScaleTo(Vector3.new(1.2, 1.2, 1.2), 0.2)
task.delay(0.2, function()
if enabled and target.Parent then
target:ScaleTo(Vector3.new(1, 1, 1), 0.2)
end
end)
end

-- 4. Подключение события
local connection = object.Touched:Connect(onTouched)

-- 5. Очистка при уничтожении объекта
script.Destroying:Connect(function()
enabled = false
if connection and connection.Connected then
connection:Disconnect()
end
end)

Разбор шаблона по блокам

БлокЗачем нуженЧто будет, если убрать
local object = script.ParentПрямая привязка к контексту. Надёжнее workspace.Button.При переименовании/копировании — сломается.
IsA("BasePart"/"Model")Защита от размещения скрипта в Folder, IntValue и др.Ошибка attempt to index nil при Touched.
Проверка hit:FindFirstChildOfClass("Model")Игнорировать касания частиц, Part, не принадлежащих персонажу.Сработает на падающем мусоре, эффектах.
hit:IsDescendantOf(object)Защита от self-touch (например, при Anchored = false).Объект может активировать сам себя.
enabled + DestroyingГарантированная отмена подписки.Утечка памяти при удалении объекта.
ScaleTo() вместо Size = ...Плавное изменение (встроенный метод анимации).Резкое изменение — режет глаз.

Шаг 4. Расширение: другие типы взаимодействия

Тот же шаблон работает для любого события — нужно только заменить:

Тип взаимодействияЧто изменить в шаблоне
Нажатие мышью (в GUI)Объект: TextButton в ScreenGui; Событие: MouseButton1Click; Проверка: не нужна (GUI — только клиент)
Использование инструментаОбъект: Tool в Backpack; Событие: Activated; Место скрипта: внутри Tool → будет Script, а не LocalScript
Вход в зону (Region3)Объект: Part как триггер; Логика: while true do task.wait(); if hrp.Position inside region then ...; Событие: не используется — polling
Клиентская анимация (камера, HUD)Скрипт: LocalScript, размещённый в StarterGui/Button; Событие: MouseButton1Click

Пример: кнопка в GUI (клиентская версия)

-- LocalScript внутри StarterGui/ScreenGui/TextButton

local button = script.Parent
local enabled = true

local function onClick()
if not enabled then return end

-- Изменение свойства объекта GUI
button.TextColor3 = Color3.new(1, 0, 0)

-- Вызов удалённой функции (безопасно!)
game.ReplicatedStorage.RemoteEvents.Action:FireServer("ButtonClick")
end

local connection = button.MouseButton1Click:Connect(onClick)

script.Destroying:Connect(function()
enabled = false
if connection then connection:Disconnect() end
end)

✅ Правило безопасности:
— Клиент может инициировать действие, но не выполнять его напрямую.
— Реальная логика (начисление монет, выдача предмета) — только после FireServer → серверная валидация.


События

События — это точки расширения, в которые можно «подключить» код.

-- Подписка на событие
workspace.Part.Touched:Connect(function(hit)
print("Столкнулся с", hit.Name)
end)

Как это работает:

  1. Происходит физическое столкновение → движок вызывает событие .Touched.
  2. Система ищет все активные :Connect() на этом событии.
  3. Для каждого — создаётся задача, выполняемая в порядке подписки.

✅ Важно:

  • :Connect() возвращает соединение (RBXScriptConnection) — его можно .Disconnect().
  • События не блокируют основной поток: обработчики запускаются асинхронно.

Игрок

player.Character — это объект типа Model, размещённый непосредственно в Workspace. Он представляет физическую форму игрока в мире: тело, анимации, коллизии, передвижение. Его наличие необязательно: игрок может быть в игре без персонажа (например, в интерфейсном меню), но любое взаимодействие с 3D-миром требует его загрузки.

Персонаж создаётся, существует и уничтожается в рамках строго определённого цикла, управляемого движком. На каждый этап приходится конкретное событие и состояние объекта.

  • Состояние player.Character = nil
    Это начальное и конечное состояние. Оно возникает:

    • до первого вызова player:LoadCharacter() (обычно сразу после PlayerAdded);
    • после player:UnloadCharacter() (например, при смерти, телепортации в меню);
    • при выходе игрока (PlayerRemoving).
  • Создание персонажа
    Происходит вызовом player:LoadCharacter(), который инициирует:

    1. Создание пустого Model → присваивается player.Character.
    2. Клонирование базового скелета (R6 или R15, в зависимости от настроек аккаунта):
      • R6: 6 частей — Head, Torso, Left/Right Arm, Left/Right Leg;
      • R15: 15 частей — добавлены Upper/LowerTorso, Left/RightHand, суставы.
    3. Установка PrimaryPart = HumanoidRootPart — точка привязки для перемещения.
    4. Добавление системных объектов:
      • Humanoid — контроллер здоровья, состояния (Running, Jumping), смерти;
      • Animator — управление анимациями (через AnimationTrack);
      • HumanoidDescription — описание внешности (тело, лицо, одежда);
      • StarterGear — экипировка по умолчанию (если настроена в StarterPlayer).
  • Событие CharacterAdded
    Генерируется после присвоения player.Character = model, но до полной загрузки всех частей. На момент события:

    • model:FindFirstChild("Humanoid") может вернуть nil, если загрузка мешей задержана;
    • Humanoid.Health часто равен 0 в первый тик, затем устанавливается в 100;
    • HumanoidRootPart может отсутствовать на 1–2 кадра (особенно при медленной загрузке ассетов).
  • Полная инициализация
    Состояние «персонаж готов» наступает, когда:

    • Humanoid.Health > 0;
    • HumanoidRootPart существует и имеет CFrame;
    • Animator загрузил базовые анимации (idle, walk, jump).

    Рекомендуемый способ ожидания:

    player.CharacterAdded:Connect(function(char)
    local hrp = char:WaitForChild("HumanoidRootPart", 5)
    local hum = char:WaitForChild("Humanoid", 5)
    if not (hrp and hum) then return end
    hum.HealthChanged:Connect(function(health)
    if health > 0 then
    -- Персонаж полностью инициализирован
    end
    end)
    end)
  • Уничтожение персонажа
    Инициируется player:UnloadCharacter() или автоматически при:

    • Humanoid.Health <= 0 (если включено автовоскрешение — создаётся новый);
    • телепортации в зону без физики;
    • выходе из игры.
      Перед уничтожением генерируется событие CharacterRemoving, позволяющее сохранить состояние (позиция, инвентарь в руках).

Объект player (тип Player) содержит:

Свойство / Дочерний объектНазначение
.UserIdУникальный ID в системе Roblox
.NameОтображаемое имя
.CharacterМодель персонажа (м.б. nil при входе/выходе)
.PlayerGuiGUI, отображаемый только этому игроку
.BackpackИнвентарь вне экипировки
.leaderstats (соглашение)Папка для счётчиков (уровень, монеты)

⚠️ player.Character может быть nil не только при входе, но и при смерти, телепортации, смене персонажа.

Обязательные компоненты персонажа

Ниже — минимальный набор объектов, без которых персонаж не будет функционировать как игровая сущность.

  • HumanoidRootPart
    Это Part (обычно BallSocket или Block), служащий точкой опоры для физики и перемещения. Именно его CFrame используется движком для:

    • расчёта позиции игрока в мире;
    • привязки камеры;
    • определения центра масс в физике.
      Если HumanoidRootPart отсутствует или nil, методы вроде Humanoid:MoveTo() игнорируются.
  • Humanoid
    Контроллер состояния персонажа. Его свойства управляют поведением:

    • Health, MaxHealth — система жизни;
    • WalkSpeed, JumpPower — физические параметры;
    • State (Enum.HumanoidStateType) — текущее действие (Walking, Jumping, Dead);
    • SeatPart — если персонаж сидит, ссылается на Seat.
      Методы :TakeDamage(), :Sit(), :MoveTo() вызывают изменения состояния и генерируют события (Touched, Died).
  • Animator
    Отвечает за проигрывание анимаций. Работает с Animation (ассеты, загруженные из библиотеки или AnimationIds).
    Важно: Animator не создаётся вручную — он добавляется автоматически при загрузке персонажа.

  • Скелет (R6/R15)
    Иерархия Part и JointInstance (BallSocketConstraint, HingeConstraint).
    Пример R15:

    Character
    ├── HumanoidRootPart
    ├── LowerTorso
    │ ├── LeftLeg
    │ └── RightLeg
    ├── UpperTorso
    │ ├── LeftArm
    │ └── RightArm
    │ └── Head
    └── ...

    Коллизии между частями отключены (CanCollide = false), чтобы избежать самопроизвольных падений.

Разработчик может добавлять в персонажа:

  • Tool — экипированный предмет (в Backpack или прямо в Character);
  • ProximityPrompt — интерактивные подсказки;
  • Script/LocalScript — поведение, привязанное к персонажу (например, Animate.lua — клонируется из StarterCharacterScripts).

Инвентарь

Термин «инвентарь» в Roblox не имеет единого объекта-контейнера. Вместо этого используется двухуровневая система, разделённая на:

  • Backpack — серверный контейнер для предметов, не экипированных в текущий момент;
  • PlayerGui + Character — клиентские места отображения (GUI) и экипировки (в руках).

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

Backpack — серверное хранилище

  • Это объект типа Backpack, дочерний по отношению к player.

  • Создаётся автоматически при подключении игрока, если в StarterPack есть хотя бы один предмет или явно вызвано player:FindFirstChild("Backpack") (ленивая инициализация).

  • Содержимое: только объекты типа Tool (и его наследники, например, HopperBin).
    Другие типы (Part, Folder, IntValue) игнорируются движком: они не отображаются в интерфейсе, не подчиняются логике экипировки.

  • Жизненный цикл предмета в Backpack:

    1. Игрок подбирает предмет → сервер вызывает tool.Parent = player.Backpack.
    2. Предмет появляется в клиентском инвентаре (рендерится через StarterGui/BackpackFrame).
    3. При нажатии — клиент отправляет tool:Activate(), сервер получает tool.Activated.
    4. Сервер проверяет:
      tool:IsDescendantOf(player.Backpack) → можно экипировать;
      player.Character существует → можно поместить в руки.
    5. Сервер вызывает tool.Parent = player.Character → предмет переходит в персонаж.
  • Ограничения:

    • Максимум 10 предметов в Backpack по умолчанию (настраивается через StarterPlayer.MaxBackpackItems).
    • Предметы в Backpack не участвуют в физике, даже если CanCollide = true.
    • Backpack доступен только на сервере; клиент видит его содержимое через репликацию, но не может напрямую изменять.

Экипировка — предметы в руках

Когда предмет «в руках», он находится внутри Character, обычно в Torso или UpperTorso.
Типичная иерархия:

Character
└── UpperTorso
└── Sword (Tool)
├── Handle (Part) — видимая модель
└── Script — логика атаки
  • Активация: вызов tool:Activate() на клиенте → tool.Activated на сервере.
  • Деактивация: tool:Deactivate() или смена оружия → сервер перемещает tool.Parent = player.Backpack.

🔐 Безопасность:
— Клиент не может вызвать tool.Parent = workspace напрямую — сервер отклонит операцию.
— Сервер должен проверять, что tool принадлежит игроку:

tool.AncestryChanged:Connect(function(_, newParent)
if newParent ~= player.Character and newParent ~= player.Backpack then
tool.Parent = player.Backpack -- возврат в инвентарь
end
end)

Визуальное отображение (GUI)

Инвентарь не имеет встроенного GUI. Его отображение — обязанность разработчика.

  • Типичный подход:

    1. В StarterGui создаётся ScreenGui с Frame и ImageButton для каждого слота.
    2. На клиенте LocalScript подписывается на:
      • player.Backpack.ChildAdded → отобразить новый предмет;
      • player.Backpack.ChildRemoved → убрать иконку;
      • Humanoid.EquipTool / UnequipTool → подсветить активный слот.
    3. Для иконок используются ImageLabel с rbxassetid://... (ассеты из Marketplace или загруженные через DevHub).
  • Важно:
    GUI должен обновляться только на клиенте, но данные — браться с сервера через RemoteEvent. Например:

    -- Сервер
    inventoryUpdate:FireClient(player, { "Sword", "Shield", nil, ... })

    -- Клиент
    inventoryUpdate.OnClientEvent:Connect(function(slots)
    for i, itemName in ipairs(slots) do
    slotsGUI[i].Icon.Image = getIconId(itemName)
    end
    end)

Постоянное хранение

Ни Backpack, ни Character не сохраняются между сессиями. Для долговременного инвентаря используется:

  • DataStore — сохранение списка Tool по ProductId или имени:
    DataStore:SetAsync("Inventory_" .. player.UserId, { "Sword", "Potion" })
  • leaderstats (соглашение) — временные счётчики в рамках сессии:
    local leaderstats = Instance.new("Folder")
    leaderstats.Name = "leaderstats"
    leaderstats.Parent = player

    local coins = Instance.new("IntValue")
    coins.Name = "Coins"
    coins.Value = 100
    coins.Parent = leaderstats

⚠️ leaderstats — это не системный объект, а соглашение сообщества. Его использование не гарантируется, но поддерживается всеми популярными фреймворками (например, StarterPlayerScripts/Chat).


Взаимодействие

Цикл «взять → экипировать → использовать → убрать» выглядит так:

  1. Сервер создаёт Tool в Workspace (например, Sword).
  2. Игрок касается предмета → сервер проверяет расстояние и права → sword.Parent = player.Backpack.
  3. Клиент получает репликацию → отображает иконку в GUI.
  4. Игрок нажимает кнопку → клиент вызывает sword:Activate().
  5. Сервер получает sword.Activated → проверяет:
    • sword.Parent == player.Backpack — можно экипировать;
    • player.Character существует — можно поместить в руки.
  6. Сервер: sword.Parent = player.Character.UpperTorso.
  7. Клиент: запускает анимацию, показывает эффекты.
  8. При использовании (удар) — клиент отправляет RemoteEvent:FireServer("Attack"), сервер валидирует и применяет урон.

Любой пропущенный шаг (например, отсутствие проверки Parent на сервере) приводит к уязвимости или ошибке.


Транзакции и сервер

Что такое транзакция?

Транзакция — операция, которая должна быть выполнена целиком или не выполнена вовсе.
Пример: «списать 100 монет и выдать меч».

Если списание прошло, а выдача — нет → игрок потерял деньги → недоверие.

Почему сервер — источник истины?

ОперацияКлиентская проверкаСерверная проверка
Проверка балансаif balance >= 100CurrencyManager:GetBalance(player) >= 100
Списаниеbalance -= 100CurrencyManager:SpendCoins(player, 100)
Выдача предметаinventory:add("Sword")InventorySystem:UnlockItem(player, "Sword")

Проблема клиента:

  • Любой может открыть DevConsole (F9) и выполнить:
    game.Players.LocalPlayer.leaderstats.Coins.Value = 999999
  • Или заменить цену в GUI-скрипте.

Сервер защищён: клиент может попросить, но решает сервер.


Как проходит покупка?

Рассмотрим покупку меча за 500 монет:

ЭтапКлиентСерверПлатформа
1. ПоказОтображает кнопку «Купить (500 монет)»
2. ИнициацияBuyEvent:FireServer("Sword", txId)Получает запрос
3. ВалидацияПроверяет: — txId уникален? — предмет существует? — баланс ≥ 500?
4. Исполнение1. Списывает 500 монет; 2. Добавляет меч в Inventory; 3. Сохраняет в DataStore
5. ПодтверждениеПолучает BuyEvent.OnClientEvent("Success")
6. ОтображениеОбновляет GUI, показывает эффект

Если на шаге 4 произошла ошибка → сервер возвращает "Failed" → клиент не меняет баланс (он запрашивает актуальный после ответа).

🔁 При повторной отправке с тем же txId сервер отклонит запрос как дубль — это и есть идемпотентность.


Ландшафт

Ландшафт (объект Terrain) — это специализированный компонент среды, предназначенный для создания непрерывных, объёмных, деформируемых поверхностей крупного масштаба: гор, пещер, рек, рельефа местности. В отличие от сборки из отдельных Part, ландшафт представляет собой единое воксельное поле, управляемое движком на уровне низкоуровневых операций. Его существование обусловлено необходимостью баланса между визуальной сложностью, физической достоверностью и производительностью при масштабах, недостижимых через классический подход с BasePart.

Внутреннее устройство

Ландшафт организован как трёхмерная регулярная сетка ячеек — вокселей (volume pixels). Каждый воксель имеет фиксированный размер — 2 × 2 × 2 метра (это значение неизменно и не настраивается). Вся доступная для редактирования область делится на чанки размером 64 × 64 × 64 вокселя (то есть 128 × 128 × 128 метров в мире). Чанки загружаются и выгружаются динамически по мере перемещения игрока, что позволяет работать с картами размером до 8192 × 8192 × 512 метров (теоретический лимит), не перегружая память.

Каждый воксель хранит несколько атрибутов:

  • Материал — определяет визуальный вид и физические свойства (земля, камень, вода, песок и др.; всего 36 встроенных материалов).
  • Форма — геометрия внутри вокселя: пусто, полный куб, наклонная плоскость (ramp), угловая поверхность (wedge) и комбинации (например, «четверть куба»). Это позволяет создавать плавные склоны без ступенчатых переходов.
  • Цвет — корректирующий оттенок (tint), накладываемый на базовую текстуру материала.

Движок генерирует единый меш на каждый чанк, объединяя все воксели с одинаковым материалом и смежными гранями. Этот процесс называется mesh stitching. Благодаря ему количество отрисовываемых объектов остаётся пропорциональным числу чанков, а не числу вокселей — что даёт решающее преимущество в производительности при больших ландшафтах.

Физическое поведение

Физика ландшафта реализована на основе воксельной коллизионной сетки, а не полигональных мешей. Это означает, что коллизии рассчитываются не по точной геометрии поверхности, а по упрощённой сетке, где каждый воксель представляет собой куб или призму. Точность коллизий зависит от разрешения формы внутри вокселя, но даже в максимальном качестве она уступает MeshPart с высокополигональной коллизией.

Ключевые особенности:

  • Объекты с CanCollide = true взаимодействуют с ландшафтом как с твёрдой поверхностью; Anchored = false части падают и останавливаются на ней.
  • Touched и TouchEnded работают, но с задержкой и возможными «дрожаниями» при движении вдоль наклонных поверхностей (из-за дискретности вокселей).
  • Физические свойства (трение, упругость) наследуются от материала: Material.Grass имеет низкое трение, Material.Ice — ещё ниже, Material.Sand — высокое сопротивление.
  • Вода (Material.Water) — не физическая жидкость, а визуальный эффект с ограниченной глубиной (максимум 20 метров). Объекты под водой получают замедление через Humanoid.WalkSpeed, но потоков, течений или плавучести нет.

Важно: ландшафт не участвует в динамической физике — его нельзя толкнуть, разрушить или переместить физическим воздействием. Любые изменения должны инициироваться программно или через инструменты редактора.

Редактирование

В Studio ландшафт редактируется через панель Terrain, доступную при выделении объекта Terrain в Workspace. Основные инструменты:

  • Raise/Lower — изменение высоты поверхности;
  • Smooth — сглаживание рельефа;
  • Paint — смена материала;
  • Fill — заливка объёма (например, создание куба земли);
  • Erase — удаление вокселей (создание пустоты, пещер);
  • Region Paste — копирование фрагментов между местами или играми.

Программное управление осуществляется через API объекта Terrain, доступного как workspace.Terrain. Ключевые методы:

  • :FillBlock(region: Region3, material: Enum.Material)
    Заполняет указанный куб материалом. Region3 должен быть выровнен по воксельной сетке (координаты кратны 2).

  • :FillWedge(region: Region3, material: Enum.Material)
    Заполняет клин (полезно для склонов).

  • :ReadVoxels(region: Region3, resolution: number)
    Возвращает трёхмерный массив данных вокселей в заданной области. resolution = 4 — стандартное разрешение (1 воксель = 1 элемент массива).

  • :WriteVoxels(region: Region3, resolution: number, data: VoxelData)
    Записывает данные в ландшафт. Используется для процедурной генерации (например, шум Перлина → высота → материал).

  • :PlaceAsync(region: Region3, instances: {Instance})
    Встраивает стандартные Part, MeshPart в ландшафт (например, камни на поверхности), сохраняя их физику и скрипты.

Ограничения API:

  • Все операции синхронные и блокирующие — при работе с большими областями (> 1 млн вокселей) возможны фризы.
  • Максимальная область в одном вызове — 2048 × 2048 × 256 вокселей.
  • Изменения мгновенно реплицируются всем клиентам (в отличие от Part, где можно скрыть промежуточные состояния).

Производительность и масштабируемость

Ландшафт оптимизирован для статических или медленно меняющихся сцен. Его преимущества проявляются при площади поверхности свыше 500 × 500 метров:

  • 1 чанк = 1 draw call для рендера, 1 физический объект для движка;
  • в то время как сборка из Part того же размера потребовала бы тысяч draw call’ов и объектов физики.

Однако у ландшафта есть узкие места:

  • Изменение в реальном времени (например, копание тоннелей игроками) требует частых вызовов :FillBlock(), что создаёт нагрузку на CPU и сеть (из-за репликации).
  • LOD (Level of Detail) для ландшафта отсутствует — чанки всегда рендерятся в полном разрешении.
  • Ограничения на материалы: нельзя использовать кастомные текстуры напрямую — только через Terrain:DecorateAsync() с предварительно загруженными ассетами.

Для гибридных решений рекомендуется:

  • использовать Terrain для базового рельефа (горы, реки);
  • добавлять детализацию через Part/MeshPart (камни, деревья, здания);
  • избегать частого изменения вокселей — кэшировать операции, применять изменения пакетно.

Совместимость и ограничения платформы

  • Ландшафт поддерживается на всех платформах, включая мобильные устройства и консоли.
  • В мобильных сборках используется упрощённый шейдер рендера — детали текстур и тени могут быть снижены.
  • Максимальная высота по оси Y — 500 метров (вокселей выше y = 250 не существует); максимальная глубина — −200 метров.
  • Невозможно создать «плавающие» острова без опоры — воксели в воздухе удаляются автоматически, если не поддерживаются снизу (настройка workspace.Terrain.AutoStretchFill).

Когда использовать ландшафт, а когда — Part

Выбор обусловлен задачей, а не предпочтениями:

  • Используйте Terrain, если:

    • требуется непрерывный рельеф (горы, долины, пещеры);
    • карта превышает 300 × 300 метров;
    • важна производительность на слабых устройствах;
    • допустимы приблизительные коллизии.
  • Используйте Part/MeshPart, если:

    • нужна точная геометрия (архитектура, интерьеры);
    • важны детальные коллизии (платформер, паркур);
    • объекты должны быть динамическими (движущиеся платформы, разрушаемые стены);
    • требуется кастомная текстура на отдельном элементе.

Гибридный подход (ландшафт + декор из Part) — стандартная практика в профессиональных проектах.


Добавление, удаление, изменение объектов

Через интерфейс (Studio)

  • Добавить: Home → Part / Model / Script.
  • Удалить: выделить → Delete / ПКМ → Delete.
  • Изменить свойство: выделить → Properties → ввести значение.

Через код

ДействиеКод
Создатьlocal p = Instance.new("Part")
Разместитьp.Parent = workspace
Изменить свойствоp.Size = Vector3.new(5, 1, 5)
Найти по имениworkspace:FindFirstChild("Ground")
Найти по путиgame.Players.Player1.PlayerGui.ScoreLabel
Удалитьp:Destroy()

:Destroy() предпочтительнее p.Parent = nil, потому что:
— отключает события;
— освобождает память;
— не оставляет «висячих» ссылок.


Монетизация

Что такое Robux?

  • Валюта платформы Roblox.
  • Приобретается за реальные деньги.
  • Разработчик не управляет балансом Robux игрока — только запрашивает покупку.

Как работает покупка за Robux?

⚠️ Запрещено:

  • Запрашивать платёж из LocalScript без подтверждения через PromptPurchase.
  • Выдавать предмет до получения PromptPurchaseFinished.
  • Использовать RemoteEvent для имитации покупки за Robux.

Архитектура проекта в Roblox Studio

Структура сцены

Каждый проект в Roblox Studio строится вокруг понятия места (Place) — файла, содержащего всю информацию об игровом мире: объекты, скрипты, настройки, ресурсы. Проект хранится в виде .rbxl или .rbxlx, что позволяет сохранять состояние сцены между сессиями редактирования.

Центральным элементом редактора является окно Explorer, отображающее иерархию всех объектов в текущем месте. Объекты организованы в древовидную структуру, где каждый узел может содержать дочерние элементы. Это позволяет группировать связанные компоненты, например, помещая все элементы игровой среды в папку Game Environment.

Пример:

Workspace
├── Game Environment
│ ├── Ground
│ ├── Obstacle
│ └── Platforms
├── StarterGui
└── ReplicatedStorage

Такая организация способствует поддержке чистоты кода и упрощает навигацию по проекту.

Основные контейнеры

  • Workspace — корневой контейнер для всех видимых объектов в игровом мире: персонажи, платформы, препятствия.
  • StarterGui — содержит элементы графического интерфейса (GUI), которые автоматически клонируются для каждого игрока при подключении.
  • ReplicatedStorage — используется для хранения данных и объектов, доступных как на сервере, так и на клиенте.
  • ServerScriptService — место для размещения серверных скриптов, управляющих логикой игры.
  • Lighting — хранит параметры освещения, времени суток, тумана.
  • Players — содержит данные о каждом игроке, включая его персонажа (Character) и инвентарь.

Правильное использование этих контейнеров обеспечивает предсказуемое поведение объектов и корректную работу системы репликации.


Интерфейс Roblox Studio

Основные панели

Интерфейс Roblox Studio состоит из нескольких ключевых компонентов:

  1. Главная область (Viewport) — трёхмерное пространство, в котором происходит редактирование сцены.
  2. Панель инструментов (Toolbar) — расположена сверху, содержит инструменты для выбора, перемещения, изменения размеров объектов, а также запуска и остановки игры.
  3. Explorer — дерево объектов сцены, позволяющее добавлять, удалять и переупорядочивать элементы.
  4. Properties — отображает и позволяет изменять свойства выбранного объекта (размер, положение, цвет, прозрачность, коллизии и др.).
  5. Toolbox — библиотека готовых моделей, эффектов, скриптов и материалов.
  6. Output — консоль, выводящая сообщения от скриптов, ошибки, предупреждения и результаты выполнения команд.

Доступ к этим панелям осуществляется через меню View. Например, чтобы открыть консоль, необходимо выбрать View → Output.

Управление камерой

Управление вьюпортом аналогично другим 3D-редакторам:

  • W, A, S, D — перемещение камеры.
  • Колесо мыши — масштабирование.
  • ПКМ + движение мыши — вращение камеры.
  • Зажатие Q — орбитальное вращение вокруг центра.

Эти действия позволяют эффективно навигировать по сцене и точно размещать объекты.


Создание игровой среды

Базовые объекты: Part и BasePart

Все физические объекты в Roblox основаны на классе BasePart. Наиболее часто используется Part — параллелепипед, который можно масштабировать, вращать и раскрашивать.

Пример: создание земли

  1. В Explorer создайте папку Game Environment.
  2. Внутри неё добавьте новый Part и назовите его Ground.
  3. Используйте инструмент Resize для изменения размеров (например, 100×1×100).
  4. В Properties установите материал: Grass или Concrete.
  5. При необходимости измените цвет через свойство Color.

Работа с ландшафтом

Для создания естественного рельефа используется встроенный Terrain Editor.

Шаги:

  1. Перейдите в меню View → Terrain.
  2. Выберите инструмент:
    • Raise/Lower — изменение высоты поверхности.
    • Smooth — сглаживание переходов.
    • Paint — нанесение текстур (трава, песок, камень).
  3. Для добавления воды используйте режим Water, задав уровень и форму водоёма.

Ландшафт поддерживает многослойную текстуризацию и может быть экспортирован/импортирован для повторного использования.


Программирование на Lua в Roblox

Основы Lua

Roblox использует диалект Lua 5.1 с расширениями API, специфичными для платформы. Язык интерпретируется в реальном времени, что позволяет быстро тестировать изменения.

Типичный скрипт в Roblox:

local part = script.Parent

part.Touched:Connect(function(hit)
print("Объект задет: " .. hit.Name)
end)

Типы скриптов

  • Script — выполняется на сервере. Используется для логики, требующей надёжности (например, проверка победы).
  • LocalScript — выполняется на клиенте. Подходит для GUI, анимаций, ввода с клавиатуры.
  • ModuleScript — содержит переиспользуемый код, импортируется через require().

Обработка событий

События — основа реактивной архитектуры в Roblox. Большинство объектов имеют встроенные события:

-- Обработка клика по кнопке
script.Parent.MouseButton1Click:Connect(function(player)
print(player.Name .. " нажал кнопку")
end)

-- Изменение положения персонажа
game.Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(char)
char:WaitForChild("HumanoidRootPart").Touched:Connect(function(hit)
-- Логика столкновения
end)
end)
end)

Асинхронность и задержки

Для отложенного выполнения используется task.wait() (аналог delay()):

task.spawn(function()
task.wait(5)
print("Прошло 5 секунд")
end)

Физика и коллизии

Система физики

Roblox включает встроенную физическую модель, основанную на двигателе NVIDIA PhysX. Все BasePart по умолчанию участвуют в симуляции, если не отключено свойство Anchored.

Ключевые свойства:

  • Anchored — фиксирует объект в пространстве.
  • CanCollide — определяет, будет ли объект сталкиваться с другими.
  • Massless — игнорирует влияние массы при столкновениях.
  • Gravity — включение/выключение гравитации для части.

PhysicsService

Для точного управления взаимодействием между объектами используется PhysicsService, позволяющий назначать объекты в разные группы коллизий:

local PhysicsService = game:GetService("PhysicsService")

PhysicsService:CreateCollisionGroup("Player")
PhysicsService:CollisionGroupSetCollidable("Player", "Obstacle", false)

Графический интерфейс (GUI)

Элементы интерфейса

GUI создаётся с помощью объектов внутри StarterGui. Основные типы:

  • ScreenGui — контейнер для 2D-интерфейса.
  • Frame — прямоугольная область.
  • TextButton, ImageButton — кнопки.
  • TextLabel, TextBox — отображение и ввод текста.

Пример: кнопка старта

local screenGui = Instance.new("ScreenGui")
local button = Instance.new("TextButton")

button.Size = UDim2.new(0, 150, 0, 50)
button.Position = UDim2.new(0.5, -75, 0.5, -25)
button.Text = "Start Game"
button.Parent = screenGui

button.MouseButton1Click:Connect(function()
print("Игра начата!")
end)

screenGui.Parent = game.StarterGui

Координатная система GUI

Используется относительная система координат через UDim2, где:

  • Scale — часть от размера родителя (0–1).
  • Offset — фиксированный сдвиг в пикселях.

Система уровней и прогресса

Реализация уровней

Система уровней может быть построена на основе накопления опыта (XP). При достижении порога — повышение уровня.

local playerData = {
Level = 1,
XP = 0,
XPToNextLevel = 100
}

function addXP(amount)
playerData.XP = playerData.XP + amount
while playerData.XP >= playerData.XPToNextLevel do
playerData.XP = playerData.XP - playerData.XPToNextLevel
playerData.Level = playerData.Level + 1
playerData.XPToNextLevel = math.floor(playerData.XPToNextLevel * 1.5)
print("Уровень повышен до: " .. playerData.Level)
end
end

Хранение данных

Для сохранения прогресса используется DataStore:

local DataStoreService = game:GetService("DataStoreService")
local playerStore = DataStoreService:GetDataStore("PlayerData")

-- Сохранение
playerStore:SetAsync("Player_"..player.UserId, playerData)

-- Загрузка
local data = playerStore:GetAsync("Player_"..player.UserId)

Важно: DataStore имеет ограничения на частоту вызовов и требует обработки ошибок.


Мультиплеер и репликация

Сервер-клиент архитектура

Roblox использует авторитетный сервер:

  • Все критические решения принимаются на сервере.
  • Клиенты получают только визуальные и интерфейсные обновления.

RemoteEvents и RemoteFunctions

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

  • RemoteEvent — односторонняя отправка (например, клик по кнопке).
  • RemoteFunction — запрос с ответом (например, получение данных игрока).

Пример:

ServerScript

local remoteEvent = game.ReplicatedStorage.RemoteEvent

remoteEvent.OnServerEvent:Connect(function(player, action)
if action == "Jump" then
-- Выполнить действие на сервере
end
end)

LocalScript

remoteEvent:FireServer("Jump")

Анимации и моделирование

Создание анимаций

Анимации создаются в Animation Editor:

  1. Выделите персонажа или часть.
  2. Откройте Window → Animation Editor.
  3. Добавьте ключевые кадры для свойств (позиция, вращение).
  4. Экспортируйте анимацию в AnimationController.

Скриптовое управление анимациями

local animator = character:WaitForChild("Animator")
local animation = Instance.new("Animation")
animation.AnimationId = "rbxassetid://123456789"

local track = animator:LoadAnimation(animation)
track:Play()

Тестирование и отладка

Консоль вывода (Output)

Окно Output показывает:

  • Результаты print().
  • Ошибки выполнения скриптов.
  • Предупреждения о производительности.

Инструменты отладки

  • Debug → Stats — мониторинг FPS, памяти, количества частей.
  • Test → Play — запуск локальной сессии.
  • TeleportService — тестирование перехода между местами.

Публикация проекта

Подготовка к публикации

  1. Убедитесь, что все скрипты протестированы.
  2. Проверьте права доступа к ассетам.
  3. Настройте настройки места: название, описание, теги.
  4. Установите уровень приватности (публичный, друзья, приватный).

Процесс публикации

  1. Нажмите Publish to Roblox.
  2. Выберите существующее место или создайте новое.
  3. Загрузите изменения.
  4. Получите ссылку для распространения.

После публикации игра становится доступна другим пользователям. Возможна интеграция с системой monetization (продажа предметов за Robux).


Методология разработки

Итеративный подход

  1. Прототипирование — создание минимального воспроизводимого примера (MVP).
  2. Тестирование — проверка на разных устройствах и соединениях.
  3. Оптимизация — снижение количества частей, использование MeshPart вместо сложных конструкций.
  4. Сбор обратной связи — анализ действий игроков, исправление багов.

Рекомендации

  • Начинайте с простых проектов (платформеры, мини-игры).
  • Используйте готовые ресурсы из Toolbox.
  • Документируйте код и структуру проекта.
  • Регулярно сохраняйте и коммитьте изменения (при использовании внешних систем контроля версий).

Система магазина и монет в Roblox Studio

Внутриигровая экономика — один из ключевых элементов современных игровых проектов, особенно в многопользовательских и сервисных играх, ориентированных на длительное вовлечение. Roblox, как платформа, предоставляет разработчикам инструменты для построения собственных экономических систем, включая магазины, валюту, транзакции и интеграцию с платёжными средствами. Однако, несмотря на высокий уровень абстракции, которую предлагает движок, понимание принципов построения таких систем требует системного подхода: от проектирования логики до обеспечения безопасности и масштабируемости.

Настоящая глава посвящена реализации магазина в Roblox Studio, и архитектурному осмыслению того, что представляет собой внутриигровой магазин как подсистема, как он интегрируется в общую структуру проекта, и какие ограничения и возможности накладывает платформа Roblox на разработчика. Мы последовательно разберём концепции, их взаимосвязи и реализацию, при этом сохраняя акцент на устойчивости, читаемости кода и перспективе дальнейшего развития проекта.


1. Что такое внутриигровой магазин

Внутриигровой магазин — это программная подсистема, которая реализует функции выбора, приобретения и выдачи игровых активов (предметов, улучшений, скинов, способностей и т.д.) в обмен на одну или несколько форм внутриигровой валюты. Магазин — это целостный сервис, включающий:

  • Каталог товаров — структура данных, описывающая доступные предметы, их стоимость, условия приобретения и эффекты после покупки;
  • Систему валюты — механизм учёта баланса игрока, валидации транзакций и обновления данных;
  • Механизмы выдачи и применения — логика, которая реагирует на успешную транзакцию и производит соответствующие изменения в состоянии игры (например, надевает скин, открывает уровень, выдаёт инвентарь);
  • Интерфейс взаимодействия — визуальный и поведенческий слой, через который игрок взаимодействует с магазином;
  • Систему сохранения и синхронизации — способ гарантированного сохранения состояния покупок между сессиями и при переходе между серверами.

Важнейшее свойство хорошего магазина — идемпотентность операций. Это означает, что повторное выполнение одной и той же операции (например, повторная отправка запроса на покупку) не приводит к нежелательным последствиям, таким как многократное списание валюты или дублирование предмета. В распределённых системах, какими являются игры на Roblox, такая устойчивость к сетевым артефактам критична.

Подсистема магазина должна быть изолирована от основной игровой логики. Это достигается за счёт чёткого разделения ответственности: модуль магазина не должен напрямую менять состояние персонажа или мира, а только отправлять события или вызывать строго определённые методы других систем (например, через шину событий или менеджер инвентаря). Такой подход обеспечивает тестируемость, модульность и упрощает дальнейшее расширение функционала.


2. Что такое магазин и монеты в Roblox: платформенные особенности

Roblox предоставляет две основные формы валюты: Robux — официальная платёжная валюта платформы, приобретаемая за реальные деньги, и внутриигровые валюты — пользовательские единицы, создаваемые разработчиком (например, «золото», «кристаллы», «очки опыта»). Эти два типа валют принципиально различаются по происхождению, способу управления и правовым последствиям.

2.1. Robux и Developer Products

Robux — это валюта, принадлежащая платформе. Разработчик не может напрямую управлять балансом Robux игрока. Вместо этого используется механизм Developer Products — зарегистрированных в системе DevHub цифровых товаров, привязанных к конкретной игре. При покупке такого товара Roblox обрабатывает платёж, списывает Robux с аккаунта игрока и отправляет подтверждённое событие в игру через MarketplaceService:PromptPurchase() и последующий MarketplaceService.PromptPurchaseFinished.

Ключевые особенности:

  • Все транзакции с Robux проходят через серверы Roblox — клиентская сторона не может инициировать списание напрямую.
  • Разработчик получает уведомление о покупке асинхронно, и только после получения подтверждения от сервера может выдать предмет.
  • Поддерживается механизм возврата (refund) — в течение определённого времени игрок может отменить покупку, и разработчик обязан корректно обработать отмену (например, отозвать предмет).
  • Developer Products неизменяемы после публикации: нельзя изменить цену или название без создания нового продукта. Это требует тщательного планирования при запуске проекта.

Таким образом, магазин, использующий Robux, всегда является гибридной системой: клиент отображает интерфейс, сервер Roblox обрабатывает платёж, а сервер игры (Game Server) — выдаёт награду.

2.2. Внутриигровые валюты

Любая валюта, не являющаяся Robux (например, «монеты», «жетоны»), полностью управляется разработчиком. Её баланс хранится в DataStore, в плейерских атрибутах (Player:SetAttribute / GetAttribute) или в пользовательских объектах (например, Folder в PlayerGui или Backpack), но только в рамках сессии — для постоянного хранения обязателен DataStore.

Особенности:

  • Разработчик сам определяет правила начисления (за уровень, за задание, за время) и расходования.
  • Отсутствует встроенная защита от мошенничества: клиентская часть может быть взломана, если логика проверки баланса реализована только на клиенте.
  • Нет автоматической интеграции с платёжными системами: конвертация реальных денег в такую валюту возможна только через Developer Product («купить 1000 монет за 50 Robux»).

Важно подчеркнуть: никакая внутриигровая валюта не может быть обменена на Robux игроком — это нарушает условия использования платформы. Обратный обмен (Robux → внутриигровая валюта) разрешён и является стандартной практикой.


3. Монетизация в Roblox и как разработчики получают доход

Монетизация в Roblox строится вокруг нескольких официальных каналов, каждый из которых требует соблюдения политик и технических требований платформы.

3.1. Developer Products (одноразовые покупки)

Это основной способ продажи цифровых товаров: скины, улучшения, внутриигровая валюта, косметика. Разработчик создаёт продукт в DevHub, задаёт цену в Robux, привязывает его к игре и обрабатывает событие покупки в коде. Roblox берёт комиссию ~30% (в зависимости от условий партнёрской программы и типа аккаунта — individual vs group).

3.2. Game Passes (игровые пропуска)

Game Pass — это разовая покупка, дающая постоянное преимущество: доступ к эксклюзивному контенту, бонусам, привилегиям (например, двойной опыт, уникальный персонаж). Game Pass привязан к аккаунту игрока и действует во всех сессиях игры. Технически проверка наличия Game Pass осуществляется через GamePassService:UserOwnsGamePassAsync().

Преимущество Game Pass перед Developer Product — в постоянстве эффекта и простоте проверки. Недостаток — невозможность динамического изменения функционала после выпуска (за исключением программной логики, активируемой наличием пропуска).

3.3. Premium Payouts (доход от подписки Roblox Premium)

Разработчики получают долю от времени, проведённого подписчиками Premium в их игре, через систему Engagement-Based Payouts. Это пассивный доход, не требующий реализации магазина, но зависящий от удержания аудитории. Расчёт производится ежемесячно и зависит от доли активного времени подписчиков в конкретной игре относительно всего экосистемного времени.

3.4. Виртуальные товары и пользовательский контент (UGC)

С 2023 года Roblox активно развивает систему UGC (User-Generated Content) — пользователи могут создавать и продавать свои ассеты (одежда, аксессуары, анимации), а разработчики — интегрировать их в игру через официальные API. Это создаёт дополнительные возможности для монетизации через комиссии и кураторство.

Важное ограничение: все платёжные операции должны проходить через официальные сервисы Roblox. Попытки организовать сторонние платежи (например, через Telegram-бота или внешний сайт) ведут к бану игры и аккаунта.


4. Как создать свою систему магазина и монет в Roblox Studio

Построение собственной экономической системы в Roblox требует технической реализации и проектирования архитектуры, устойчивой к ошибкам, мошенничеству и изменениям в требованиях. Ниже рассмотрены ключевые компоненты такой системы — от концептуальной модели до кодовой структуры. Мы будем избегать «быстрых решений» вроде скриптов в StarterGui, ориентируясь на промышленные практики разработки: разделение ответственности, защита от клиентских атак, восстанавливаемость состояния и поддерживаемость.


4.1. Архитектурные принципы

Любая устойчивая система магазина в Roblox должна соответствовать следующим принципам:

  • Сервер — единственный источник истины.
    Вся логика, связанная с проверкой баланса, списанием валюты и выдачей предметов, должна выполняться на сервере (ServerScriptService или ReplicatedStorage). Клиент (StarterGui, LocalScript) может инициировать запрос, но не принимать решение о допустимости операции.

  • Полная изоляция данных.
    Данные об инвентаре, валюте и истории покупок хранятся в DataStore, а не в PlayerGui или Backpack. Временные копии могут быть на клиенте для отображения, но они всегда должны синхронизироваться с сервером и считаться недостоверными до подтверждения.

  • Событийная модель взаимодействия.
    Клиент отправляет запросы. Сервер обрабатывает их, валидирует, и при успехе отправляет подтверждение или отказ. Это предотвращает race-conditions и обеспечивает предсказуемость.

  • Идемпотентность транзакций.
    Каждая покупка должна иметь уникальный идентификатор (например, transactionId = os.time() .. "_" .. playerId). При повторной отправке запроса с тем же ID сервер не должен выполнять операцию дважды. Это защищает от сетевых дублей и намеренных атак.

  • Отказоустойчивость при сохранении.
    Операция «списать валюту и выдать предмет» должна быть атомарной: либо оба действия выполнены, либо ни одно. Для этого применяется паттерн двухфазного сохранения:

    1. Сервер временно блокирует баланс игрока в памяти.
    2. Выполняет выдачу предмета (например, добавляет в PlayerData.Inventory).
    3. Выполняет сохранение в DataStore.
    4. При успехе — фиксирует списание; при ошибке — откатывает изменения.

4.2. Проектирование структуры данных

Перед написанием кода необходимо определить, как будут храниться и передаваться данные. Это влияет на масштабируемость и удобство отладки.

4.2.1. Структура каталога товаров

Каталог товаров — это статическая или полу-статическая конфигурация. Лучше всего её хранить в ReplicatedStorage в виде ModuleScript, например:

-- ReplicatedStorage/Catalog/Items.lua
return {
Sword_001 = {
Id = "Sword_001",
DisplayName = "Огненный клинок",
Description = "Меч, оставляющий след из пламени",
Price = { Coins = 500 },
Type = "Tool",
Unlockable = true,
Metadata = {
Damage = 25,
FireEffect = true
}
},
Skin_Warrior_Red = {
Id = "Skin_Warrior_Red",
DisplayName = "Красный воин",
Description = "Эксклюзивный скин для класса Воин",
Price = { RobuxProduct = "prod_123abc" }, -- ссылка на Developer Product ID
Type = "Appearance",
Metadata = {
MeshId = "rbxassetid://123456789",
TextureId = "rbxassetid://987654321"
}
}
}

Обратите внимание:

  • Цена может быть указана как в пользовательской валюте (Coins), так и через ссылку на DeveloperProduct (по ID или имени).
  • Все метаданные предмета — строго структурированы. Это позволяет системе выдачи интерпретировать их без жёсткой привязки к конкретным скриптам.
  • Каталог не содержит информации о наличии у игрока — только описание товара.
4.2.2. Структура данных игрока

Данные игрока (PlayerData) хранятся в DataStore и должны включать:

{
Coins = 1250,
Inventory = {
["Sword_001"] = { Count = 1, Equipped = true },
["Skin_Warrior_Red"] = { Unlocked = true }
},
PurchaseHistory = {
{ TransactionId = "1731156480_12345", ItemId = "Sword_001", Timestamp = 1731156480 },
{ TransactionId = "1731156800_12345", ItemId = "prod_123abc", Timestamp = 1731156800 }
}
}

Элементы:

  • Coins — баланс пользовательской валюты.
  • Inventory — карта предметов. Каждый предмет описывается минимально: достаточно флагов (Unlocked, Equipped, Count), а не полной копии каталога.
  • PurchaseHistory — журнал транзакций для аудита, отладки и реализации идемпотентности.

Важно: никогда не храните пароли, токены или приватные ключи в DataStore. Все данные, сохраняемые через DataStoreService, шифруются Roblox, но не предназначены для хранения секретов.


4.3. Реализация клиент-серверного взаимодействия

Взаимодействие между клиентом и сервером строится на RemoteEvent и RemoteFunction. Используйте два отдельных канала:

  • RemoteEvent — для асинхронных действий (покупка, запрос обновления баланса).
  • RemoteFunction — для синхронных запросов («можно ли купить?», «получить текущий баланс?»).
Пример: запрос на покупку
  1. Клиент (LocalScript в GUI):

    local BuyEvent = game:GetService("ReplicatedStorage"):WaitForChild("RemoteEvents"):WaitForChild("BuyItem")
    BuyEvent:FireServer("Sword_001", "tx_" .. tick())
  2. Сервер (Script в ServerScriptService):

    local BuyEvent = script.Parent:WaitForChild("BuyItem")

    BuyEvent.OnServerEvent:Connect(function(player, itemId, transactionId)
    if not player or not itemId or not transactionId then return end

    -- Проверка дубликата транзакции
    if isTransactionProcessed(player.UserId, transactionId) then
    warn("Duplicate transaction:", transactionId)
    return
    end

    local catalog = require(game.ReplicatedStorage.Catalog.Items)
    local item = catalog[itemId]
    if not item then return end

    local playerData = getPlayerData(player) -- загрузка из DataStore или кэша
    if not playerData then return end

    -- Валидация: достаточно ли валюты?
    local cost = item.Price.Coins
    if cost and playerData.Coins < cost then
    -- Отправить отказ клиенту
    fireClientEvent(player, "PurchaseFailed", { Reason = "InsufficientFunds" })
    return
    end

    -- Атомарное выполнение
    if commitPurchase(player, playerData, item, transactionId) then
    fireClientEvent(player, "PurchaseSuccess", { ItemId = itemId })
    else
    fireClientEvent(player, "PurchaseFailed", { Reason = "ServerError" })
    end
    end)

Ключевые моменты:

  • isTransactionProcessed проверяет PurchaseHistory.
  • commitPurchase выполняет: списание валюты, добавление в инвентарь, сохранение в DataStore, запись в историю — и только при полном успехе возвращает true.
  • Клиент получает только событие — без деталей баланса (во избежание утечки информации).

4.4. Интеграция с Robux (Developer Products)

Для продажи через Robux используется MarketplaceService. Важно: никогда не доверяйте клиенту информацию о покупке.

Правильная последовательность:

  1. Клиент вызывает MarketplaceService:PromptPurchase(player, productId).
  2. Игрок подтверждает покупку (всплывающее окно от Roblox).
  3. Roblox отправляет событие PromptPurchaseFinished на сервер.
  4. Сервер проверяет:
    • Принадлежит ли productId ожидаемому товару?
    • Не была ли покупка уже обработана?
    • Корректен ли player?
  5. При успехе — выдаёт предмет, сохраняет.

Пример обработчика на сервере:

local MarketplaceService = game:GetService("MarketplaceService")
local DataStoreService = game:GetService("DataStoreService")

MarketplaceService.PromptPurchaseFinished:Connect(function(player, productId, purchased)
if not purchased then return end

local productMap = {
[123456789] = "Coins_1000", -- ID продукта → внутренний ID товара
[987654321] = "Skin_Warrior_Red"
}

local internalId = productMap[productId]
if not internalId then
warn("Unknown product ID:", productId)
return
end

local transactionId = "robux_" .. productId .. "_" .. os.time()
if isTransactionProcessed(player.UserId, transactionId) then return end

local playerData = getPlayerData(player)
local catalog = require(game.ReplicatedStorage.Catalog.Items)
local item = catalog[internalId]

if item and commitPurchase(player, playerData, item, transactionId) then
-- Успешно
else
-- Логирование ошибки — без уведомления игрока (во избежание спама)
end
end)

Замечание: Roblox гарантирует, что PromptPurchaseFinished срабатывает только после подтверждения платёжа на сервере Roblox. Это делает его безопасным.


4.5. Безопасность: типичные уязвимости и защита

4.5.1. Подделка запросов на клиенте

Если логика «хватает предмет из каталога и вычитает цену» реализована в LocalScript, злоумышленник может изменить цену на 0 и купить всё.
Защита: Вся валидация — на сервере. Клиент отправляет только itemId, сервер сам смотрит цену в каталоге.

4.5.2. Race condition при одновременных покупках

Два запроса на покупку одного и того же предмета (например, последнего в лимитированной серии) могут пройти, если проверка баланса и списание не атомарны.
Защита: Блокировка игрока на время транзакции (например, через coroutine или флаг playerData.Locked), либо использование DataStore:UpdateAsync, который гарантирует сериализацию.

4.5.3. Потеря данных при ошибке сохранения

Если после списания валюты произошла ошибка сохранения, игрок потеряет деньги.
Защита: Паттерн «сначала сохранить, потом применить»:

  1. Создать копию playerData.
  2. Внести изменения в копию.
  3. Попытаться сохранить копию через UpdateAsync.
  4. При успехе — применить изменения в текущее состояние игрока.
4.5.4. Спам-атаки

Злоумышленник может отправлять тысячи запросов на покупку в секунду.
Защита: Rate-limiting на уровне сервера — например, разрешать не более 3 запросов в секунду на игрока, с использованием Debounce.


4.6. Тестирование и отладка

  • Используйте TestService для эмуляции покупок в Studio без траты Robux.
  • Создайте Debug-режим: при game.PlaceId == 0 (локальный запуск) разрешать выдачу валюты по нажатию клавиши.
  • Логируйте все транзакции в print() или через HttpService в внешний сервис (например, Discord-вебхук для разработчиков).
  • Пишите unit-тесты для commitPurchase, isTransactionProcessed и других критичных функций — с использованием mock-объектов.

4.7. Расширяемость и поддержка

Хорошая система магазина должна позволять:

  • Добавлять новые валюты без изменения ядра.
  • Подключать модули выдачи (например, ToolSystem, AppearanceSystem) через интерфейсы.
  • Поддерживать A/B-тестирование цен (через конфигурационный ModuleScript, переключаемый по флагу).

Рекомендуется выделить следующие модули:

  • CurrencyManager — управление балансами.
  • InventorySystem — хранение и синхронизация инвентаря.
  • TransactionProcessor — валидация и выполнение покупок.
  • CatalogService — загрузка и кэширование каталога.
  • DataStoreAdapter — абстракция над DataStoreService.

Это позволяет заменять компоненты (например, перейти с GlobalDataStore на OrderedDataStore для лидербордов) без переписывания всей логики.


🛠️ Гайд: Создание собственной системы магазина и монет в Roblox Studio

Цель: построить систему, в которой игрок может:

  • зарабатывать внутриигровые монеты (Coins);
  • тратить их на предметы в магазине;
  • покупать монеты за Robux через Developer Product;
  • сохранять прогресс между сессиями;
  • быть защищён от подделки транзакций.

Ограничения:

  • Никакой логики на клиенте, кроме отображения и отправки запросов.
  • Все данные — через DataStore.
  • Поддержка идемпотентности и предотвращение race condition.

Шаг 1. Подготовка проекта: структура

Создайте следующую иерархию в Explorer:

ReplicatedStorage/
├── Catalog/
│ └── Items.lua -- каталог товаров (ModuleScript)
├── Services/
│ ├── CurrencyManager.lua
│ ├── InventorySystem.lua
│ ├── TransactionProcessor.lua
│ └── DataStoreAdapter.lua
├── RemoteEvents/
│ ├── BuyItem.lua -- RemoteEvent
│ └── RequestBalance.lua -- RemoteEvent
ServerScriptService/
├── MainEconomySystem.lua -- точка входа
StarterGui/
└── ShopGUI/
└── ShopFrame.lua -- ScreenGui с LocalScript внутри

💡 Почему так?
Разделение по папкам обеспечивает читаемость и предотвращает "скриптовый хаос". Серверные сервисы — в ReplicatedStorage/Services, клиентская логика — в StarterGui, события — отдельно. Это соответствует best practices Roblox и позволяет легко находить компоненты.


Шаг 2. Создание каталога товаров

ReplicatedStorage/Catalog/Items.lua (ModuleScript)

-- Описание всех предметов в магазине.
-- Не содержит состояния игрока — только "что можно купить и за сколько".

return {
Coins_1000 = {
Id = "Coins_1000",
DisplayName = "1000 монет",
Description = "Пополните запасы внутриигровой валюты",
Price = { RobuxProduct = "prod_coins_1k" }, -- имя, не ID!
Type = "CurrencyPack",
Metadata = { Amount = 1000 }
},

Sword_Fire = {
Id = "Sword_Fire",
DisplayName = "Огненный меч",
Description = "Наносит +5 урона и поджигает врагов",
Price = { Coins = 500 },
Type = "Tool",
Metadata = {
ToolAssetId = 123456789, -- rbxassetid ссылка на Tool в Toolbox или Inventory
DamageBonus = 5,
HasFireEffect = true
}
},

Skin_RedArmor = {
Id = "Skin_RedArmor",
DisplayName = "Красные доспехи",
Description = "Эксклюзивный внешний вид",
Price = { Coins = 1200 },
Type = "Appearance",
Metadata = {
ShirtTemplate = "rbxassetid://987000001",
PantsTemplate = "rbxassetid://987000002"
}
}
}

💡 Важно:

  • Price — таблица, чтобы в будущем можно было добавить Gems = 10 без изменений в логике.
  • RobuxProduct — символьное имя, а не числовой ID. Это позволяет избежать ошибок при переносе проекта (ID меняются между местами).
  • Metadata строго типизирован: система выдачи будет проверять наличие полей, а не их значения.

Шаг 3. Реализация адаптера к DataStore

ReplicatedStorage/Services/DataStoreAdapter.lua (ModuleScript)

local DataStoreService = game:GetService("DataStoreService")

-- Используем изолированное имя, чтобы не конфликтовать с другими играми
local STORE_NAME = "EconomyData_v2"
local RETRY_DELAY = 3
local MAX_RETRIES = 3

local DataStoreAdapter = {}

-- Загрузка данных игрока
function DataStoreAdapter.LoadAsync(player)
local userId = player.UserId
local success, data = pcall(function()
return DataStoreService:GetDataStore(STORE_NAME):GetAsync("Player_" .. userId)
end)

if not success then
warn("DataStore load failed for", player.Name, "-", data)
return nil
end

-- Если данных нет — создаём шаблон
if not data then
data = {
Coins = 0,
Inventory = {},
PurchaseHistory = {}
}
end

return data
end

-- Сохранение с повторными попытками
function DataStoreAdapter.SaveAsync(player, data)
local userId = player.UserId
local key = "Player_" .. userId

for i = 1, MAX_RETRIES do
local success, err = pcall(function()
DataStoreService:GetDataStore(STORE_NAME):SetAsync(key, data)
end)

if success then
return true
elseif i == MAX_RETRIES then
warn("DataStore save failed after retries for", player.Name, "-", err)
return false
else
task.wait(RETRY_DELAY)
end
end
end

return DataStoreAdapter

💡 Почему SetAsync, а не UpdateAsync?
Для простоты этого гайда используется SetAsync. В продакшене обязательно перейдите на UpdateAsync, чтобы предотвратить перезапись при одновременных запросах. Но SetAsync проще для первого шага и демонстрирует базовую идею.


Шаг 4. Менеджер валюты и инвентаря

ReplicatedStorage/Services/CurrencyManager.lua

local DataStoreAdapter = require(script.Parent.DataStoreAdapter)

local CurrencyManager = {}

function CurrencyManager.GetBalance(player)
local data = CurrencyManager._GetPlayerData(player)
return data and data.Coins or 0
end

function CurrencyManager.AddCoins(player, amount)
if amount <= 0 then return false end

local data = CurrencyManager._GetPlayerData(player)
if not data then return false end

data.Coins = (data.Coins or 0) + amount
return CurrencyManager._SavePlayerData(player, data)
end

function CurrencyManager.SpendCoins(player, amount)
if amount <= 0 then return false end

local data = CurrencyManager._GetPlayerData(player)
if not data or (data.Coins or 0) < amount then
return false
end

data.Coins = data.Coins - amount
return CurrencyManager._SavePlayerData(player, data)
end

-- Внутренние методы (не экспортируются)
function CurrencyManager._GetPlayerData(player)
if not player:FindFirstChild("_EconomyData") then
local data = DataStoreAdapter.LoadAsync(player)
if data then
local folder = Instance.new("Folder")
folder.Name = "_EconomyData"
folder.Parent = player

-- Сохраняем в память игрока для быстрого доступа
folder:SetAttribute("Data", data)
end
end

return player._EconomyData and player._EconomyData:GetAttribute("Data")
end

function CurrencyManager._SavePlayerData(player, data)
player._EconomyData:SetAttribute("Data", data)
return DataStoreAdapter.SaveAsync(player, data)
end

return CurrencyManager

ReplicatedStorage/Services/InventorySystem.lua

local InventorySystem = {}

function InventorySystem.UnlockItem(player, itemId)
local data = require(script.Parent.CurrencyManager)._GetPlayerData(player)
if not data then return false end

if not data.Inventory then data.Inventory = {} end
data.Inventory[itemId] = data.Inventory[itemId] or {}
data.Inventory[itemId].Unlocked = true

return require(script.Parent.CurrencyManager)._SavePlayerData(player, data)
end

function InventorySystem.IsItemUnlocked(player, itemId)
local data = require(script.Parent.CurrencyManager)._GetPlayerData(player)
return data and data.Inventory and data.Inventory[itemId] and data.Inventory[itemId].Unlocked
end

return InventorySystem

💡 Замечание:
Оба модуля используют кэширование в _EconomyData, чтобы избежать частых вызовов DataStore. Это повышает производительность. Синхронизация с DataStore происходит только при изменении.


Шаг 5. Обработка транзакций

ReplicatedStorage/Services/TransactionProcessor.lua

local CurrencyManager = require(script.Parent.CurrencyManager)
local InventorySystem = require(script.Parent.InventorySystem)
local Catalog = require(script.Parent.Parent.Catalog.Items)

local TransactionProcessor = {}

-- Проверка уникальности транзакции
local function isDuplicate(player, transactionId)
local data = CurrencyManager._GetPlayerData(player)
if not data or not data.PurchaseHistory then return false end

for _, record in ipairs(data.PurchaseHistory) do
if record.TransactionId == transactionId then
return true
end
end
return false
end

-- Добавление записи в историю
local function recordTransaction(player, transactionId, itemId)
local data = CurrencyManager._GetPlayerData(player)
if not data.PurchaseHistory then data.PurchaseHistory = {} end

table.insert(data.PurchaseHistory, {
TransactionId = transactionId,
ItemId = itemId,
Timestamp = os.time()
})
end

-- Основная функция покупки
function TransactionProcessor.ProcessPurchase(player, itemId, transactionId)
if not player or not itemId or not transactionId then return false end

-- 1. Проверка дубликата
if isDuplicate(player, transactionId) then
warn("Duplicate transaction:", transactionId, "from", player.Name)
return false
end

-- 2. Получение описания товара
local item = Catalog[itemId]
if not item then
warn("Unknown item ID:", itemId)
return false
end

-- 3. Определение типа оплаты и валидация
local priceData = item.Price
local canAfford = false

if priceData.Coins then
-- Покупка за монеты
canAfford = CurrencyManager.GetBalance(player) >= priceData.Coins
elseif priceData.RobuxProduct then
-- Robux-покупка уже подтверждена Roblox — проверка не нужна
canAfford = true
else
warn("Item", itemId, "has no valid price")
return false
end

if not canAfford then return false end

-- 4. Выполнение операций
local success = false

-- Атомарное выполнение: всё или ничего
if priceData.Coins then
if not CurrencyManager.SpendCoins(player, priceData.Coins) then
return false
end
end

-- Выдача предмета по типу
if item.Type == "CurrencyPack" then
local amount = item.Metadata.Amount
success = CurrencyManager.AddCoins(player, amount)
elseif item.Type == "Tool" then
success = InventorySystem.UnlockItem(player, itemId)
elseif item.Type == "Appearance" then
success = InventorySystem.UnlockItem(player, itemId)
else
warn("Unsupported item type:", item.Type)
return false
end

if not success then return false end

-- 5. Запись в историю и сохранение
recordTransaction(player, transactionId, itemId)
return CurrencyManager._SavePlayerData(player, CurrencyManager._GetPlayerData(player))
end

return TransactionProcessor

💡 Ключевой момент:
Здесь нет RemoteEvent. Это чистая бизнес-логика, которую можно тестировать изолированно. Интеграция с событиями — на следующем шаге.


Шаг 6. Серверная точка входа

ServerScriptService/MainEconomySystem.lua

-- Создаём RemoteEvents, если их нет
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RemoteEvents = ReplicatedStorage:FindFirstChild("RemoteEvents")
if not RemoteEvents then
RemoteEvents = Instance.new("Folder")
RemoteEvents.Name = "RemoteEvents"
RemoteEvents.Parent = ReplicatedStorage
end

local BuyItem = ReplicatedStorage.RemoteEvents:FindFirstChild("BuyItem")
if not BuyItem then
BuyItem = Instance.new("RemoteEvent")
BuyItem.Name = "BuyItem"
BuyItem.Parent = ReplicatedStorage.RemoteEvents
end

local RequestBalance = ReplicatedStorage.RemoteEvents:FindFirstChild("RequestBalance")
if not RequestBalance then
RequestBalance = Instance.new("RemoteEvent")
RequestBalance.Name = "RequestBalance"
RequestBalance.Parent = ReplicatedStorage.RemoteEvents
end

-- Импорт сервисов
local TransactionProcessor = require(ReplicatedStorage.Services.TransactionProcessor)
local CurrencyManager = require(ReplicatedStorage.Services.CurrencyManager)

-- Обработка покупки
BuyItem.OnServerEvent:Connect(function(player, itemId, transactionId)
if not player:IsA("Player") then return end

local success = TransactionProcessor.ProcessPurchase(player, itemId, transactionId)
if success then
-- Уведомляем клиента об успехе и отправляем новый баланс
BuyItem:FireClient(player, "Success", { ItemId = itemId })
task.delay(0.1, function()
if player and player.Parent then
RequestBalance:FireClient(player, "Update", { Balance = CurrencyManager.GetBalance(player) })
end
end)
else
BuyItem:FireClient(player, "Failed", { Reason = "InvalidRequest" })
end
end)

-- Запрос баланса
RequestBalance.OnServerEvent:Connect(function(player)
if player and player:IsA("Player") then
local balance = CurrencyManager.GetBalance(player)
RequestBalance:FireClient(player, "Response", { Balance = balance })
end
end)

-- Поддержка Developer Products
local MarketplaceService = game:GetService("MarketplaceService")

-- Карта: имя продукта → внутренний ID
local PRODUCT_MAP = {
prod_coins_1k = "Coins_1000"
-- Добавьте другие по мере регистрации в DevHub
}

MarketplaceService.PromptPurchaseFinished:Connect(function(player, productId, purchased)
if not purchased or not player:IsA("Player") then return end

-- Получаем имя продукта по ID (в реальном проекте храните маппинг в ModuleScript)
local productName = nil
for name, id in pairs(script.Parent.Parent.DevProductMapping:GetAttribute("Products") or {}) do
if id == productId then
productName = name
break
end
end

if not productName then
warn("Unknown product ID:", productId)
return
end

local internalId = PRODUCT_MAP[productName]
if not internalId then
warn("No internal mapping for product:", productName)
return
end

local transactionId = "robux_" .. productName .. "_" .. os.time()
TransactionProcessor.ProcessPurchase(player, internalId, transactionId)
end)

-- Необязательно: выдача монет при входе (для теста)
game.Players.PlayerAdded:Connect(function(player)
task.delay(2, function()
if player and player.Parent then
CurrencyManager.AddCoins(player, 100) -- стартовый бонус
end
end)
end)

💡 Про Developer Products:
Чтобы это работало, нужно:

  1. Зарегистрировать Developer Product в DevHub.
  2. Запомнить его Product ID (число).
  3. Создать Script в ServerScriptService с именем DevProductMapping, содержащий:
    script:SetAttribute("Products", {
    prod_coins_1k = 123456789 -- замените на реальный ID
    })
    Это позволяет не хардкодить ID в основном коде.

Шаг 7. Клиентская часть: интерфейс магазина

StarterGui/ShopGUI/ShopFrame.lua (ScreenGui → Frame → LocalScript)

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local MarketplaceService = game:GetService("MarketplaceService")

local player = Players.LocalPlayer
local BuyEvent = ReplicatedStorage.RemoteEvents.BuyItem
local BalanceEvent = ReplicatedStorage.RemoteEvents.RequestBalance

-- Получаем элементы GUI (предполагаем, что они уже созданы в дизайнере)
local balanceLabel = script.Parent:WaitForChild("BalanceLabel")
local swordButton = script.Parent:WaitForChild("SwordButton")
local coinsButton = script.Parent:WaitForChild("CoinsButton")

-- Запрашиваем баланс при открытии
BalanceEvent:FireServer()
BalanceEvent.OnClientEvent:Connect(function(action, data)
if action == "Response" or action == "Update" then
balanceLabel.Text = "Монет: " .. (data.Balance or 0)
end
end)

-- Покупка меча за монеты
swordButton.MouseButton1Click:Connect(function()
local txId = "tx_" .. tick() .. "_" .. math.random(1000, 9999)
BuyEvent:FireServer("Sword_Fire", txId)
end)

-- Покупка монет за Robux
coinsButton.MouseButton1Click:Connect(function()
-- Получаем ID продукта по имени (из серверного маппинга)
local success, mapping = pcall(function()
return ReplicatedStorage:WaitForChild("DevProductMapping"):GetAttribute("Products")
end)

if success and mapping and mapping.prod_coins_1k then
MarketplaceService:PromptPurchase(player, mapping.prod_coins_1k)
else
warn("Developer product not configured")
end
end)

-- Обработка ответа от сервера
BuyEvent.OnClientEvent:Connect(function(status, data)
if status == "Success" then
print("Успешно куплено:", data.ItemId)
-- Можно показать эффект, звук, обновить инвентарь
elseif status == "Failed" then
if data.Reason == "InsufficientFunds" then
warn("Недостаточно монет")
else
warn("Покупка не удалась")
end
end
end)

💡 Важно для клиента:

  • Никакого подсчёта баланса — только отображение.
  • Никакой проверки «хватит ли денег» перед отправкой — это делает сервер.
  • tick() + math.random даёт уникальный transactionId без коллизий.

Шаг 8. Тестирование в Studio

  1. Запустите игру локально (Play в Studio).
  2. Откройте магазин — должен отобразиться баланс 100 (стартовый бонус).
  3. Нажмите «Купить меч» — баланс уменьшится на 500, предмет разблокируется.
  4. Перезайдите — баланс и инвентарь сохранятся (Roblox эмулирует DataStore локально).
  5. Для теста Robux-покупок:
    • В Studio: Test > Test Purchases.
    • Добавьте Developer Product (введите ID и название).
    • Нажмите «Купить монеты» — появится окно подтверждения.
    • После подтверждения — 1000 монет добавятся.

✅ Если всё работает — система готова к деплою.


Что делать дальше (продвинутые шаги)

ЗадачаКак реализовать
Поддержка нескольких валютРасширить Price = { Coins = 100, Gems = 5 }, изменить CurrencyManager на работу с таблицей балансов ({ Coins = 500, Gems = 20 }).
Лидерборды по богатствуИспользовать OrderedDataStore для хранения топ-100.
Возвраты (refunds)Обрабатывать MarketplaceService.RefundOccurred.
A/B-тестирование ценХранить цены в Configuration-модуле, управляемом через Game.Settings или внешний JSON.
Модульные эффекты (например, надеть скин)Создать AppearanceService, который слушает InventorySystem.ItemUnlocked через BindableEvent.