Перейти к основному содержимому

Практикум — обби на Roblox

Roblox

Учебный проект в жанре обби (parkour): игрок проходит этапы, собирает монеты, покупает улучшения. Примеры на Luau, task.* и актуальный Creator Hub.

Перед стартом пройдите Roblox Studio — первая игра. Теория клиент–сервер — Разработка на Roblox; углублённая экономика — Внутриигровая экономика.


Цель проекта

МеханикаГде выполняется
Прогресс (этап, монеты)Сервер + DataStore
Чекпоинты и kill-блокиСервер
Сбор наград на PartСервер (валидация касания)
Магазин за внутриигровую валютуСервер
Покупка за Robux (Developer Product)Сервер + MarketplaceService
HUD (монеты, этап)Клиент (отображение)
Запросы игрокаКлиентRemoteEventСервер

Структура в Studio

Создайте в Explorer:

ServerScriptService
├── GameMain (Script — точка входа)
ReplicatedStorage
├── Modules
│ └── DataModule (ModuleScript)
├── Remotes
│ ├── ShopPurchase (RemoteEvent)
│ └── StatsUpdated (RemoteEvent)
ServerStorage
└── StageTemplates (Folder — опционально)
Workspace
├── Stages (Folder)
│ ├── Stage1 … (Model с Spawn, Finish, Coins)
├── Lobby
└── SpawnLocation
StarterGui
└── HUD (ScreenGui → TextLabel)
StarterPlayer
└── StarterPlayerScripts
└── HUDClient (LocalScript)
Game Settings для DataStore

В Game Settings → Security включите Enable Studio Access to API Services, иначе DataStore в локальном Play не сохраняется. В опубликованной игре сервис работает при включённых API в Experience.


Загрузчик ServerHandler

Все серверные системы удобно вешать на один Script в ServerScriptService и запускать дочерние ModuleScript параллельно — если один модуль упадёт при require, остальные всё равно подключатся.

ServerScriptService
└── ServerHandler (Script)
├── Data (ModuleScript)
├── PartFunctions (ModuleScript)
├── Physics (ModuleScript)
├── Monetization (ModuleScript)
└── Initialize (ModuleScript — leaderstats, BindToClose)
--!strict
-- ServerScriptService/ServerHandler

for _, child in script:GetChildren() do
if child:IsA("ModuleScript") then
task.spawn(function()
require(child)
end)
end
end

Модули храните в ServerScriptService (или ServerStorage, если клиенту не нужен require по пути). Клиент не должен иметь доступа к серверным модулям с логикой экономики.


Модуль данных DataModule

ReplicatedStorage/Modules/DataModule — единое место для загрузки, сохранения и изменения статистики.

--!strict

local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local STORE = DataStoreService:GetDataStore("ObbyPlayerData_v1")

export type PlayerData = {
Coins: number,
Stage: number,
Wins: number,
}

local DEFAULT: PlayerData = {
Coins = 0,
Stage = 1,
Wins = 0,
}

local DataModule = {}
local session: { [Player]: PlayerData } = {}
local saveCooldown: { [Player]: number } = {}

local SAVE_INTERVAL = 60

local function key(player: Player): string
return "u_" .. player.UserId
end

function DataModule.get(player: Player): PlayerData
return session[player] or table.clone(DEFAULT)
end

function DataModule.load(player: Player): PlayerData
local data = table.clone(DEFAULT)
local ok, result = pcall(function()
return STORE:GetAsync(key(player))
end)
if ok and type(result) == "table" then
for k, v in pairs(DEFAULT) do
if type(result[k]) == typeof(v) then
data[k] = result[k]
end
end
end
session[player] = data
return data
end

function DataModule.save(player: Player): boolean
local data = session[player]
if not data then
return false
end
local now = os.clock()
if saveCooldown[player] and now - saveCooldown[player] < 2 then
return false
end
saveCooldown[player] = now
local ok = pcall(function()
STORE:SetAsync(key(player), data)
end)
return ok
end

function DataModule.increment(player: Player, field: "Coins" | "Stage" | "Wins", amount: number)
local data = session[player]
if not data then
return
end
local current = data[field]
if type(current) == "number" then
data[field] = current + amount
end
end

