Архитектура и разработка масштабных скриптов на Roblox Lua

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

Основы архитектуры: Модульность и организация кода с помощью ModuleScripts

Основы архитектуры: Модульность и организация кода с помощью ModuleScripts

Добро пожаловать в курс «Архитектура и разработка масштабных скриптов на Roblox Lua». Вы здесь, потому что задались вопросом: «Как написать хороший и большой скрипт?». Ответ может вас удивить: секрет написания большого скрипта заключается в том, чтобы не писать большие скрипты.

В этой первой статье мы разберем фундамент любой качественной разработки на Roblox — модульность. Мы научимся разбивать гигантские задачи на маленькие, управляемые части, используя ModuleScript.

Проблема «Спагетти-кода»

Представьте, что вы строите огромный город. Если вы попытаетесь построить все здания, дороги, электросети и канализацию, используя один гигантский чертеж, где все линии наложены друг на друга, вы сойдете с ума. То же самое происходит и в программировании.

Многие новички создают один Script (или LocalScript) и пишут в него всё: обработку нажатий, сохранение данных, анимацию интерфейса и логику магазина. Когда такой файл разрастается до 2000+ строк, он превращается в так называемый спагетти-код.

Основные проблемы монолитного (единого) скрипта:

  • Нечитаемость: Найти нужную функцию занимает вечность.
  • Сложность отладки: Если что-то ломается, трудно понять, какая часть кода виновата.
  • Дублирование: Если вам нужна одна и та же формула в двух разных местах, вам приходится копировать и вставлять код. Если формула изменится, придется менять её везде.
  • Конфликты: При командной разработке два программиста не могут эффективно работать в одном файле одновременно.

Что такое ModuleScript?

В Roblox существует специальный тип скрипта — ModuleScript. В отличие от обычных Script (серверных) и LocalScript (клиентских), модульный скрипт не запускается автоматически.

ModuleScript — это контейнер для кода, который можно использовать (импортировать) в других скриптах. Он выполняется только тогда, когда его «запрашивают» с помощью функции require().

Анатомия модуля

Стандартный ModuleScript выглядит так:

-- Создаем пустую таблицу, которая будет содержать наши функции и переменные
local MyModule = {}

-- Добавляем переменную в таблицу
MyModule.SomeValue = 10

-- Добавляем функцию в таблицу
function MyModule.SayHello()
    print("Привет из модуля!")
end

-- Обязательно возвращаем таблицу в конце
return MyModule

Ключевые моменты:

  1. Таблица: Модуль обычно возвращает таблицу, наполненную функциями или данными.
  2. Return: Модуль обязан возвращать ровно одно значение (обычно это таблица, но может быть и функция, и даже число).

Как использовать require()

Чтобы использовать код из ModuleScript в обычном скрипте, мы используем функцию require(путь_к_модулю).

Предположим, наш модуль лежит в ServerScriptService и называется MathUtils.

-- Обычный Script в ServerScriptService

local ServerScriptService = game:GetService("ServerScriptService")
-- Загружаем модуль
local MathUtils = require(ServerScriptService.MathUtils)

-- Теперь мы можем использовать функции из модуля
MathUtils.SayHello() -- Выведет: Привет из модуля!
print(MathUtils.SomeValue) -- Выведет: 10

Принцип Singleton (Одиночка)

Это критически важный концепт в Roblox Lua. Когда вы вызываете require() для модуля в первый раз, Roblox выполняет код внутри модуля и запоминает (кеширует) то, что модуль вернул.

Все последующие вызовы require() для этого же модуля (на той же стороне: сервер или клиент) не будут выполнять код модуля заново. Они просто вернут ту же самую таблицу, которая была создана в первый раз.

Это позволяет использовать модули как хранилища состояния.

-- ModuleScript "GameConfig"
local Config = {}
Config.IsGameRunning = false
return Config

Если Скрипт А изменит Config.IsGameRunning = true, то Скрипт Б, подключив этот же модуль, увидит, что значение уже равно true.

Организация кода: Разделяй и властвуй

Чтобы написать «большой и хороший скрипт», нужно разбить его на слои ответственности. Давайте рассмотрим пример простой системы выдачи монет за убийство монстров.

Плохой подход (Монолит)

Один скрипт в ServerScriptService, который делает всё:

-- BAD CODE: MonolithScript
game.Players.PlayerAdded:Connect(function(player)
    -- Создание папки leaderstats
    local stats = Instance.new("Folder")
    stats.Name = "leaderstats"
    stats.Parent = player

    local coins = Instance.new("IntValue")
    coins.Name = "Coins"
    coins.Value = 0
    coins.Parent = stats

    -- Логика монстров прямо тут
    workspace.Monster.Humanoid.Died:Connect(function()
        coins.Value = coins.Value + 10
        print("Монстр умер, монеты выданы")
    end)
end)

Почему это плохо? Если вы захотите добавить магазин, вам придется лезть в этот же скрипт. Если вы захотите сохранять данные (DataStore), код станет еще длиннее.

Хороший подход (Модульность)

Мы разделим логику на три части:

  1. DataManager (Модуль): Отвечает только за работу с данными игрока.
  2. MonsterLogic (Скрипт): Отвечает за поведение монстров.
  3. Main (Скрипт): Инициализирует игру.

1. ModuleScript: DataManager (в ServerScriptService.Modules)

local DataManager = {}
local playersData = {} -- Локальная таблица для хранения данных в памяти

