Настройки игрока: как сохранить громкость и чувствительность между сессиями
Игрок убавил музыку и выключил тряску камеры — а после перезахода всё вернулось. Разбираем, как хранить настройки отдельно от прогресса, проверять их на сервере и применять на клиенте без задержек.
Средне 20 минут проверен 1 месяц назад
Что понадобится
Roblox Studio, опубликованное место и основы Luau. Пригодится гайд про DataStore — здесь та же схема, но короче.
Текст гайда «Настройки игрока: как сохранить громкость и чувствительность между сессиями»
Содержание
Экран настроек делают почти в каждой игре, и почти в каждой второй он не работает по-настоящему: ползунок двигается, музыка тише, а после следующего входа всё стоит на месте. Причина одна — настройки применили, но не сохранили. Или сохранили, но в переменной на клиенте, которая живёт ровно до закрытия окна.
Два действия, а не одно
Применить — это на клиенте и сразу: игрок двинул ползунок и должен услышать разницу в ту же секунду. Сохранить — это на сервере и не спеша: сервер проверит, что значение допустимо, и положит его в хранилище. Путать эти два шага и есть главная ошибка.
Шаг 1. Отдельное хранилище и список допустимого#
Настройки стоит держать в своём DataStore, а не в таблице прогресса. Прогресс меняется часто и от игры, настройки — редко и от игрока. Если однажды придётся обнулить прогресс — из-за бага или сезона, — громкость и раскладка у людей должны уцелеть.
Второе решение — описать заранее, какие настройки бывают и какие у них границы. Этот список живёт на сервере: он же будет и значением по умолчанию, и правилом проверки.
-- Каждая настройка: тип, значение по умолчанию и границы.
-- Всё, чего здесь нет, сервер не примет и не сохранит.
local SCHEMA = {
musicVolume = { kind = "number", default = 0.6, min = 0, max = 1 },
sfxVolume = { kind = "number", default = 0.8, min = 0, max = 1 },
sensitivity = { kind = "number", default = 1.0, min = 0.2, max = 3 },
cameraShake = { kind = "boolean", default = true },
subtitles = { kind = "boolean", default = false },
}
local Schema = {}
function Schema.defaults()
local result = {}
for name, rule in pairs(SCHEMA) do
result[name] = rule.default
end
return result
end
-- Возвращает очищенную копию: лишние ключи выброшены,
-- числа зажаты в границы, неверные типы заменены значением
-- по умолчанию. Мусор с клиента дальше этой функции не проходит.
function Schema.sanitize(raw)
local clean = Schema.defaults()
if type(raw) ~= "table" then
return clean
end
for name, rule in pairs(SCHEMA) do
local value = raw[name]
if rule.kind == "number" and type(value) == "number" and value == value then
clean[name] = math.clamp(value, rule.min, rule.max)
elseif rule.kind == "boolean" and type(value) == "boolean" then
clean[name] = value
end
end
return clean
end
return SchemaПроверка value == value
Так отсекается NaN — «не число», которое в Luau имеет тип number и проходит обычную проверку. Если положить его в громкость, звук пропадёт, а math.clamp его не поймает. Одна строка избавляет от загадочных «у меня нет звука» в поддержке.
Шаг 2. Сервер: загрузить при входе, сохранить по запросу#
Понадобятся два канала в ReplicatedStorage: RemoteEvent «SettingsPush» — сервер отдаёт клиенту его настройки при входе, и RemoteEvent «SettingsSave» — клиент просит сохранить новые. Один канал на сохранение, а не по событию на каждый ползунок: так у проверки одно место.
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local Schema = require(ServerStorage.SettingsSchema)
local store = DataStoreService:GetDataStore("PlayerSettings_v1")
local push = ReplicatedStorage:WaitForChild("SettingsPush")
local save = ReplicatedStorage:WaitForChild("SettingsSave")
-- Что сейчас у каждого игрока на сервере. Источник истины —
-- здесь, а не на клиенте.
local current = {}
local lastSave = {}
local function load(player)
local ok, data = pcall(store.GetAsync, store, tostring(player.UserId))
if not ok then
warn("Настройки не загрузились: " .. tostring(data))
data = nil
end
-- Что бы ни лежало в хранилище, оно проходит ту же очистку,
-- что и данные с клиента: схема могла измениться с прошлого раза.
current[player] = Schema.sanitize(data)
push:FireClient(player, current[player])
end
local function persist(player)
local data = current[player]
if data == nil then
return
end
local ok, err = pcall(store.SetAsync, store, tostring(player.UserId), data)
if not ok then
warn("Настройки не сохранились: " .. tostring(err))
end
end
Players.PlayerAdded:Connect(load)
save.OnServerEvent:Connect(function(player, raw)
-- Клиент прислал таблицу — сервер оставил из неё только то,
-- что знает, и в тех границах, что задал.
current[player] = Schema.sanitize(raw)
-- Не чаще раза в несколько секунд: ползунок шлёт события
-- десятками, а лимиты DataStore — не резиновые.
local now = os.clock()
if lastSave[player] and now - lastSave[player] < 5 then
return
end
lastSave[player] = now
persist(player)
end)
Players.PlayerRemoving:Connect(function(player)
-- Последнее состояние уходит в хранилище независимо от таймера:
-- иначе правка, сделанная за секунду до выхода, пропадёт.
persist(player)
current[player] = nil
lastSave[player] = nil
end)
game:BindToClose(function()
for _, player in ipairs(Players:GetPlayers()) do
persist(player)
end
end)Обрати внимание: сервер никогда не берёт значения из сообщения «как есть». Он пропускает их через схему и хранит у себя. Если клиент прислал громкость 40 — в хранилище ляжет 1. Если прислал строку — ляжет значение по умолчанию. Клиенту незачем это знать: он свою копию уже применил.
Шаг 3. Клиент: применить сразу, отправить потом#
На клиенте одна таблица настроек и одна функция apply, которая переводит её в реальность: громкость звуков, чувствительность камеры, включена ли тряска. Ползунки меняют таблицу и зовут apply сразу, а на сервер сообщение уходит с задержкой — когда игрок перестал крутить.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SoundService = game:GetService("SoundService")
local UserInputService = game:GetService("UserInputService")
local push = ReplicatedStorage:WaitForChild("SettingsPush")
local save = ReplicatedStorage:WaitForChild("SettingsSave")
local settings = nil
local pending = false
local function apply()
-- Группы звуков заводятся в SoundService заранее:
-- Music и Sfx. Все звуки игры привязаны к одной из них.
SoundService.Music.Volume = settings.musicVolume
SoundService.Sfx.Volume = settings.sfxVolume
UserInputService.MouseDeltaSensitivity = settings.sensitivity
-- Тряску и субтитры читают свои системы через эту же таблицу.
end
-- Сервер прислал настройки при входе. Пока их нет,
-- интерфейс настроек лучше не показывать вовсе.
push.OnClientEvent:Connect(function(data)
settings = data
apply()
end)
-- Вызывается из интерфейса при каждом движении ползунка
-- или переключателя.
local function set(name, value)
if settings == nil then
return
end
settings[name] = value
apply()
if pending then
return
end
pending = true
-- Ждём, пока игрок закончит крутить, и шлём один раз.
task.delay(1, function()
pending = false
save:FireServer(settings)
end)
end
return setЗачем сервер вообще проверяет громкость
Кажется, что громкость — личное дело игрока, и врать про неё некому. Но в хранилище ляжет то, что пришло, а читать это будет твой же код через месяц. Строка вместо числа в Volume роняет скрипт при входе — и у игрока не работает вся игра, а не только звук. Проверка нужна не против читера, а против собственного будущего.
Шаг 4. Когда схема меняется#
Через полгода ты добавишь настройку «показывать урон цифрами». У старых игроков в хранилище её нет — и sanitize подставит значение по умолчанию. Уберёшь настройку — старое значение просто отбросится. Это и есть причина прогонять через схему не только данные с клиента, но и данные из хранилища: одна функция закрывает оба случая, и миграции писать не нужно.
- Добавляешь настройку — добавляешь строку в схему, и всё
- Меняешь границы — старые значения зажмутся в новые при первом входе
- Переименовываешь — на один релиз оставь обе строки и перенеси значение вручную в
load, иначе игроки получат «сброс» - Имя хранилища с версией (
_v1) меняй только если формат стал несовместим совсем: это отрежет все старые данные
Что не надо хранить в настройках#
Всё, что влияет на других игроков или на честность: скорость, урон, «режим без отдачи». Это не настройки, а игровые правила, и решает их сервер, а не ползунок. Если такое значение оказалось в таблице настроек, у тебя не экран опций, а меню читов, которое ты сам и написал.
Проверь себя#
- Ползунок меняет звук в ту же секунду, без ожидания сервера
- Вышел из игры сразу после правки — после входа правка на месте
- Отправил с клиента громкость 5 — в хранилище лежит 1
- Добавил новую настройку в схему — старый игрок вошёл без ошибок и с значением по умолчанию
- В схеме нет ничего, что меняет игру для других
Схема «клиент просит, сервер решает» здесь та же, что в сохранении прогресса и в магазине. Разница только в том, что настройки можно применять до ответа сервера, а деньги — нет.

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