function DataModule.setStage(player: Player, stage: number)
local data = session[player]
if not data then
return
end
if stage > data.Stage then
data.Stage = stage
end
end

Players.PlayerRemoving:Connect(function(player)
DataModule.save(player)
session[player] = nil
end)

task.spawn(function()
while true do
task.wait(SAVE_INTERVAL)
for _, player in Players:GetPlayers() do
DataModule.save(player)
end
end
end)

return DataModule

Почему так: все изменения баланса проходят через серверный модуль; клиент не пишет в DataStore напрямую.

Рекурсивное копирование и API get / set / increment

Таблицы в Lua передаются по ссылке. Перед сохранением в DataStore копируйте сессию, чтобы случайно не испортить DEFAULT:

local function deepCopy(t: { [string]: any }): { [string]: any }
local copy = {}
for k, v in pairs(t) do
copy[k] = if type(v) == "table" then deepCopy(v) else v
end
return copy
end

function DataModule.set(player: Player, stat: "Coins" | "Stage" | "Wins", value: number)
local data = session[player]
if not data then return end
data[stat] = value
local ls = player:FindFirstChild("leaderstats")
local iv = ls and ls:FindFirstChild(stat) :: IntValue?
if iv then iv.Value = value end
end

Троттлинг DataStore и повторные попытки

GetAsync / SetAsync могут отклоняться лимитами платформы. Оборачивайте вызов в pcall и повторяйте с паузой; ограничьте число попыток, чтобы не зависнуть:

local MAX_RETRIES = 5

function DataModule.save(player: Player): boolean
local data = session[player]
if not data then return false end
for attempt = 1, MAX_RETRIES do
local ok, err = pcall(function()
STORE:SetAsync(key(player), deepCopy(data))
end)
if ok then return true end
warn("DataStore save retry", attempt, err)
task.wait(1 + attempt * 0.5)
end
return false
end

Дополнительно: автосохранение раз в 120 с и BindToClose (см. GameMain ниже). Для атомарных обновлений в продакшене изучите UpdateAsyncсправочник DataStore.


Точка входа GameMain

ServerScriptService/GameMain:

--!strict

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local DataModule = require(ReplicatedStorage.Modules.DataModule)
local StatsUpdated = ReplicatedStorage.Remotes.StatsUpdated :: RemoteEvent

local function pushStats(player: Player)
local data = DataModule.get(player)
StatsUpdated:FireClient(player, data.Coins, data.Stage, data.Wins)
end

local function setupLeaderstats(player: Player, data: DataModule.PlayerData)
local ls = Instance.new("Folder")
ls.Name = "leaderstats"
ls.Parent = player

local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = data.Coins
coins.Parent = ls

local stage = Instance.new("IntValue")
stage.Name = "Stage"
stage.Value = data.Stage
stage.Parent = ls
end

Players.PlayerAdded:Connect(function(player)
local data = DataModule.load(player)
setupLeaderstats(player, data)
pushStats(player)

player.CharacterAdded:Connect(function(character)
local stage = data.Stage
local spawnPart = workspace.Stages:FindFirstChild("Stage" .. stage)
if spawnPart and spawnPart:FindFirstChild("Spawn") then
local spawn = spawnPart.Spawn :: BasePart
character:PivotTo(spawn.CFrame + Vector3.new(0, 3, 0))
end
end)
end)

game:BindToClose(function()
for _, player in Players:GetPlayers() do
DataModule.save(player)
player:Kick("Сервер перезапускается. Прогресс сохранён.")
end
task.wait(3)
end)

BindToClose даёт время дописать DataStore при остановке сервера (важно в продакшене и при закрытии Studio-сервера).


Модуль PartFunctions

Один модуль на все «умные» Part в Workspace: kill, урон, чекпоинт, монета, магазин, значок. В Studio разметьте папки (KillParts, SpawnParts, …) и при старте пройдитесь по детям.

playerFromHit

--!strict
local Players = game:GetService("Players")