function DataManager.InitPlayer(player)
    local stats = Instance.new("Folder")
    stats.Name = "leaderstats"
    stats.Parent = player

    local coins = Instance.new("IntValue")
    coins.Name = "Coins"
    coins.Value = 0
    coins.Parent = stats

    playersData[player.UserId] = coins
end

function DataManager.AddCoins(player, amount)
    local coins = playersData[player.UserId]
    if coins then
        coins.Value = coins.Value + amount
    end
end

return DataManager

2. Script: MonsterHandler (в ServerScriptService)

local ServerScriptService = game:GetService("ServerScriptService")
-- Подключаем наш модуль
local DataManager = require(ServerScriptService.Modules.DataManager)

-- Представим, что у нас есть папка с монстрами
for _, monster in pairs(workspace.Monsters:GetChildren()) do
    local humanoid = monster:FindFirstChild("Humanoid")
    if humanoid then
        humanoid.Died:Connect(function()
            -- Находим игрока, который убил (упрощенно)
            -- В реальной игре здесь была бы логика определения убийцы
            local killer = game.Players:GetPlayers()[1]
            if killer then
                -- Мы просто просим менеджер добавить монеты
                -- Нам не важно, КАК он это делает
                DataManager.AddCoins(killer, 10)
            end
        end)
    end
end

3. Script: MainLoader (в ServerScriptService)

local ServerScriptService = game:GetService("ServerScriptService")
local DataManager = require(ServerScriptService.Modules.DataManager)

game.Players.PlayerAdded:Connect(function(player)
    DataManager.InitPlayer(player)
end)

Преимущества такой структуры

  1. Инкапсуляция: Скрипту монстров не нужно знать, как создаются leaderstats или как они называются. Он просто говорит: «Дай денег». Если завтра вы решите хранить деньги не в IntValue, а в SQL-базе, вы измените только DataManager. Скрипт монстров трогать не придется.
  2. Повторное использование: Если вы добавите магазин, он тоже просто вызовет require(DataManager) и сможет проверять баланс или списывать деньги.

Где хранить модули?

Правильная организация файлов в Explorer — залог успеха.

1. ServerScriptService

Здесь хранятся модули, которые содержат секретную логику или работают с защищенными сервисами (DataStoreService, HTTPService). Клиент (игрок) не видит и не может получить доступ к этим модулям.

  • Примеры: Менеджер базы данных, админ-панель, античит, генерация карты.

2. ReplicatedStorage

Здесь хранятся модули, которые нужны и серверу, и клиенту. Это «общая зона».

  • Примеры: Конфигурации оружия (урон, скорострельность), математические утилиты, модули для анимации интерфейса, сетевые протоколы.

Важно: Никогда не храните пароли, ключи API или логику проверки покупок в ReplicatedStorage. Эксплойтеры могут скопировать любой модуль оттуда.

