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 и бесконечность: они появляются при делении на ноль и ломают запись молча
- Один ключ вмещает около четырёх мегабайт — это очень много для чисел, но мало для истории каждого действия
Скелет: одна таблица на игрока#
Самая рабочая схема — одна запись на игрока с таблицей внутри. Не делай отдельное хранилище под монеты, отдельное под уровень и отдельное под предметы: каждое из них тратит лимит запросов, а читаются и пишутся они всегда вместе.
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: без него одна неудачная попытка уронит весь скрипт, и следующие игроки останутся без данных вовсе.
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 сначала читает текущее, отдаёт его тебе в функцию и записывает то, что ты вернул, — и делает это атомарно. Второй способ безопаснее: если тот же ключ в этот момент меняет другой сервер, ты увидишь его версию, а не затрёшь её.
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 даёт на завершение примерно тридцать секунд — этого хватает, чтобы дописать всех.
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)Автосохранение и лимиты#
Сервер может упасть без предупреждения, поэтому одного сохранения на выходе мало. Разумная середина — писать раз в минуту-две и только тех, у кого данные изменились. Частота ограничена: у каждой игры есть бюджет запросов в минуту, который зависит от числа игроков на сервере, и при его превышении вызовы начинают ждать в очереди.
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-разделе портала.
Обсуждение
Пиши по делу и без личных данных. Ссылки на сторонние сайты, обмен и «бесплатные робуксы» мы скрываем — почему.
Комментарии пишут те, кто вошёл — так спокойнее всем.
ВойтиПока никто ничего не написал. Будь первым.