local function playerFromHit(hit: Instance): (Player?, Model?)
local model = hit:FindFirstAncestorOfClass("Model")
if not model then return nil, nil end
local player = Players:GetPlayerFromCharacter(model)
return player, model
end

Kill и Damage

local function hookKillPart(part: BasePart)
part.Touched:Connect(function(hit)
local player, char = playerFromHit(hit)
local hum = char and char:FindFirstChildOfClass("Humanoid")
if player and hum and hum.Health > 0 then
hum.Health = 0
end
end)
end

local function hookDamagePart(part: BasePart)
local damageVal = part:FindFirstChild("Damage") :: IntValue?
if not damageVal then return end
local debouncing = false
part.Touched:Connect(function(hit)
local player, char = playerFromHit(hit)
local hum = char and char:FindFirstChildOfClass("Humanoid")
if player and hum and not debouncing then
debouncing = true
hum:TakeDamage(damageVal.Value)
task.delay(0.1, function()
debouncing = false
end)
end
end)
end

Debounce на клиенте не защищает от читов — только снижает шум Touched. Валидация урона остаётся на сервере.

Чекпоинт SpawnParts

На Part — IntValue Stage (номер этапа). Игрок засчитывает этап только если текущий Stage == checkpoint - 1 (нельзя перепрыгнуть вперёд):

local function hookSpawnPart(part: BasePart)
local stageVal = part:FindFirstChild("Stage") :: IntValue?
if not stageVal then return end
local targetStage = stageVal.Value
part.Touched:Connect(function(hit)
local player = playerFromHit(hit)
if not player then return end
local current = DataModule.get(player).Stage
if current == targetStage - 1 then
DataModule.setStage(player, targetStage)
DataModule.save(player)
-- FireClient StatsUpdated …
end
end)
end

Монеты с StringValue Code

Аналог CoinTags из базовой версии: у Part — StringValue Code и IntValue Reward.


Группы коллизий — игроки не толкают друг друга

--!strict
local PhysicsService = game:GetService("PhysicsService")
local Players = game:GetService("Players")

local GROUP = "ObbyPlayers"

PhysicsService:RegisterCollisionGroup(GROUP)
PhysicsService:CollisionGroupSetCollidable(GROUP, GROUP, false)

Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(char)
for _, desc in char:GetDescendants() do
if desc:IsA("BasePart") then
desc.CollisionGroup = GROUP
end
end
end)
end)

Тот же PhysicsService используют для головоломок «стена проходима только игроку» — см. 201.


Значки BadgeService

local BadgeService = game:GetService("BadgeService")

local function hookBadgePart(part: BasePart)
local badgeIdVal = part:FindFirstChild("BadgeId") :: IntValue?
if not badgeIdVal then return end
local badgeId = badgeIdVal.Value
part.Touched:Connect(function(hit)
local player = playerFromHit(hit)
if not player then return end
local uid = player.UserId
local ok, has = pcall(function()
return BadgeService:UserHasBadgeAsync(uid, badgeId)
end)
if ok and not has then
pcall(function()
BadgeService:AwardBadge(uid, badgeId)
end)
end
end)
end

Создайте значок в Creator Hub → Engagement → Badges, подставьте числовой id.


Этапы и чекпоинты

Разметка уровня

Для каждого Workspace/Stages/StageN (Model):

ОбъектТипРоль
SpawnPartТочка респавна
FinishPartЗона завершения этапа
KillPartTouched → сброс на Spawn
CoinPart + StringValue RewardIdНаграда один раз

Скрипт финиша (в ServerScriptService или ModuleScript)

--!strict
-- StageFinish.server.luau — подключите из GameMain или отдельным Script

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataModule = require(ReplicatedStorage.Modules.DataModule)
local StatsUpdated = ReplicatedStorage.Remotes.StatsUpdated :: RemoteEvent

