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

Практикум — королевская битва на Roblox

Roblox

Продолжение маршрута после обби: цикл лобби → отсчёт → бой → победитель, оружие с Raycast на клиенте для визуала и повторной проверкой на сервере. API — workspace:Raycast, Luau и task.*.


Что вы освоите

ТемаНавык
Игровой цикл раундовLobby / Intermission / Combat / Winner
Минимум игроковMIN_PLAYERS, ожидание в while
Таблица участниковcompetitors фиксируется на старт раунда
Конфиг оружияModuleScript Settings в Tool
RaycastКлиент стреляет лучом; сервер сверяет угол и дистанцию
HeadshotМножитель урона по Head
Локальная репликацияReplicate RemoteEvent — трассер и звук

Структура проекта

ServerScriptService
├── ServerHandler
│ ├── Data
│ ├── GameRunner
│ └── Weapons
ReplicatedStorage
├── Remotes
│ ├── Hit (RemoteEvent — заявка о попадании)
│ └── Replicate (RemoteEvent — VFX для всех)
├── Tools
│ └── Blaster (Tool + ModuleScript Settings + LocalScript ToolHandler)
Workspace
└── Effects (папка для временных Part трассеров)
StarterGui
└── RoundHUD (фаза, таймер)

Модуль GameRunner

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

local RoundStatus = ReplicatedStorage.Remotes.RoundStatus :: RemoteEvent

local MIN_PLAYERS = 2
local INTERMISSION = 15
local ROUND_TIME = 300

local GameRunner = {}
local competitors: { Player } = {}

local function broadcast(phase: string, seconds: number?)
RoundStatus:FireAllClients(phase, seconds or 0)
end

local function fillCompetitors()
table.clear(competitors)
for _, p in Players:GetPlayers() do
table.insert(competitors, p)
end
end

function GameRunner.gameLoop()
while true do
broadcast("Waiting", 0)
repeat
task.wait(1)
until #Players:GetPlayers() >= MIN_PLAYERS

for t = INTERMISSION, 1, -1 do
broadcast("Intermission", t)
task.wait(1)
end

fillCompetitors()
for _, p in competitors do
p:LoadCharacter()
end
broadcast("Combat", ROUND_TIME)

local deadline = os.clock() + ROUND_TIME
while os.clock() < deadline do
local alive = 0
local winner: Player? = nil
for _, p in competitors do
local hum = p.Character and p.Character:FindFirstChildOfClass("Humanoid")
if hum and hum.Health > 0 then
alive += 1
winner = p
end
end
if alive <= 1 then
if winner then
broadcast("Winner", 0)
end
break
end
task.wait(0.5)
end

task.wait(8)
end
end

return GameRunner

Data для BR — те же Coins / Wins / Kills, что в обби; ключ DataStore смените, чтобы не смешивать тестовые данные.


Конфиг оружия

Tool/Settings (ModuleScript):

--!strict
export type GunSettings = {
fireMode: "SEMI" | "AUTO",
damage: number,
headshotMultiplier: number,
rateOfFire: number,
range: number,
}

return {
fireMode = "SEMI",
damage = 22,
headshotMultiplier = 2,
rateOfFire = 450,
range = 500,
} :: GunSettings
ПолеСмысл
rateOfFireВыстрелов в минуту; пауза 60 / rateOfFire
rangeДлина луча в студах
fireModeAUTO — цикл при зажатой кнопке

Клиент — ToolHandler и Raycast

--!strict
-- LocalScript в Tool

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

local player = Players.LocalPlayer
local mouse = player:GetMouse()
local tool = script.Parent :: Tool
local handle = tool:WaitForChild("Handle") :: BasePart
local settings = require(tool:WaitForChild("Settings"))
local Hit = ReplicatedStorage.Remotes.Hit :: RemoteEvent
local Replicate = ReplicatedStorage.Remotes.Replicate :: RemoteEvent

local equipped = false
local firing = false

tool.Equipped:Connect(function()
equipped = true
end)
tool.Unequipped:Connect(function()
equipped = false
firing = false
end)

local function castRay(): (Instance?, Vector3, Vector3, Vector3)
local char = player.Character
if not char then
return nil, Vector3.zero, Vector3.zero, Vector3.zero
end
local origin = handle.Position
local direction = (mouse.Hit.Position - origin).Unit * settings.range

local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { char, workspace.Effects }

local result = workspace:Raycast(origin, direction, params)
local pos = if result then result.Position else origin + direction
return result and result.Instance or nil, pos, direction, origin
end

local function drawTracer(origin: Vector3, pos: Vector3)
Replicate:FireServer(tool, origin, pos)
end

