Studio

DataStore: как сохранить прогресс игрока и не потерять его

Монеты, уровни и открытые предметы должны пережить выход из игры. Разбираем DataStoreService по шагам: чтение, запись, ошибки сети, закрытие сервера и типичные способы потерять данные всех игроков разом.

Сложно 30 минут проверен 1 час назад

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

Roblox Studio, опубликованное место и базовое понимание Luau: переменные, функции, таблицы. Если этого нет — начни с гайда «Luau с нуля».

Текст гайда «DataStore: как сохранить прогресс игрока и не потерять его»

Содержание

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

Сначала включи доступ к API

В Studio открой «File» → «Game Settings» → «Security» и включи «Enable Studio Access to API Services». Без этого любой вызов DataStore в Studio падает с ошибкой доступа, хотя в опубликованной игре тот же код работает. Место при этом должно быть уже опубликовано: у неопубликованного места нет игры, к которой привязать хранилище.

Что можно класть в хранилище#

DataStore принимает то, что превращается в JSON: числа, строки, булевы значения и таблицы из них. Всё остальное вызовет ошибку прямо при записи.

  • Нельзя сохранять Instance, Vector3, CFrame, Color3 и другие типы Roblox — разбирай их на числа
  • Нельзя смешивать в одной таблице числовые и строковые ключи
  • Нельзя сохранять функции и таблицы с циклическими ссылками
  • Нельзя записывать nan и бесконечность: они появляются при делении на ноль и ломают запись молча
  • Один ключ вмещает около четырёх мегабайт — это очень много для чисел, но мало для истории каждого действия

Скелет: одна таблица на игрока#

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

ServerScriptService → Script · Luau
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

-- Версия прямо в имени: понадобится сменить формат — заведёшь v2,
-- а старые данные останутся целыми.
local store = DataStoreService:GetDataStore("PlayerData_v1")

-- Значения по умолчанию для нового игрока.
local DEFAULT = {
    coins = 0,
    level = 1,
    unlocked = {},
}

-- Живые данные тех, кто сейчас на сервере.
local cache = {}

local function keyFor(player)
    return "user_" .. player.UserId
end

Ключ строится из UserId, а не из имени. Имя игрок может сменить, а числовой идентификатор — нет: сохранённый по имени прогресс теряется в тот же день, когда человек переименовался.

Чтение при входе#

Любой вызов к хранилищу — это сеть, а сеть падает. Поэтому GetAsync всегда оборачивается в pcall: без него одна неудачная попытка уронит весь скрипт, и следующие игроки останутся без данных вовсе.

Загрузка с повторными попытками · Luau
local function loadData(player)
    local key = keyFor(player)

    for attempt = 1, 3 do
        local ok, result = pcall(function()
            return store:GetAsync(key)
        end)

        if ok then
            -- Пустой результат — это новый игрок, а не ошибка.
            return result or table.clone(DEFAULT)
        end

        warn(("Чтение %s не удалось (попытка %d): %s"):format(key, attempt, tostring(result)))
        task.wait(attempt * 2)
    end

    return nil
end

Players.PlayerAdded:Connect(function(player)
    local data = loadData(player)

    if data == nil then
        -- Данные не прочитались. Ставить умолчания опасно: игрок
        -- увидит ноль монет, поиграет, и этот ноль запишется поверх.
        player:Kick("Не удалось загрузить прогресс. Зайди через минуту.")
        return
    end

    cache[player] = data
end)

Главный способ потерять чужой прогресс

Он выглядит безобидно: «не прочиталось — дадим значения по умолчанию». Игрок с тысячей часов заходит, видит пустой аккаунт, играет пять минут, скрипт сохраняет — и старая запись затёрта навсегда. Правило простое: не прочитал — не пиши. Лучше выкинуть игрока с понятным сообщением, чем стереть его прогресс.

Запись при выходе#

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

Сохранение через UpdateAsync · Luau
local function saveData(player)
    local data = cache[player]

    if data == nil then
        return -- нечего сохранять: загрузка не удалась
    end

    local key = keyFor(player)

    local ok, err = pcall(function()
        store:UpdateAsync(key, function(old)
            -- old — то, что лежит в хранилище прямо сейчас.
            -- Здесь можно сверить версии или взять максимум.
            return data
        end)
    end)

    if not ok then
        warn(("Запись %s не удалась: %s"):format(key, tostring(err)))
    end
end

Players.PlayerRemoving:Connect(function(player)
    saveData(player)
    cache[player] = nil
end)

Закрытие сервера: BindToClose#

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

Дописать всех перед выключением · Luau
game:BindToClose(function()
    if game:GetService("RunService"):IsStudio() then
        return -- в Studio это только мешает тестировать
    end

    for _, player in Players:GetPlayers() do
        task.spawn(saveData, player)
    end

    -- Небольшая пауза, чтобы запросы успели уйти.
    task.wait(3)
end)

Автосохранение и лимиты#

Сервер может упасть без предупреждения, поэтому одного сохранения на выходе мало. Разумная середина — писать раз в минуту-две и только тех, у кого данные изменились. Частота ограничена: у каждой игры есть бюджет запросов в минуту, который зависит от числа игроков на сервере, и при его превышении вызовы начинают ждать в очереди.

Простой цикл автосохранения · Luau
task.spawn(function()
    while true do
        task.wait(90)

        for _, player in Players:GetPlayers() do
            saveData(player)
            task.wait(1) -- растягиваем запросы, а не бьём пачкой
        end
    end
end)

Сохраняй по факту изменения

Заведи в таблице игрока флаг «изменилось» и ставь его там, где меняются монеты или уровень. Тогда автосохранение пропустит тех, кто просто стоит на месте, и бюджет запросов уйдёт на тех, у кого действительно что-то произошло.

Что ещё стоит знать#

  • Данные из GetAsync могут быть слегка устаревшими: хранилище отвечает быстро, но не мгновенно согласованно
  • Один игрок может открыть игру на двух серверах — без блокировки сессии его прогресс будут писать оба, и победит последний
  • Меняешь формат данных — заводи новую версию хранилища и переноси старое поле при первом чтении, а не переписывай на месте
  • OrderedDataStore нужен для таблиц лидеров: он умеет сортировать, но хранит только числа
  • Логируй неудачные записи через warn: без этого потери данных обнаруживаются по жалобам игроков
  • Проверять всё это надо в опубликованной игре, а не только в Studio — там другие ограничения

Когда прогресс начал сохраняться, следующий вопрос — как игрок его тратит и кто эту трату разрешает. Отвечать на это должен сервер: «Клиент и сервер: RemoteEvent без дыр». А что происходит с цифрами игры после публикации, разбирает гайд про статистику. Курс по языку и разбор ошибок собраны в Studio-разделе портала.

Обсуждение

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

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

Войти

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