Studio

Первый магазин в своей игре: окно, кнопка и проверка на сервере

Как собрать внутриигровой магазин так, чтобы его нельзя было обнести одной строчкой из консоли: где рисовать окно, что отправлять на сервер и почему цену нельзя брать из кнопки.

Сложно 25 минут проверен 41 день назад

Что понадобится

Опубликованный проект в Studio и основы Luau. Гайд «Клиент и сервер в Roblox» лучше прочитать до этого — здесь всё держится на этом разделении.

Текст гайда «Первый магазин в своей игре: окно, кнопка и проверка на сервере»

Содержание

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

Одно правило, из которого следует весь остальной гайд

Клиент имеет право только на одно сообщение: «игрок нажал купить вот этот товар». Есть ли у него деньги, сколько стоит товар, существует ли товар вообще и что игрок получит — решает сервер. Всё, что решает клиент, игрок может решить за него.

Что где лежит#

Разложить файлы стоит до того, как написана первая строка. Дальше по гайду мы будем ссылаться именно на эти места.

Раскладка магазина по сервисам
ЧтоГде лежитКто видит
Список товаров (ShopItems)ServerStorageТолько сервер
Скрипт магазинаServerScriptServiceТолько сервер
RemoteFunction для покупкиReplicatedStorageОбе стороны
Окно магазина (ScreenGui)StarterGuiИгрок
Скрипт окна (LocalScript)Внутри окнаИгрок

Почему список товаров не в ReplicatedStorage

Всё, что лежит в ReplicatedStorage, игрок может прочитать целиком. Список товаров там — это не дыра сама по себе, но соблазн: рано или поздно кто-то возьмёт цену из этого списка на клиенте и отправит её на сервер. В ServerStorage такой возможности просто нет.

Шаг 1. Список товаров#

Товар — это данные, а не кнопка. Заводим модуль в ServerStorage: одно место, где перечислено всё, что продаётся. Ключ — короткий идентификатор, по которому клиент и будет просить покупку.

ServerStorage → ModuleScript «ShopItems» · Luau
-- Единственный список товаров в игре. Клиент его не видит.
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. Сервер, который никому не верит#

Вот здесь и находится весь магазин. Всё остальное — оформление. Скрипт получает от клиента одну строку — идентификатор товара — и дальше не верит ничему, включая её.

ServerScriptService → Script «Shop» · Luau
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. Окно и кнопка#

Теперь можно рисовать. Задача клиента скромная: показать список, отправить идентификатор и показать ответ сервера. Ни одной проверки денег здесь нет — не потому что лень, а потому что проверка на клиенте ничего не значит.

LocalScript внутри окна магазина · Luau
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 и возвраты» объясняет, что игрок вправе вернуть, а что нет, — и после этого понятнее, почему выдачу нельзя оставлять на клиенте. А если магазином будет управлять кто-то кроме тебя, посмотри «Команды администратора»: права выдаются по тому же правилу — сервер решает, клиент просит.

Реклама · игра в Telegram Moki Blast — шарики, боссы и дуэли в Telegram Надуй щёки Моки и собери три шара одного цвета Шесть миров: от руин до Золотого города Король обезьян ждёт на 120-м уровне Дуэли с живыми игроками и турниры Играть в Telegram

Обсуждение

Пиши по делу и без личных данных. Ссылки на сторонние сайты, обмен и «бесплатные робуксы» мы скрываем — почему.

Комментарии пишут те, кто вошёл — так спокойнее всем.

Войти

Пока никто ничего не написал. Будь первым.