local function tryFire()
if not equipped or tool:FindFirstChild("Debounce") and (tool.Debounce :: BoolValue).Value then
return
end
local waitTime = 60 / settings.rateOfFire
local deb = tool:FindFirstChild("Debounce") :: BoolValue?
if deb then deb.Value = true end
task.delay(waitTime, function()
if deb then deb.Value = false end
end)

local hit, pos, direction, origin = castRay()
drawTracer(origin, pos)
if hit then
local relCFrame = hit.CFrame:ToObjectSpace(CFrame.new(pos))
Hit:FireServer(tool, hit, direction, origin, relCFrame)
end
end

mouse.Button1Down:Connect(function()
firing = true
if settings.fireMode == "SEMI" then
tryFire()
else
task.spawn(function()
while firing and equipped do
tryFire()
task.wait(60 / settings.rateOfFire)
end
end)
end
end)

mouse.Button1Up:Connect(function()
firing = false
end)

Replicate.OnClientEvent на всех клиентах создаёт короткий Part-трассер в workspace.Effects и удаляет через Debris.


Сервер — verifyHit и урон

Клиент присылает hit, origin, direction, relCFrame. Сервер не доверяет hit слепо:

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

local function angleBetween(a: Vector3, b: Vector3): number
local cos = math.clamp(a.Unit:Dot(b.Unit), -1, 1)
return math.acos(cos)
end

function Weapons.verifyHit(
shooter: Player,
tool: Tool,
claimedHit: Instance,
direction: Vector3,
origin: Vector3,
relCFrame: CFrame,
settings: any
): boolean
local char = shooter.Character
if not char or not tool:IsDescendantOf(char) then
return false
end
if direction.Magnitude < 0.01 then
return false
end
if (origin - char:GetPivot().Position).Magnitude > 12 then
return false
end
local toTarget = (claimedHit.Position - origin).Unit
if angleBetween(direction.Unit, toTarget) > math.rad(8) then
return false
end
local recomposed = claimedHit.CFrame:ToWorldSpace(relCFrame).Position
if (recomposed - claimedHit.Position).Magnitude > 2 then
return false
end
if (origin - claimedHit.Position).Magnitude > settings.range + 10 then
return false
end
return true
end

function Weapons.applyDamage(shooter: Player, hit: Instance, settings: any)
local model = hit:FindFirstAncestorOfClass("Model")
if not model then return end
local victim = Players:GetPlayerFromCharacter(model)
if not victim or victim == shooter then return end
local hum = model:FindFirstChildOfClass("Humanoid")
if not hum or hum.Health <= 0 then return end
local dmg = settings.damage
if hit.Name == "Head" then
dmg *= settings.headshotMultiplier
end
hum:TakeDamage(dmg)
-- increment Kills in Data if hum died
end

return Weapons

Подключение:

Hit.OnServerEvent:Connect(function(player, tool, hit, direction, origin, relCFrame)
local settings = require(tool:WaitForChild("Settings"))
if not Weapons.verifyHit(player, tool, hit, direction, origin, relCFrame, settings) then
return
end
Weapons.applyDamage(player, hit, settings)
end)
Серверный Raycast

В продакшене часто делают второй workspace:Raycast на сервере из origin по direction и сравнивают цель с ответом клиента. Это дороже по CPU, но устойчивее к подмене hit.


Локальная репликация VFX

ЧтоГде
Трассер, дым, звук выстрелаКлиент стрелка + Replicate для остальных
TakeDamageТолько сервер
UI попаданияFireClient жертве после applyDamage

Подробнее о репликации — Разработка на Roblox.


HUD раунда

RoundStatus.OnClientEvent обновляет TextLabel:

RoundStatus.OnClientEvent:Connect(function(phase: string, seconds: number)
if phase == "Intermission" then
label.Text = "Старт через " .. seconds
elseif phase == "Combat" then
label.Text = "Бой!"
elseif phase == "Winner" then
label.Text = "Победитель определён"
end
end)

Тестирование

СценарийОжидание
1 игрокРаунд ждёт MIN_PLAYERS
2 клиентаУрон только через сервер; убийство засчитывается
Выход mid-roundcompetitors не ломает подсчёт живых
HeadshotУрон x headshotMultiplier только при hit.Name == "Head"

Чек-лист

  • GameRunner проходит полный цикл Waiting → Combat → Winner
  • Settings в Tool, Debounce на Tool
  • Клиент использует RaycastParams, не устаревший FindPartOnRay
  • verifyHit отсекает неверный угол и дистанцию
  • Урон и Kills только на сервере
  • VFX через Replicate, без локального TakeDamage

См. также

См. также

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