local function hookFinish(finishPart: BasePart, stageIndex: number)
finishPart.Touched:Connect(function(hit)
local character = hit:FindFirstAncestorOfClass("Model")
if not character then
return
end
local player = Players:GetPlayerFromCharacter(character)
if not player then
return
end
DataModule.setStage(player, stageIndex + 1)
DataModule.increment(player, "Wins", 1)
DataModule.save(player)

local ls = player:FindFirstChild("leaderstats")
if ls then
local stageVal = ls:FindFirstChild("Stage") :: IntValue?
if stageVal then
stageVal.Value = DataModule.get(player).Stage
end
end
local data = DataModule.get(player)
StatsUpdated:FireClient(player, data.Coins, data.Stage, data.Wins)
end)
end

for _, stage in workspace.Stages:GetChildren() do
local n = tonumber(stage.Name:match("%d+"))
local finish = stage:FindFirstChild("Finish") :: BasePart?
if n and finish then
hookFinish(finish, n)
end
end

Kill-блок: при Touched вызовите player:LoadCharacter() или телепорт на Spawn текущего этапа.


Монеты и защита от повторного сбора

Паттерн «один раз на игрока» — папка-теги у Player:

--!strict
-- В обработчике монеты (сервер)

local function grantCoinOnce(player: Player, coinId: string, amount: number)
local tags = player:FindFirstChild("CoinTags") :: Folder?
if not tags then
tags = Instance.new("Folder")
tags.Name = "CoinTags"
tags.Parent = player
end
if tags:FindFirstChild(coinId) then
return
end
local tag = Instance.new("BoolValue")
tag.Name = coinId
tag.Parent = tags

DataModule.increment(player, "Coins", amount)
local ls = player:FindFirstChild("leaderstats")
local coins = ls and ls:FindFirstChild("Coins") :: IntValue?
if coins then
coins.Value = DataModule.get(player).Coins
end
end

coinId — уникальное имя Part (например Stage2_Coin03), чтобы нельзя было фармить одну монету бесконечно.


RemoteEvent и клиентский HUD

Сервер уже шлёт StatsUpdated:FireClient(player, coins, stage, wins).

Клиент StarterPlayerScripts/HUDClient:

--!strict

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local player = Players.LocalPlayer
local gui = player:WaitForChild("PlayerGui"):WaitForChild("HUD") :: ScreenGui
local label = gui:WaitForChild("StatsLabel") :: TextLabel
local StatsUpdated = ReplicatedStorage.Remotes.StatsUpdated :: RemoteEvent

StatsUpdated.OnClientEvent:Connect(function(coins: number, stage: number, wins: number)
label.Text = string.format("Монеты: %d | Этап: %d | Победы: %d", coins, stage, wins)
end)

Запрос покупки из GUI:

-- Клиент — только FireServer с id товара
ShopPurchase:FireServer("SpeedCoil")
-- Сервер — валидация цены и выдача Tool
ShopPurchase.OnServerEvent:Connect(function(player, itemId: string)
local PRICE = { SpeedCoil = 50 }
local price = PRICE[itemId]
if not price then
return
end
local data = DataModule.get(player)
if data.Coins < price then
return
end
DataModule.increment(player, "Coins", -price)
-- выдать Tool из ServerStorage в Backpack
DataModule.save(player)
pushStats(player) -- ваша функция обновления GUI
end)

Магазин и монетизация

Внутриигровая валюта — ShopParts

Шаблоны Tool лежат в ReplicatedStorage/ShopItems. На Part в мире — StringValue ItemName (имя Tool). При касании сервер проверяет цену и клонирует Tool в Backpack:

--!strict
local SHOP = {
["Зелье прыгучести"] = { Price = 50 },
}

local function hookShopPart(part: BasePart)
local nameVal = part:FindFirstChild("ItemName") :: StringValue?
if not nameVal then return end
local itemName = nameVal.Value
local item = SHOP[itemName]
if not item then return end
local template = ReplicatedStorage.ShopItems:FindFirstChild(itemName)
if not template or not template:IsA("Tool") then return end

part.Touched:Connect(function(hit)
local player = playerFromHit(hit)
if not player then return end
if DataModule.get(player).Coins < item.Price then return end
DataModule.increment(player, "Coins", -item.Price)
local tool = template:Clone()
tool.Parent = player:FindFirstChild("Backpack")
DataModule.save(player)
end)
end

