Практикум — обби на 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 → 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):
| Объект | Тип | Роль |
|---|---|---|
Spawn | Part | Точка респавна |
Finish | Part | Зона завершения этапа |
Kill | Part | Touched → сброс на Spawn |
Coin | Part + 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; финиш — только сервер |
| Дубль Robux | PurchaseHistory + ProcessReceipt |
| Эксплойт debounce | Debounce не заменяет сервер; урон только в DamageParts на сервере |
| Читы на скорость | Серверная телепортация только через валидные зоны |
Клиент никогда не меняет leaderstats и не создаёт награды в Workspace для других игроков.
Тестирование и публикация
| Шаг | Действие |
|---|---|
| 1 | Test → Play — пройдите этап, соберите монету, перезайдите (проверка DataStore) |
| 2 | Test → Start Server, 2 Players — второй клиент не ломает прогресс первого |
| 3 | Game Settings — API Services, иконка, описание |
| 4 | File → Publish to Roblox |
| 5 | Creator Hub → Public → пригласите друзей на playtest |
Чек-лист практикума
-
ServerHandlerподключает все модули черезtask.spawn -
DataModuleгрузит и сохраняет данные; есть retry при ошибкеDataStore -
BindToCloseи автосохранение работают -
SpawnPartsне даёт перепрыгнуть этап -
KillParts/DamagePartsтолько на сервере - Группа коллизий
ObbyPlayersвключена - Монеты и значки выдаются один раз
- Магазин за монеты и
ProcessReceiptдля Robux протестированы - HUD через
StatsUpdated - Игра опубликована и проверена вне Studio
См. также
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Разработка игр — это процесс создания видеоигр, который включает в себя множество этапов, от идеи и концепции до финального продукта. Термин * Разработка компьютерных и видеоигр — одна из наиболее интердисциплинарных и кооперативных областей в индустрии информационных технологий. Игровой движок как платформа - подсистемы рендеринга, физики, ввода и сценариев, ускоряющие создание видеоигр. Roblox Studio не является традиционным игровым движком, но представляет собой платформу как услугу (PaaS) с ограниченной, но эффективной средой разработки. Языки для игр на примере Unity - роль C#, стандартная библиотека, сборка мусора и продуктивность разработки под движок. Моделирование — это процесс создания трёхмерных объектов, называемых моделями, для последующего использования в цифровых средах, особенно в видеоиграх. Текстура в контексте разработки игр — это изображение, накладываемое на поверхность трёхмерной модели с целью придания ей визуальной детализации, цвета, рельефа и других свойств внешнего вида. Гейм-дизайн — три уровня опыта, MDA, механики и баланс; вход в углублённый маршрут и связь с Unity. Верхний уровень гейм-дизайна — субъективный опыт игрока, модели Bartle и Yee, персоны, вопросы experience design. Механики как строительные блоки гейм-дизайна — существительные и глаголы, пространство состояний, семейства control, progression, uncertainty, resource management. Как механики складываются в системы — цепочки конверсии, положительная и отрицательная обратная связь, эмерджентность и настройка баланса.Процесс разработки видеоигр
Дорожная карта геймдева
Команда разработки
Игровой движок
Виды игровых движков
Языки программирования игр
Моделирование
Текстуры
Гейм-дизайн
Опыт игрока и мотивационные модели
Механики и пространство состояний
Системы, петли обратной связи и баланс