Принцип DRY (Don't Repeat Yourself)

«Не повторяйся» — это золотое правило программирования. Если вы видите, что копируете один и тот же кусок кода в два разных скрипта — остановитесь. Выделите этот код в ModuleScript.

Пример: У вас есть формула расчета опыта для уровня. Она нужна на сервере (чтобы выдать уровень) и на клиенте (чтобы показать прогресс-бар).

Вместо того чтобы писать формулу дважды, создайте модуль LevelCalculator в ReplicatedStorage:

-- ReplicatedStorage.LevelCalculator
local LevelCalculator = {}

function LevelCalculator.GetExpForLevel(level)
    return 100 * (level ^ 1.5)
end

return LevelCalculator

Теперь и сервер, и клиент используют единый источник истины.

Заключение

Написание «большого скрипта» начинается с отказа от написания больших файлов. Ваша задача как архитектора — разбивать систему на маленькие, независимые кирпичики (модули), которые общаются друг с другом через понятные интерфейсы (функции).

В следующих статьях мы углубимся в объектно-ориентированное программирование (ООП) на Lua и узнаем, как создавать собственные классы с помощью метатаблиц, что выведет вашу архитектуру на новый уровень.

Клиент-серверное взаимодействие: Безопасная репликация и защита от уязвимостей

Клиент-серверное взаимодействие: Безопасная репликация и защита от уязвимостей

В предыдущей статье мы научились структурировать код с помощью ModuleScript, превращая хаос в порядок. Однако в многопользовательских играх, таких как Roblox, архитектура — это не только красота кода, но и безопасность.

Самая большая ошибка новичка — думать, что игра происходит только на одном компьютере. На самом деле, ваша игра — это распределенная система, где есть один Сервер (Авторитет) и множество Клиентов (Игроков). Понимание того, как они общаются, отделяет профессионального разработчика от создателя игр, которые взламываются за 5 минут.

Модель Клиент-Сервер: Великая Стена

Roblox использует модель, которая раньше называлась FilteringEnabled. Суть её проста: изменения, сделанные клиентом, не видны другим игрокам и серверу, если они не прошли через специальные каналы связи.

Представьте ресторан:

  • Клиент (Игрок): Сидит за столиком и смотрит в меню (GUI). Он может решать, что хочет заказать.
  • Сервер (Кухня): Готовит еду и управляет запасами продуктов. Только у повара есть доступ к холодильнику (DataStore).
  • RemoteEvent (Официант): Передает заказ от клиента на кухню.

Если клиент сам зайдет на кухню и начнет жарить стейк — это хаос (и уязвимость). В Roblox клиент видит всё, что происходит, но менять может только то, что ему разрешено (своего персонажа и локальные эффекты).

Каналы связи: RemoteEvent и RemoteFunction

Чтобы преодолеть «стену» между клиентом и сервером, мы используем объекты, хранящиеся в ReplicatedStorage (так как это хранилище видно обеим сторонам).

1. RemoteEvent (Односторонняя связь)

Используется, когда одной стороне нужно сказать другой «Сделай это», и ей не важен ответ или результат выполнения.

  • Client -> Server: Игрок нажал кнопку «Атака». Сервер должен нанести урон.
  • Server -> Client: Сервер решил, что начался дождь. Клиентам нужно включить частицы дождя.

Этот метод асинхронный. Отправитель не ждет, пока получатель закончит работу. Он просто «бросает» сообщение и идет дальше.

2. RemoteFunction (Двусторонняя связь)

Используется, когда отправителю нужен ответ.

  • Client -> Server: Игрок спрашивает: «Сколько у меня денег?». Сервер отвечает: «100 монет».
  • Server -> Client: (Используется крайне редко и опасно) Сервер спрашивает клиента: «Какой у тебя размер экрана?».

Этот метод синхронный (yielding). Скрипт, вызвавший функцию, остановится и зависнет, пока не получит ответ. Именно поэтому вызов InvokeClient с сервера считается плохой практикой: если у игрока завис компьютер или он удалил скрипт, ваш серверный поток зависнет навсегда.

Главное правило безопасности: Никогда не доверяй клиенту

Это мантра любого сетевого инженера. Клиент находится в руках игрока. Это значит, что игрок (или эксплойтер) может:

  1. Удалить любой LocalScript.
  2. Подделать любой аргумент, отправляемый в RemoteEvent.
  3. Вызвать RemoteEvent в любой момент, даже если у него нет на это права (например, кулдауна).

Пример уязвимости: Магазин

Рассмотрим классический пример плохого кода покупки меча.

Плохой код (LocalScript):

-- КЛИЕНТ (УЯЗВИМО)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BuyEvent = ReplicatedStorage:WaitForChild("BuyItem")

-- Игрок нажимает кнопку покупки
BuyButton.MouseButton1Click:Connect(function()
    -- Клиент говорит серверу: "Я покупаю Меч за 100 монет"
    BuyEvent:FireServer("Sword", 100)
end)

Плохой код (Script):

-- СЕРВЕР (УЯЗВИМО)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BuyEvent = ReplicatedStorage.BuyItem

BuyEvent.OnServerEvent:Connect(function(player, itemName, price)
    -- Сервер наивно верит клиенту
    if player.Leaderstats.Coins.Value >= price then
        player.Leaderstats.Coins.Value -= price
        GiveItem(player, itemName)
    end
end)

В чем проблема? Эксплойтер может написать скрипт-инжектор:

game.ReplicatedStorage.BuyItem:FireServer("SuperGodSword", 0) -- Бесплатно!
-- Или даже
game.ReplicatedStorage.BuyItem:FireServer("Sword", -10000) -- Накрутка денег!

Правильный подход: Санитайзинг (Sanitizing)

Сервер должен знать «Истину». Клиент должен отправлять только намерение.

Хороший код (LocalScript):

-- КЛИЕНТ
BuyButton.MouseButton1Click:Connect(function()
    -- Клиент говорит: "Я хочу купить предмет с ID 5"
    BuyEvent:FireServer(5)
end)

Хороший код (Script):

-- СЕРВЕР
local ShopData = require(game.ServerScriptService.Modules.ShopData)

BuyEvent.OnServerEvent:Connect(function(player, itemID)
    -- 1. Проверка типов: Действительно ли пришло число?
    if type(itemID) ~= "number" then return end

    -- 2. Проверка существования: Есть ли такой товар?
    local itemInfo = ShopData.GetItem(itemID)
    if not itemInfo then return end

    -- 3. Проверка логики: Хватает ли денег? (Цену берем из СВОЕЙ базы, а не от клиента)
    local price = itemInfo.Price
    if player.Leaderstats.Coins.Value >= price then
        player.Leaderstats.Coins.Value -= price
        GiveItem(player, itemInfo.Name)
    end
end)

Проверка дистанции (Distance Check)

Еще одна частая уязвимость — взаимодействие с объектами на огромном расстоянии. Например, игрок подбирает предмет, находясь на другом конце карты.

Чтобы защититься, сервер должен проверять расстояние между игроком и объектом. Для этого используется формула евклидова расстояния в трехмерном пространстве:

D=(x2−x1)2+(y2−y1)2+(z2−z1)2D = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2 + (z_2 - z_1)^2}

Где:

  • DD — итоговое расстояние между точками.
  • x1,y1,z1x_1, y_1, z_1 — координаты первой точки (например, позиция игрока).
  • x2,y2,z2x_2, y_2, z_2 — координаты второй точки (например, позиция предмета).

В Lua это делается проще, через встроенные методы Vector3:

local MAX_DISTANCE = 15

PickupEvent.OnServerEvent:Connect(function(player, itemPart)
    if not itemPart or not itemPart:IsA("BasePart") then return end

    local character = player.Character
    if not character then return end

    -- Вычисляем расстояние (Magnitude)
    local distance = (character.HumanoidRootPart.Position - itemPart.Position).Magnitude

    if distance <= MAX_DISTANCE then
        -- Разрешаем подбор
        PickupItem(player, itemPart)
    else
        warn(player.Name .. " попытался подобрать предмет слишком далеко!")
    end
end)

Архитектура сетевого кода

Если у вас в игре 50 разных действий (сесть, лечь, купить, продать, стрелять), создание 50 отдельных RemoteEvent в ReplicatedStorage превратит папку в свалку.

В рамках нашего курса по архитектуре, мы применим модульный подход.

Паттерн «Единый шлюз» (Single Remote Pattern)

