Studio

Настройки игрока: как сохранить громкость и чувствительность между сессиями

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

Средне 20 минут проверен 1 месяц назад

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

Roblox Studio, опубликованное место и основы Luau. Пригодится гайд про DataStore — здесь та же схема, но короче.

Текст гайда «Настройки игрока: как сохранить громкость и чувствительность между сессиями»

Содержание

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

Два действия, а не одно

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

Шаг 1. Отдельное хранилище и список допустимого#

Настройки стоит держать в своём DataStore, а не в таблице прогресса. Прогресс меняется часто и от игры, настройки — редко и от игрока. Если однажды придётся обнулить прогресс — из-за бага или сезона, — громкость и раскладка у людей должны уцелеть.

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

ServerStorage → ModuleScript «SettingsSchema» · Luau
-- Каждая настройка: тип, значение по умолчанию и границы.
-- Всё, чего здесь нет, сервер не примет и не сохранит.
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» — клиент просит сохранить новые. Один канал на сохранение, а не по событию на каждый ползунок: так у проверки одно место.

ServerScriptService → Script «PlayerSettings» · Luau
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 сразу, а на сервер сообщение уходит с задержкой — когда игрок перестал крутить.

StarterPlayerScripts → LocalScript «SettingsClient» · Luau
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
  • Добавил новую настройку в схему — старый игрок вошёл без ошибок и с значением по умолчанию
  • В схеме нет ничего, что меняет игру для других

Схема «клиент просит, сервер решает» здесь та же, что в сохранении прогресса и в магазине. Разница только в том, что настройки можно применять до ответа сервера, а деньги — нет.

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

Обсуждение

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

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

Войти

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