Первый магазин в своей игре: окно, кнопка и проверка на сервере
Как собрать внутриигровой магазин так, чтобы его нельзя было обнести одной строчкой из консоли: где рисовать окно, что отправлять на сервер и почему цену нельзя брать из кнопки.
Сложно 25 минут проверен 41 день назад
Что понадобится
Опубликованный проект в Studio и основы Luau. Гайд «Клиент и сервер в Roblox» лучше прочитать до этого — здесь всё держится на этом разделении.
Текст гайда «Первый магазин в своей игре: окно, кнопка и проверка на сервере»
Содержание
Магазин за внутриигровую валюту — почти всегда первая серьёзная система, которую делает начинающий автор. И почти всегда первая, которую ломают. Причина одна и та же: магазин собирают как интерфейс, а не как правило. Кнопка знает цену, кнопка отнимает монеты, кнопка выдаёт предмет — и всё это на стороне игрока.
Одно правило, из которого следует весь остальной гайд
Клиент имеет право только на одно сообщение: «игрок нажал купить вот этот товар». Есть ли у него деньги, сколько стоит товар, существует ли товар вообще и что игрок получит — решает сервер. Всё, что решает клиент, игрок может решить за него.
Что где лежит#
Разложить файлы стоит до того, как написана первая строка. Дальше по гайду мы будем ссылаться именно на эти места.
| Что | Где лежит | Кто видит |
|---|---|---|
Список товаров (ShopItems) | ServerStorage | Только сервер |
| Скрипт магазина | ServerScriptService | Только сервер |
RemoteFunction для покупки | ReplicatedStorage | Обе стороны |
Окно магазина (ScreenGui) | StarterGui | Игрок |
Скрипт окна (LocalScript) | Внутри окна | Игрок |
Почему список товаров не в ReplicatedStorage
Всё, что лежит в ReplicatedStorage, игрок может прочитать целиком. Список товаров там — это не дыра сама по себе, но соблазн: рано или поздно кто-то возьмёт цену из этого списка на клиенте и отправит её на сервер. В ServerStorage такой возможности просто нет.
Шаг 1. Список товаров#
Товар — это данные, а не кнопка. Заводим модуль в ServerStorage: одно место, где перечислено всё, что продаётся. Ключ — короткий идентификатор, по которому клиент и будет просить покупку.
-- Единственный список товаров в игре. Клиент его не видит.
return {
speed_boots = {
name = "Быстрые ботинки",
price = 150,
grant = function(player)
local character = player.Character
if character then
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid then
humanoid.WalkSpeed = 24
end
end
end,
},
lantern = {
name = "Фонарь",
price = 80,
grant = function(player)
local tool = game.ServerStorage.Tools.Lantern:Clone()
tool.Parent = player.Backpack
end,
},
}Обрати внимание: у товара есть grant — функция выдачи. Она тоже живёт на сервере. Соблазн «клиент сам выдаст себе предмет, сервер только спишет монеты» выглядит удобно ровно до первого игрока, который выдаст себе всё и ничего не спишет.
Шаг 2. Один канал для покупки#
Кладём в ReplicatedStorage один RemoteFunction с именем BuyItem. Именно функция, а не событие: клиенту нужно узнать результат — купил или нет, и почему нет.
Один канал на весь магазин, а не по каналу на товар. Чем меньше точек входа, тем меньше мест, где можно забыть проверку.
Шаг 3. Сервер, который никому не верит#
Вот здесь и находится весь магазин. Всё остальное — оформление. Скрипт получает от клиента одну строку — идентификатор товара — и дальше не верит ничему, включая её.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local items = require(ServerStorage.ShopItems)
local buyItem = ReplicatedStorage:WaitForChild("BuyItem")
-- Кто уже в процессе покупки. Без этого один игрок
-- отправляет десять запросов подряд и покупает
-- на все деньги десять раз.
local busy = {}
local function coinsOf(player)
local stats = player:FindFirstChild("leaderstats")
return stats and stats:FindFirstChild("Coins") or nil
end
buyItem.OnServerInvoke = function(player, itemId)
-- 1. Клиент прислал не строку. Такое приходит только
-- от того, кто лезет руками.
if type(itemId) ~= "string" then
return false, "Неизвестный товар"
end
-- 2. Товара нет в списке. Название клиент придумать может,
-- товар — нет.
local item = items[itemId]
if item == nil then
return false, "Неизвестный товар"
end
-- 3. Покупка уже идёт.
if busy[player] then
return false, "Подожди секунду"
end
busy[player] = true
local coins = coinsOf(player)
if coins == nil then
busy[player] = nil
return false, "Кошелёк не найден"
end
-- 4. Цена берётся из списка на сервере, а не из запроса.
if coins.Value < item.price then
busy[player] = nil
return false, "Не хватает монет"
end
-- 5. Сначала списываем, потом выдаём. Не наоборот:
-- упавшая выдача не должна оставлять бесплатный предмет.
coins.Value -= item.price
local ok, err = pcall(item.grant, player)
if not ok then
-- Выдать не вышло — возвращаем деньги.
coins.Value += item.price
warn("Выдача " .. itemId .. " сорвалась: " .. tostring(err))
busy[player] = nil
return false, "Не получилось выдать товар"
end
busy[player] = nil
return true, item.name
end
game.Players.PlayerRemoving:Connect(function(player)
busy[player] = nil
end)Пять проверок подряд выглядят занудством, пока не посмотришь, что каждая ловит. Первая — мусор вместо идентификатора. Вторая — выдуманный товар. Третья — десять нажатий за секунду. Четвёртая — покупку без денег. Пятая — предмет, выданный при сорвавшемся списании.
Чего в этом коде нет намеренно
Сервер не принимает от клиента ни цену, ни количество, ни имя товара для показа, ни «скидку». Всё, что приходит снаружи, — одна строка, и та проверяется по списку. Если в твоём магазине сервер принимает цену — магазина у тебя нет, есть кнопка «дать бесплатно».
Шаг 4. Окно и кнопка#
Теперь можно рисовать. Задача клиента скромная: показать список, отправить идентификатор и показать ответ сервера. Ни одной проверки денег здесь нет — не потому что лень, а потому что проверка на клиенте ничего не значит.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local buyItem = ReplicatedStorage:WaitForChild("BuyItem")
local gui = script.Parent
local status = gui:WaitForChild("Status")
-- У каждой кнопки в окне лежит атрибут ItemId со строкой
-- вроде "lantern". Больше клиенту знать не нужно.
for _, button in gui.Items:GetChildren() do
if button:IsA("TextButton") then
local itemId = button:GetAttribute("ItemId")
button.Activated:Connect(function()
button.Active = false
status.Text = "Покупаем…"
local ok, result = pcall(function()
return buyItem:InvokeServer(itemId)
end)
if not ok then
status.Text = "Сервер не ответил"
elseif result then
status.Text = "Куплено"
else
-- Сервер вторым значением возвращает причину,
-- и её честно показываем игроку.
local _, reason = buyItem:InvokeServer(itemId)
status.Text = reason or "Не получилось"
end
task.wait(0.6)
button.Active = true
end)
end
endКнопку стоит гасить на время ответа
button.Active = false на время запроса — не украшение. Без него нетерпеливый игрок отправит пять запросов, пока идёт первый, и увидит пять сообщений об ошибке подряд. Сервер это переживёт (у него есть busy), а человек решит, что магазин сломан.
Шаг 5. Покупка должна пережить выход из игры#
Магазин, который работает только до выхода, — это не магазин. Купленное нужно сохранять, и сохранять на сервере: и монеты, и список того, что игрок уже приобрёл. Как это делается надёжно, с повторными попытками и без затирания чужого прогресса, разобрано в гайде «DataStore: сохранение прогресса».
Одна деталь именно про магазин: храни факт покупки, а не последствие. Записать «купил быстрые ботинки» правильнее, чем записать «скорость 24». Через месяц ты поменяешь значение скорости, и все, кто купил ботинки раньше, останутся со старым числом.
Как проверить, что магазин собран правильно#
- Запусти игру на двух игроков в Studio и купи один и тот же товар обоими — деньги должны списаться у каждого свои
- Нажми кнопку покупки десять раз подряд как можно быстрее — предмет должен выдаться один раз
- Проверь, что будет, если денег ровно на один меньше цены: должно быть внятное «не хватает», а не тишина
- Убери товар из списка на сервере и попробуй купить его старой кнопкой — должно вернуться «неизвестный товар»
- Выйди из игры и зайди снова: купленное должно остаться
Проверка «а если игрок захочет обмануть»
Посмотри на свой код и спроси: что будет, если клиент пришлёт сюда любое значение, какое захочет? Не «зачем ему это», а именно «что будет». Если ответ — «получит предмет бесплатно», значит проверка не там. Это же правило работает и для админ-команд, и для наград за задания, и вообще для всего, что приходит от игрока.
Дальше логично взять «Игровые пропуска и покупки» — там тот же принцип, но с настоящими робуксами и обработкой чека, где ошибка стоит уже реальных денег игрока. Полезно и посмотреть на всё это глазами покупателя: «Покупки в Roblox и возвраты» объясняет, что игрок вправе вернуть, а что нет, — и после этого понятнее, почему выдачу нельзя оставлять на клиенте. А если магазином будет управлять кто-то кроме тебя, посмотри «Команды администратора»: права выдаются по тому же правилу — сервер решает, клиент просит.

Обсуждение
Пиши по делу и без личных данных. Ссылки на сторонние сайты, обмен и «бесплатные робуксы» мы скрываем — почему.
Комментарии пишут те, кто вошёл — так спокойнее всем.
ВойтиПока никто ничего не написал. Будь первым.