Вместо кучи событий можно использовать всего несколько, разделенных по смыслу (например, GameAction, ShopAction, SystemAction).

Пример реализации:

  1. ReplicatedStorage: Один RemoteEvent с именем NetworkGateway.
  2. ServerScriptService: Скрипт-обработчик, который перенаправляет запросы в нужные модули.
-- Server Handler
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetworkGateway = ReplicatedStorage.NetworkGateway

-- Таблица модулей-обработчиков
local Handlers = {
    Shop = require(script.Parent.Modules.ShopHandler),
    Combat = require(script.Parent.Modules.CombatHandler)
}

NetworkGateway.OnServerEvent:Connect(function(player, actionType, payload)
    -- actionType может быть "Shop:Buy" или "Combat:Attack"

    local category, action = actionType:match("([^:]+):([^:]+)")

    if Handlers[category] then
        -- Передаем задачу соответствующему модулю
        Handlers[category].Process(player, action, payload)
    end
end)

Такой подход упрощает отладку и позволяет легко добавлять новые функции, просто создавая новые модули, не захламляя ReplicatedStorage.

Защита от спама (Debounce / Rate Limiting)

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

Всегда добавляйте проверку времени последнего действия (Rate Limit) на сервере.

local cooldowns = {}

function ProcessAction(player)
    local lastAction = cooldowns[player.UserId] or 0
    local currentTime = os.time()

    if currentTime - lastAction < 0.5 then -- Лимит: 1 действие в 0.5 секунды
        return false -- Блокируем действие
    end

    cooldowns[player.UserId] = currentTime
    return true
end

Заключение

Безопасность в Roblox — это не одна «волшебная галочка» в настройках. Это стиль мышления. Каждый раз, когда вы пишете OnServerEvent, задавайте себе вопрос: «Что произойдет, если злоумышленник отправит сюда nil, отрицательное число или вызовет это миллион раз?».

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

Работа с данными: DataStoreService и эффективное управление состоянием игры

Работа с данными: DataStoreService и эффективное управление состоянием игры

Мы продолжаем наш курс «Архитектура и разработка масштабных скриптов». В первой статье мы научились организовывать код в модули, во второй — защитили сервер от читеров. Теперь перед нами встает задача, которая определяет, вернется ли игрок в вашу игру завтра: сохранение прогресса.

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

Что такое DataStoreService?

DataStoreService — это облачная база данных Roblox. Она позволяет сохранять данные (числа, строки, таблицы) между игровыми сессиями. Данные хранятся на серверах Roblox и привязаны к вашему плейсу.

Однако, работа с облаком отличается от работы с локальной памятью. Сетевые запросы могут быть медленными, они могут теряться, и, самое главное, у них есть лимиты.

Архитектура: Кэширование сессии (Session Cache)

Главная ошибка новичков — обращаться к DataStore каждый раз, когда нужно изменить или прочитать данные. Например, каждый раз, когда игрок получает монету, скрипт пишет это сразу в облако.

Почему это плохо?

  1. Лимиты: Roblox ограничивает количество запросов. Если вы превысите лимит, запросы начнут отклоняться, и данные не сохранятся.
  2. Скорость: Запись в облако занимает время (от 0.1 до нескольких секунд). Это может вызвать задержки.

Правильный подход: Кэширование

Мы будем использовать архитектурный паттерн Session Cache. Идея проста: когда игрок заходит, мы загружаем его данные один раз и сохраняем их в обычной Lua-таблице (в памяти сервера). Вся игра работает только с этой таблицей. Когда игрок выходит, мы сохраняем таблицу обратно в облако.

Безопасность данных: pcall

Любой запрос к внешней сети (HTTP или DataStore) может завершиться ошибкой. Сервер Roblox может временно зависнуть, или интернет-соединение может прерваться. Если ваш скрипт просто вызовет GetAsync и произойдет ошибка, скрипт «упадет», и игрок останется без данных (или, что хуже, игра посчитает, что он новый игрок, и перезапишет его сохранение нулями).

Для защиты используется функция pcall (Protected Call).

local DataStoreService = game:GetService("DataStoreService")
local MyDataStore = DataStoreService:GetDataStore("PlayerData_V1")

local success, result = pcall(function()
    return MyDataStore:GetAsync("Player_12345")
end)

if success then
    print("Данные загружены:", result)
else
    warn("Ошибка загрузки:", result)
    -- Здесь должна быть логика повторной попытки (Retry Logic)
end

SetAsync против UpdateAsync

В DataStoreService есть два основных метода записи:

  1. SetAsync(key, value) — просто берет значение и записывает его, удаляя то, что было раньше.
  2. UpdateAsync(key, callback_function) — читает текущее значение, позволяет вам его изменить и записывает обратно.

Почему SetAsync опасен? Представьте ситуацию: игрок покупает монеты на сайте Roblox и одновременно находится в игре. Или игрок быстро перезаходит с одного сервера на другой. Может возникнуть состояние гонки (Race Condition), когда старые данные перезаписывают новые.

UpdateAsync — это атомарная операция. Она гарантирует, что вы работаете с самой актуальной версией данных.

-- Правильное использование UpdateAsync для добавления монет
local function AddCoins(userId, amount)
    local success, err = pcall(function()
        MyDataStore:UpdateAsync(userId, function(oldData)
            -- oldData - это то, что СЕЙЧАС лежит в базе
            local currentCoins = oldData or 0
            return currentCoins + amount
        end)
    end)
end

Реализация модуля DataManager

Следуя принципам нашего курса, мы создадим ModuleScript для управления данными. Он будет скрывать сложность работы с DataStore от остальной игры.

