Внутриигровая экономика Roblox
Продолжение курса Разработка на Roblox: здесь — транзакции, проектирование магазина и полный практический гайд. Базовая архитектура клиент–сервер и RemoteEvent разобраны в основной статье.
Транзакции и сервер
Что такое транзакция?
Транзакция — операция, которая должна быть выполнена целиком или не выполнена вовсе.
Пример: «списать 100 монет и выдать меч».
Если списание прошло, а выдача — нет → игрок потерял деньги → недоверие.
Почему сервер — источник истины?
| Операция | Клиентская проверка | Серверная проверка |
|---|---|---|
| Проверка баланса | if balance >= 100 | CurrencyManager:GetBalance(player) >= 100 |
| Списание | balance -= 100 | CurrencyManager: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 сервер не должен выполнять операцию дважды. Это защищает от сетевых дублей и намеренных атак. -
Отказоустойчивость при сохранении.
Операция «списать валюту и выдать предмет» должна быть атомарной: либо оба действия выполнены, либо ни одно. Для этого применяется паттерн двухфазного сохранения:- Сервер временно блокирует баланс игрока в памяти.
- Выполняет выдачу предмета (например, добавляет в
PlayerData.Inventory). - Выполняет сохранение в
DataStore. - При успехе — фиксирует списание; при ошибке — откатывает изменения.
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— для синхронных запросов («можно ли купить?», «получить текущий баланс?»).
Пример: запрос на покупку
-
Клиент (
LocalScriptв GUI):local BuyEvent = game:GetService("ReplicatedStorage"):WaitForChild("RemoteEvents"):WaitForChild("BuyItem")BuyEvent:FireServer("Sword_001", "tx_" .. tick()) -
Сервер (
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) thenwarn("Duplicate transaction:", transactionId)returnendlocal catalog = require(game.ReplicatedStorage.Catalog.Items)local item = catalog[itemId]if not item then return endlocal playerData = getPlayerData(player) -- загрузка из DataStore или кэшаif not playerData then return end-- Валидация: достаточно ли валюты?local cost = item.Price.Coinsif cost and playerData.Coins < cost then-- Отправить отказ клиентуfireClientEvent(player, "PurchaseFailed", { Reason = "InsufficientFunds" })returnend-- Атомарное выполнениеif commitPurchase(player, playerData, item, transactionId) thenfireClientEvent(player, "PurchaseSuccess", { ItemId = itemId })elsefireClientEvent(player, "PurchaseFailed", { Reason = "ServerError" })endend)
Ключевые моменты:
isTransactionProcessedпроверяетPurchaseHistory.commitPurchaseвыполняет: списание валюты, добавление в инвентарь, сохранение в DataStore, запись в историю — и только при полном успехе возвращаетtrue.- Клиент получает только событие — без деталей баланса (во избежание утечки информации).
4.4. Интеграция с Robux (Developer Products)
Для продажи через Robux используется MarketplaceService. Важно: никогда не доверяйте клиенту информацию о покупке.
Правильная последовательность:
- Клиент вызывает
MarketplaceService:PromptPurchase(player, productId). - Игрок подтверждает покупку (всплывающее окно от Roblox).
- Roblox отправляет событие
PromptPurchaseFinishedна сервер. - Сервер проверяет:
- Принадлежит ли
productIdожидаемому товару? - Не была ли покупка уже обработана?
- Корректен ли
player?
- Принадлежит ли
- При успехе — выдаёт предмет, сохраняет.
Пример обработчика на сервере:
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. Потеря данных при ошибке сохранения
Если после списания валюты произошла ошибка сохранения, игрок потеряет деньги.
Защита: Паттерн «сначала сохранить, потом применить»:
- Создать копию
playerData. - Внести изменения в копию.
- Попытаться сохранить копию через
UpdateAsync. - При успехе — применить изменения в текущее состояние игрока.
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:
Чтобы это работало, нужно:
- Зарегистрировать Developer Product в DevHub.
- Запомнить его Product ID (число).
- Создать
ScriptвServerScriptServiceс именемDevProductMapping, содержащий:Это позволяет не хардкодить ID в основном коде.script:SetAttribute("Products", {prod_coins_1k = 123456789 -- замените на реальный 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
- Запустите игру локально (
Playв Studio). - Откройте магазин — должен отобразиться баланс
100(стартовый бонус). - Нажмите «Купить меч» — баланс уменьшится на 500, предмет разблокируется.
- Перезайдите — баланс и инвентарь сохранятся (Roblox эмулирует DataStore локально).
- Для теста Robux-покупок:
- В Studio:
Test > Test Purchases. - Добавьте Developer Product (введите ID и название).
- Нажмите «Купить монеты» — появится окно подтверждения.
- После подтверждения — 1000 монет добавятся.
- В Studio:
✅ Если всё работает — система готова к деплою.
Что делать дальше (продвинутые шаги)
| Задача | Как реализовать |
|---|---|
| Поддержка нескольких валют | Расширить 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. |
См. также
Другие статьи этого же раздела в боковом меню (как на странице «О разделе»). Разработка игр — это процесс создания видеоигр, который включает в себя множество этапов, от идеи и концепции до финального продукта. Это междисциплинарная деятельность, объединяющая… В игровой индустрии художественная работа — это техническая реализация визуальной стратегии. Каждое изображение, модель, текстура или анимация создаётся с учётом ограничений движка,… Если команда разработки — это оркестр, то продюсер или проджект-менеджер — дирижёр. Его задача — обеспечить, чтобы все участники работали в согласованном ритме, в рамках установленного бюджета и… Игровой движок как платформа - подсистемы рендеринга, физики, ввода и сценариев, ускоряющие создание видеоигр. Roblox Studio не является традиционным игровым движком, но представляет собой платформу как услугу (PaaS) с ограниченной, но эффективной средой разработки. Языки для игр на примере Unity - роль C#, стандартная библиотека, сборка мусора и продуктивность разработки под движок. Моделирование — это процесс создания трёхмерных объектов, называемых моделями, для последующего использования в цифровых средах, особенно в видеоиграх. Эти объекты могут представлять собой… Первый и фундаментальный этап — создание UV-развёртки. UV-развёртка представляет собой двумерное представление поверхности трёхмерной модели. Буквы U и V обозначают координаты на плоскости текстуры,… Для текста используется TextMeshPro — современный компонент с поддержкой шрифтов, стилей и локализации. Счёт, здоровье, уровень — всё это обновляется динамически через присвоение строки — Для работы с графикой в играх под Windows используются два основных API — DirectX и Vulkan. DirectX — это набор технологий, разработанных Microsoft специально для Windows и Xbox. Он включает в себя… Для продвижения игр используется сервис PlayStation Plus. Через него разработчики могут предложить свою игру миллионам подписчиков в рамках программ Игры месяца или Новые релизы. Участие в таких… Nintendo - консоли, аксессуары вроде Pro Controller и устройство игровой экосистемы компании.Процесс разработки видеоигр
Дорожная карта геймдева
Команда разработки
Игровой движок
Виды игровых движков
Языки программирования игр
Моделирование
Текстуры
Гейм-дизайн
PC
PlayStation
Nintendo