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

Внутриигровая экономика Roblox

Roblox

Продолжение курса Разработка на Roblox: здесь — транзакции, проектирование магазина и полный практический гайд. Базовая архитектура клиент–сервер и RemoteEvent разобраны в основной статье.


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

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

Транзакция — операция, которая должна быть выполнена целиком или не выполнена вовсе.
Пример: «списать 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 сервер отклонит запрос как дубль — это и есть идемпотентность.


Система магазина и монет в 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, "-", Данные)
return nil
end

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

return Данные
end

-- Сохранение с повторными попытками
function DataStoreAdapter.SaveAsync(player, Данные)
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, Данные)
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 Данные 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 Данные then return false end

data.Coins = (data.Coins or 0) + amount
return CurrencyManager._SavePlayerData(player, Данные)
end

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

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

data.Coins = data.Coins - amount
return CurrencyManager._SavePlayerData(player, Данные)
end

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

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

return player._EconomyData and player._EconomyData:GetAttribute("Данные")
end

function CurrencyManager._SavePlayerData(player, Данные)
player._EconomyData:SetAttribute("Данные", Данные)
return DataStoreAdapter.SaveAsync(player, Данные)
end

return CurrencyManager

ReplicatedStorage/Services/InventorySystem.lua

local InventorySystem = {}

function InventorySystem.UnlockItem(player, itemId)
local data = require(script.Parent.CurrencyManager)._GetPlayerData(player)
if not Данные 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, Данные)
end

function InventorySystem.IsItemUnlocked(player, itemId)
local data = require(script.Parent.CurrencyManager)._GetPlayerData(player)
return Данные 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 Данные 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, Данные)
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, Данные)
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.

См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).