-- ServerScriptService/Modules/DataManager.lua
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local DataManager = {}
local PlayerDataStore = DataStoreService:GetDataStore("GameData_v1")

-- Наш кэш сессии
local sessionData = {}

-- Данные по умолчанию для новых игроков
local DEFAULT_DATA = {
    Coins = 0,
    Level = 1,
    Inventory = {}
}

-- Функция глубокого копирования таблицы (вспомогательная)
local function DeepCopy(original)
    local copy = {}
    for k, v in pairs(original) do
        if type(v) == "table" then
            copy[k] = DeepCopy(v)
        else
            copy[k] = v
        end
    end
    return copy
end

-- 1. Загрузка данных при входе
function DataManager.LoadData(player)
    local key = "User_" .. player.UserId

    local success, data = pcall(function()
        return PlayerDataStore:GetAsync(key)
    end)

    if success then
        -- Если данных нет (новый игрок), берем дефолтные
        sessionData[player.UserId] = data or DeepCopy(DEFAULT_DATA)
        print("Данные загружены для", player.Name)
    else
        warn("Критическая ошибка загрузки для", player.Name)
        -- Важно: кикнуть игрока, чтобы не перезаписать его данные пустыми
        player:Kick("Ошибка загрузки данных. Пожалуйста, перезайдите.")
    end
end

-- 2. Получение данных (для других скриптов)
function DataManager.Get(player)
    return sessionData[player.UserId]
end

-- 3. Сохранение данных при выходе
function DataManager.SaveData(player)
    local key = "User_" .. player.UserId
    local dataToSave = sessionData[player.UserId]

    if not dataToSave then return end -- Нечего сохранять

    local success, err = pcall(function()
        PlayerDataStore:SetAsync(key, dataToSave)
    end)

    if not success then
        warn("Не удалось сохранить данные:", err)
    end

    -- Очищаем кэш
    sessionData[player.UserId] = nil
end

return DataManager

Теперь в основном скрипте (Main.server.lua) мы просто подключаем обработчики:

local Players = game:GetService("Players")
local DataManager = require(game.ServerScriptService.Modules.DataManager)

Players.PlayerAdded:Connect(function(player)
    DataManager.LoadData(player)
end)

Players.PlayerRemoving:Connect(function(player)
    DataManager.SaveData(player)
end)

Защита от потери данных при закрытии сервера

Иногда сервер выключается не потому, что игроки вышли, а потому что разработчик нажал «Shut Down» для обновления, или сервер упал. В этом случае событие PlayerRemoving может не успеть сработать для всех.

Для этого существует game:BindToClose. Эта функция позволяет выполнить код перед окончательным закрытием сервера. У вас есть около 30 секунд.

-- Добавляем в Main.server.lua
game:BindToClose(function()
    print("Сервер выключается, сохраняем всех...")

    -- Проходимся по всем оставшимся игрокам
    for _, player in pairs(Players:GetPlayers()) do
        -- Запускаем сохранение в отдельном потоке (coroutine), чтобы ускорить процесс
        task.spawn(function()
            DataManager.SaveData(player)
        end)
    end

    -- Ждем немного, чтобы запросы успели уйти
    task.wait(3)
end)

Лимиты и математика запросов

Важно понимать, сколько запросов вы можете отправить. Лимиты зависят от количества игроков на сервере. Рассмотрим формулу лимита на запись (Write Requests) в минуту:

L=60+N×10L = 60 + N \times 10

Где:

  • LL — лимит запросов на запись в минуту.
  • 6060 — базовое количество запросов.
  • NN — количество игроков на сервере.
  • 1010 — множитель для каждого игрока.

Например, если на сервере 5 игроков, ваш лимит записи в минуту: 60+5×10=11060 + 5 \times 10 = 110 запросов. Этого более чем достаточно для сохранения при выходе, но недостаточно, если вы будете сохранять каждое движение игрока.

Автосохранение (AutoSave)

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

В DataManager можно добавить цикл:

task.spawn(function()
    while true do
        task.wait(300) -- Каждые 5 минут
        for _, player in pairs(Players:GetPlayers()) do
            DataManager.SaveData(player)
        end
    end
end)

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

Заключение

Работа с данными — это фундамент долгосрочного успеха игры. Мы создали модульную систему, которая:

  1. Кэширует данные для быстрого доступа.
  2. Использует pcall для защиты от сбоев.
  3. Обрабатывает закрытие сервера через BindToClose.

В следующей статье мы перейдем к визуальной части и разберем профессиональную архитектуру UI, чтобы ваши интерфейсы были такими же надежными, как и ваш код.

Программирование динамических интерфейсов и взаимодействие с игроком

Программирование динамических интерфейсов и взаимодействие с игроком

Мы продолжаем наш курс по архитектуре масштабных скриптов в Roblox. В прошлых статьях мы построили надежный фундамент: модульную структуру кода, защищенное клиент-серверное взаимодействие и систему сохранения данных. Но для игрока всё это невидимо. Игрок взаимодействует с игрой через Интерфейс (UI).

Плохой код интерфейса — это самая частая причина «лагов» на клиенте. Если ваш инвентарь открывается с задержкой в полсекунды, или здоровье обновляется рывками, игрок почувствует, что игра сделана некачественно. В этой статье мы разберем, как применять архитектурные паттерны к UI, чтобы создавать плавные, отзывчивые и красивые интерфейсы.

Проблема: Скрипт внутри Кнопки