Расходуемый Tool (бафф прыжка) логично реализовать LocalScript внутри Tool, который меняет Humanoid.JumpPower на 30 с — эффект локален, покупка остаётся серверной.

Game Pass и Developer Product

ТипКогда использовать
Game PassРазовая покупка навсегда (VIP, x2 монет)
Developer ProductПовторяемая покупка (пак монет, revive)

Касание Part с PromptId + BoolValue IsProduct:

MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player, passId, purchased)
if purchased and REWARDS[passId] then
REWARDS[passId](player)
end
end)

UserOwnsGamePassAsync кеширует результат на сессию — после покупки в игре выдайте награду в обработчике PromptGamePassPurchaseFinished, а не только при входе.

ProcessReceipt и история покупок

Идемпотентность — отдельный DataStore «уже выдано»:

--!strict
local DataStoreService = game:GetService("DataStoreService")
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")

local PurchaseHistory = DataStoreService:GetDataStore("PurchaseHistory_v1")

MarketplaceService.ProcessReceipt = function(receiptInfo)
local player = Players:GetPlayerByUserId(receiptInfo.PlayerId)
if not player then
return Enum.ProductPurchaseDecision.NotProcessedYet
end
local purchaseId = receiptInfo.PurchaseId
local ok, already = pcall(function()
return PurchaseHistory:GetAsync(tostring(purchaseId))
end)
if ok and already then
return Enum.ProductPurchaseDecision.PurchaseGranted
end
-- выдача награды по receiptInfo.ProductId
DataModule.increment(player, "Coins", 500)
pcall(function()
PurchaseHistory:SetAsync(tostring(purchaseId), true)
end)
DataModule.save(player)
return Enum.ProductPurchaseDecision.PurchaseGranted
end

Полная схема транзакций — 202.


Клиентские эффекты

Вращающиеся платформы и декор — клиент (RunService.RenderStepped), чтобы не нагружать сервер:

--!strict
-- LocalScript в StarterPlayerScripts

local RunService = game:GetService("RunService")
local folder = workspace:WaitForChild("Spinners")

RunService.RenderStepped:Connect(function(dt)
for _, part in folder:GetChildren() do
if part:IsA("BasePart") then
local speed = part:GetAttribute("SpinSpeed") or 1
part.CFrame = part.CFrame * CFrame.Angles(0, math.rad(speed) * dt, 0)
end
end
end)

Trail на персонаже для VIP — косметика в StarterCharacter; логику «кто VIP» сервер реплицирует через атрибут или CollectionService tag.


Анти-эксплойт

УгрозаЗащита
Накрутка монет с клиентаВсе начисления только в серверных Touched / OnServerEvent
Повторная покупкаПроверка баланса на сервере; списание до выдачи предмета
Спам FireServerКулдаун os.clock() на игрока (2–5 с)
Подделка этапаSpawnParts проверяет stage - 1; финиш — только сервер
Дубль RobuxPurchaseHistory + ProcessReceipt
Эксплойт debounceDebounce не заменяет сервер; урон только в DamageParts на сервере
Читы на скоростьСерверная телепортация только через валидные зоны

Клиент никогда не меняет leaderstats и не создаёт награды в Workspace для других игроков.


Тестирование и публикация

ШагДействие
1Test → Play — пройдите этап, соберите монету, перезайдите (проверка DataStore)
2Test → Start Server, 2 Players — второй клиент не ломает прогресс первого
3Game Settings — API Services, иконка, описание
4File → Publish to Roblox
5Creator Hub → Public → пригласите друзей на playtest

Чек-лист практикума

  • ServerHandler подключает все модули через task.spawn
  • DataModule грузит и сохраняет данные; есть retry при ошибке DataStore
  • BindToClose и автосохранение работают
  • SpawnParts не даёт перепрыгнуть этап
  • KillParts / DamageParts только на сервере
  • Группа коллизий ObbyPlayers включена
  • Монеты и значки выдаются один раз
  • Магазин за монеты и ProcessReceipt для Robux протестированы
  • HUD через StatsUpdated
  • Игра опубликована и проверена вне Studio

См. также

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").