Откройте любой проект новичка, и вы увидите это:

  • StarterGui
    • ScreenGui
      • ShopFrame
        • BuyButton
          • LocalScript (содержит логику покупки)
        • CloseButton
          • LocalScript (содержит логику закрытия)

Это архитектурный тупик. Когда у вас 50 кнопок, у вас 50 скриптов. Если вы захотите изменить звук нажатия кнопки, вам придется открывать 50 файлов. Более того, эти скрипты ничего не знают друг о друге.

Золотое правило UI-архитектуры: Интерфейс должен быть глупым. Он должен только отображать то, что ему говорят, и сообщать о действиях игрока.

Паттерн MVC (Model-View-Controller)

Для организации UI мы адаптируем классический паттерн MVC под реалии Roblox.

  1. Model (Модель): Это наши данные. На клиенте это обычно кэшированные значения (сколько у игрока золота, какой уровень, что в инвентаре). Мы уже затрагивали это в теме репликации.
  2. View (Представление): Это визуальная часть. ScreenGui, Frame, TextLabel. В коде это модуль, который умеет только менять свойства UI (цвет, текст, прозрачность).
  3. Controller (Контроллер): Это «мозг». Он слушает события (нажатия кнопок, сетевые пакеты) и решает, что делать.

Реализация View: Модуль управления визуалом

Вместо того чтобы писать script.Parent.Text = ... в разных местах, мы создадим один модуль, который управляет конкретным окном. Пусть это будет ShopView.

-- ReplicatedStorage/ClientModules/Views/ShopView.lua
local TweenService = game:GetService("TweenService")

local ShopView = {}
ShopView.__index = ShopView

-- Ссылки на объекты UI (находим их один раз при старте)
local PlayerGui = game.Players.LocalPlayer:WaitForChild("PlayerGui")
local MainFrame = PlayerGui:WaitForChild("ShopGui"):WaitForChild("MainFrame")
local CoinsLabel = MainFrame:WaitForChild("CoinsLabel")
local ItemContainer = MainFrame:WaitForChild("ItemsList")

function ShopView.SetVisible(isVisible)
    -- Используем анимацию вместо простого переключения
    local targetPos = isVisible and UDim2.new(0.5, 0, 0.5, 0) or UDim2.new(0.5, 0, 1.5, 0)
    local tweenInfo = TweenInfo.new(0.5, Enum.EasingStyle.Back, Enum.EasingDirection.Out)

    TweenService:Create(MainFrame, tweenInfo, {Position = targetPos}):Play()
end

function ShopView.UpdateCoins(amount)
    CoinsLabel.Text = "Монеты: " .. tostring(amount)
end

return ShopView

Заметьте: этот модуль не знает, откуда берутся монеты. Он не знает, что происходит при нажатии кнопок. Он просто «Красит забор».

Реализация Controller: Связующее звено

Контроллер — это LocalScript (обычно один на всю игру или один на крупную систему), который связывает данные и визуал.

-- StarterPlayerScripts/Controllers/ShopController.client.lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ShopView = require(ReplicatedStorage.ClientModules.Views.ShopView)

-- Сетевые события
local UpdateCoinsEvent = ReplicatedStorage:WaitForChild("UpdateCoins")

-- 1. Слушаем сервер (Model обновляется -> обновляем View)
UpdateCoinsEvent.OnClientEvent:Connect(function(newAmount)
    ShopView.UpdateCoins(newAmount)
end)

-- 2. Слушаем ввод игрока (View сообщает о действии -> обрабатываем логику)
-- Предположим, у нас есть кнопка открытия магазина
local OpenButton = game.Players.LocalPlayer.PlayerGui.HUD.OpenShop

OpenButton.MouseButton1Click:Connect(function()
    ShopView.SetVisible(true)
end)

Такое разделение позволяет вам полностью перерисовать дизайн магазина в Roblox Studio, поменять названия кнопок и цветов, и вам нужно будет поправить только пару строк в ShopView. Логика (ShopController) не сломается.

Динамические списки и управление памятью

Одна из самых сложных задач — отображение списков (инвентарь, список серверов, лидерборд). Новички часто делают так: очищают весь список и создают все кнопки заново при любом изменении. Это вызывает микро-фризы.

Эффективный рендеринг списков

Используйте Object Pooling (Пул объектов) или умное обновление.

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

-- Внутри ShopView
local itemFrames = {} -- Кэш созданных фреймов: { [ItemID] = Frame }

function ShopView.UpdateInventory(inventoryData)
    for itemId, amount in pairs(inventoryData) do
        if itemFrames[itemId] then
            -- Обновляем существующий
            itemFrames[itemId].AmountLabel.Text = "x" .. amount
        else
            -- Создаем новый, только если его нет
            local newFrame = ReplicatedStorage.Assets.ItemTemplate:Clone()
            newFrame.Name = itemId
            newFrame.AmountLabel.Text = "x" .. amount
            newFrame.Parent = ItemContainer
            itemFrames[itemId] = newFrame
        end
    end

    -- Опционально: удаляем фреймы предметов, которых больше нет в данных
end

Optimistic UI (Оптимистичный интерфейс)

Игроки ненавидят задержки. Когда вы нажимаете «Купить», сигнал идет на сервер, сервер обрабатывает покупку, сохраняет данные и отправляет ответ назад. Это может занять 200-500 мс. Для игрока это выглядит как «кнопка не работает».

Оптимистичный UI — это подход, при котором мы обновляем интерфейс мгновенно, предполагая, что сервер ответит успехом.

Сценарий:

  1. У игрока 100 монет. Товар стоит 50.
  2. Игрок жмет «Купить».
  3. Клиент (сразу): Визуально отнимает 50 монет (показывает 50) и выдает предмет в инвентарь.
  4. Клиент: Отправляет запрос на сервер.
  5. Сервер: Проверяет. Если всё ок — ничего не делаем (или подтверждаем). Если ошибка (не хватило денег, читер) — отправляем откат.
  6. Клиент (при ошибке): Возвращает монеты обратно (показывает 100) и показывает сообщение об ошибке.

Это создает ощущение мгновенной реакции игры.

Математика анимаций: Линейная интерполяция (Lerp)

Хотя TweenService мощен, иногда нам нужно создавать кастомные анимации или плавное следование камеры/курсора. Для этого используется формула линейной интерполяции.

Формула Lerp (Linear Interpolation) выглядит так:

v=a+(b−a)×tv = a + (b - a) \times t

Где:

  • vv — итоговое значение в текущий момент времени.
  • aa — начальное значение.
  • bb — конечное (целевое) значение.
  • tt — коэффициент прогресса от 0.0 до 1.0 (где 0 — это начало, а 1 — конец).

В Roblox Lua это часто используется для плавного изменения чисел или векторов:

local RunService = game:GetService("RunService")

local currentVal = 0
local targetVal = 100
local speed = 10 -- Скорость интерполяции

RunService.RenderStepped:Connect(function(deltaTime)
    -- Плавное приближение currentVal к targetVal
    -- Здесь t зависит от времени кадра (deltaTime), чтобы анимация была плавной при любом FPS
    local t = math.min(deltaTime * speed, 1)
    currentVal = currentVal + (targetVal - currentVal) * t

    -- Применяем к UI
    MyFrame.Transparency = currentVal / 100
end)

Эта техника делает интерфейс «живым» и «резиновым», что выглядит намного профессиональнее жестких переключений.

Ввод: ContextActionService против UserInputService

Как обрабатывать нажатия клавиш для открытия меню?

  • UserInputService: Хорош для глобального ввода (узнать, где мышка, нажата ли клавиша Shift).
  • ContextActionService: Идеален для действий, привязанных к контексту (открыть инвентарь, сесть в машину).

Почему ContextActionService лучше для архитектуры?

  1. Он позволяет переписывать действия. Если вы открыли меню, вы можете временно отключить кнопку прыжка или стрельбы, просто привязав другое действие с более высоким приоритетом.
  2. Он автоматически создает сенсорные кнопки для мобильных устройств.
local ContextActionService = game:GetService("ContextActionService")

local function toggleShop(actionName, inputState, inputObject)
    if inputState == Enum.UserInputState.Begin then
        ShopView.SetVisible(not ShopView.IsVisible)
    end
end

-- Привязываем клавишу M к функции. false - не создавать кнопку на мобилках
ContextActionService:BindAction("ToggleShop", toggleShop, false, Enum.KeyCode.M)

Заключение

Качественный интерфейс в Roblox — это не просто красивые картинки, это строгая архитектура.

  1. Разделяйте логику (Controller) и визуал (View).
  2. Используйте модули, чтобы не плодить LocalScript.
  3. Применяйте Оптимистичный UI для мгновенного отклика.
  4. Используйте математику (Lerp) для плавности.

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

Оптимизация производительности, профилирование и лучшие практики чистого кода

Оптимизация производительности, профилирование и лучшие практики чистого кода

Поздравляю! Вы добрались до финала курса «Архитектура и разработка масштабных скриптов на Roblox Lua». Мы прошли долгий путь: от разбиения монолитного кода на модули до создания безопасной сетевой архитектуры и плавных интерфейсов.

Теперь у вас есть красивая, структурированная система. Но есть один нюанс: красивый код не всегда работает быстро.

В этой заключительной статье мы поговорим о том, что отличает профессиональный проект от любительского — о производительности. Мы научимся находить «узкие места» (bottlenecks), управлять памятью и писать код, который не только работает, но и «летает».

Миф о преждевременной оптимизации

Существует популярная цитата Дональда Кнута:

Преждевременная оптимизация — корень всех зол.

Многие новички понимают это неправильно. Они думают: «Я напишу как попало, а потом, если будет лагать, исправлю». Это ошибка. Архитектурная оптимизация должна быть заложена сразу. Если вы выбрали неправильный алгоритм или структуру данных в начале, исправление этого в конце разработки потребует переписывания всего проекта.

Наша цель — не экономить наносекунды на каждой строчке, а избегать грубых ошибок, убивающих FPS (кадры в секунду).

Инструменты диагностики: MicroProfiler и Script Performance

Вы не можете улучшить то, что не можете измерить. Если игра тормозит, гадать «почему» — бесполезно. Нужно смотреть фактам в лицо.

1. Script Performance

Самый простой инструмент в Roblox Studio (View -> Script Performance). Он показывает, сколько процентов процессорного времени занимает каждый скрипт.

  • Activity: Процент использования CPU. Если у скрипта больше 3-5% в покое — это тревожный знак.
  • Rate: Количество выполнений в секунду.

2. MicroProfiler

Это «тяжелая артиллерия». MicroProfiler показывает каждый кадр игры в мельчайших деталях: сколько времени ушло на физику, сколько на рендеринг, а сколько на ваши скрипты.

Чтобы открыть его в игре, нажмите Ctrl + F6, а затем Ctrl + P для паузы.

Если вы видите огромную цветную полосу с названием вашего скрипта, значит, вы делаете слишком много вычислений внутри одного кадра (обычно внутри RenderStepped или Heartbeat).

Алгоритмическая сложность: Big O Notation

Самая частая причина лагов — неправильный выбор алгоритма. В информатике эффективность алгоритмов измеряется с помощью «О-большого» (OO).

Представьте, что у вас есть список из NN игроков, и вам нужно найти одного по имени.

Линейный поиск: O(N)O(N)

Вы перебираете список по порядку:

local players = {player1, player2, ...}
for _, p in ipairs(players) do
    if p.Name == "Target" then return p end
end

Время выполнения растет линейно. Если игроков 10, вы сделаете 10 проверок. Если 100 — 100 проверок.

Формула времени выполнения:

T≈k⋅NT \approx k \cdot N

Где TT — время выполнения, kk — константа (время одной операции сравнения), а NN — количество элементов.

Квадратичная сложность: O(N2)O(N^2) — Убийца производительности

Это происходит, когда вы вкладываете один цикл в другой. Например, вы хотите проверить, находится ли кто-то из 50 монстров рядом с кем-то из 50 игроков.

for _, monster in pairs(monsters) do
    for _, player in pairs(players) do
        -- Проверка дистанции
    end
end

Если NN (количество объектов) равно 100, количество операций будет:

Ops=N2=1002=10000Ops = N^2 = 100^2 = 10000

Где OpsOps — количество операций, а NN — количество элементов. Увеличение числа объектов в 2 раза увеличивает нагрузку в 4 раза!

Константное время: O(1)O(1) — Идеал

Использование словарей (Hash Maps) позволяет получать данные мгновенно, независимо от размера списка.

local playersMap = { ["Target"] = player1, ... }
local target = playersMap["Target"] -- Мгновенно!

Совет: Всегда используйте словари для поиска объектов, если это возможно.

Управление памятью и утечки (Memory Leaks)

Lua использует Garbage Collector (GC) — сборщик мусора. Он автоматически удаляет данные, которые больше не используются. Но иногда мы случайно «держим» данные, не давая GC их удалить. Это называется утечкой памяти.

Главный враг: RBXScriptConnection

Когда вы пишете part.Touched:Connect(...), создается связь. Если вы удалите part, связь исчезнет. Но если вы подписываетесь на событие глобального объекта (например, RunService.Heartbeat) внутри локального объекта, связь останется навсегда, даже если локальный объект уничтожен.

Плохой код:

-- Внутри скрипта оружия
local RunService = game:GetService("RunService")

-- ОШИБКА: Это соединение будет работать вечно, даже если оружие удалят
RunService.Heartbeat:Connect(function()
    print("Оружие активно")
end)

Хороший код (использование Janitor/Maid): В масштабных проектах используют классы-уборщики. Но в простом варианте нужно всегда сохранять соединение и отключать его.

local connection
connection = RunService.Heartbeat:Connect(function()
    -- Логика
end)

script.Destroying:Connect(function()
    if connection then connection:Disconnect() end
end)

Параллельные вычисления: Parallel Luau

Roblox долгое время был однопоточным. Это значило, что вся логика игры, физика и интерфейс стояли в одной очереди. Если скрипт зависал, зависала вся игра.

Теперь у нас есть Actors и Parallel Luau. Это позволяет распределять тяжелые задачи (например, процедурную генерацию карты или управление тысячей NPC) по разным ядрам процессора.

Чтобы использовать это, скрипт должен находиться внутри экземпляра Actor. Вместо обычного Connect, мы используем ConnectParallel.

-- Скрипт внутри Actor
game:GetService("RunService").Heartbeat:ConnectParallel(function()
    -- Этот код выполняется параллельно с другими скриптами
    local result = HeavyMathCalculation()

    task.synchronize() -- Возвращаемся в главный поток для изменения свойств Parts
    script.Parent.Position = result
end)

Важно: В параллельном режиме нельзя менять свойства объектов (Part.Position, GUI). Можно только считать математику. Чтобы применить изменения, нужно вызвать task.synchronize().

Лучшие практики чистого кода

Оптимизация — это не только скорость выполнения, но и скорость чтения кода разработчиком.

1. Принцип единой ответственности (SRP)

Каждая функция и каждый модуль должны делать только одну вещь. Если функция называется UpdateInventoryAndSaveDataAndPlaySound(), её нужно разбить на три.

2. Избегайте магических чисел

Плохо:

if player.Health < 30 then ... end

Что такое 30? Почему не 25?

Хорошо:

local LOW_HEALTH_THRESHOLD = 30
if player.Health < LOW_HEALTH_THRESHOLD then ... end

3. Кэширование сервисов

Вызов game:GetService(...) довольно быстрый, но в циклах, выполняющихся 60 раз в секунду, лучше вынести его наверх.

-- В начале скрипта
local Workspace = game:GetService("Workspace")
local Players = game:GetService("Players")

-- Внутри функций используем переменные, а не вызываем GetService снова

Заключение курса

Мы завершаем курс «Архитектура и разработка масштабных скриптов». Давайте вспомним наш путь:

  1. Модульность: Мы научились разбивать код на ModuleScript, избегая спагетти-кода.
  2. Безопасность: Мы построили стену между клиентом и сервером, фильтруя все запросы.
  3. Данные: Мы создали надежную систему сохранений с защитой от сбоев.
  4. Интерфейс: Мы разделили логику и визуал, используя MVC.
  5. Производительность: Сегодня мы узнали, как заставить всё это работать быстро.

Создание качественной игры на Roblox — это инженерная задача. Теперь у вас есть набор инструментов, чтобы решать её профессионально. Не пишите код, который просто «работает». Пишите код, который будет работать через год, который легко читать и который уважает время и ресурсы ваших игроков.

Удачи в разработке ваших миров!