Roblox · Королевская битва · урок 6

Самолёт и раунд

Самолёт пролетает над островом, камера смотрит на него, пробел — прыжок. Дальше бой: зона сужается, «Живых: N», последний выживший побеждает и получает +1 к победам.

Структура

Синим — что добавляем на этом уроке.

Workspace
├─ PlaneStart, PlaneEnd (Part) — невидимые, высоко над краями острова
├─ SpawnRange (Part) — невидимая плита во весь остров — над ней можно прыгать
└─ MapSpawn (Part) — больше не нужна — удали
ServerStorage
├─ Plane (Model) — PrimaryPart — Body; все детали Anchored, CanCollide выкл.
└─ RoundStart (BindableEvent) — лобби → раунд
ReplicatedStorage
└─ PlaneEvent (RemoteEvent)
ServerScriptService
└─ RoundServer (Script) — вместо ZoneServer — удали его
Workspace → Lobby → Gate
└─ GateScript (Script) — обновлён: отправляет игроков в раунд
StarterPlayer → StarterCharacterScripts
└─ PlaneLocal (LocalScript)
StarterGui → RoundGui
├─ AliveText, CenterText (TextLabel) — «Живых: N» и подсказка/победитель
└─ RoundLocal (LocalScript) — обновлён

Шаги

  1. Зачем самолёт: игрок сам выбирает, где высадиться, а старт не превращается в драку в одной точке. Кто не прыгнул — сбросим в конце маршрута.
  2. Самолёт (или автобус, как в Fortnite): модель «Plane» с главной деталью «Body» = PrimaryPart. Все детали Anchored, CanCollide выкл. Положи в ServerStorage — каждый раунд берётся копия.
  3. PlaneStart и PlaneEnd — невидимые детали на высоте ~200 над противоположными краями острова (PlaneEnd — чуть внутри острова). SpawnRange — невидимая плоская деталь во весь остров.
  4. ServerStorage → BindableEvent «RoundStart»: GateScript сообщает им RoundServer, кого отправлять (BindableEvent — как RemoteEvent, но между серверными скриптами). ReplicatedStorage → RemoteEvent «PlaneEvent».
  5. RoundServer: сажает игроков в самолёт (персонаж закреплён и летит вместе с ним), ведёт самолёт, выпускает по пробелу только над SpawnRange, запускает волны зоны из ZoneModule, считает живых, награждает победителя.
  6. Победы сохраняются в DataStore — в Studio включи Game Settings → Security → Enable Studio Access to API Services. Все вызовы DataStore — в pcall: если сервер Roblox не ответит, игра не сломается.
  7. Методичка ждала 20 игроков и убивала победителя. Здесь раунд начинается с любым числом, а победитель возвращается в лобби. Если в раунде один игрок — раунд идёт до его смерти (так удобно проверять).
  8. Проверка на нескольких игроках: Test → Clients and Servers → Local Server, 2–3 игрока → Start.

Скрипты

RoundServer

Где: ServerScriptService · Script
Зачем: Самолёт, бой, победа.

-- игроки
local Players = game:GetService("Players")
-- атрибуты раунда и события
local RS = game:GetService("ReplicatedStorage")
-- образец самолёта
local SS = game:GetService("ServerStorage")
-- каждый кадр
local RunService = game:GetService("RunService")
-- сохранение побед
local DSS = game:GetService("DataStoreService")
-- модуль зоны
local Zone = require(script.Parent:WaitForChild("ZoneModule"))
-- хранилище
local store = DSS:GetDataStore("BattleRoyale")
-- лобби отправило игроков
local roundStart = SS:WaitForChild("RoundStart")
-- самолёт (PrimaryPart — Body)
local planeSample = SS:WaitForChild("Plane")
-- начало маршрута
local planeStart = workspace:WaitForChild("PlaneStart")
-- конец маршрута
local planeEnd = workspace:WaitForChild("PlaneEnd")
-- над этой областью можно прыгать
local spawnRange = workspace:WaitForChild("SpawnRange")
-- куда вернуть победителя
local lobbySpawn = workspace:WaitForChild("Lobby"):WaitForChild("SpawnLocation")
-- прыжок из самолёта
local planeEvent = RS:WaitForChild("PlaneEvent")
-- секунд полёта от края до края
local FLIGHT_TIME = 30
-- игрок → его персонаж: кто ещё жив в раунде
local alive = {}
-- игрок → true: кто ещё в самолёте
local onBoard = {}
-- самолёт сейчас
local plane = nil
-- Lobby, Plane или Fight
RS:SetAttribute("Phase", "Lobby")
-- имени победителя пока нет
RS:SetAttribute("Winner", "")

-- игрок зашёл: таблица лидеров
local function Setup(player)
    -- папка
    local stats = Instance.new("Folder")
    -- так Roblox показывает её в таблице справа
    stats.Name = "leaderstats"
    -- в игрока
    stats.Parent = player
    -- победы
    local wins = Instance.new("IntValue")
    -- имя
    wins.Name = "Wins"
    -- в таблицу
    wins.Parent = stats
    -- загружаем (pcall — сервер может не ответить)
    local ok, value = pcall(function() return store:GetAsync(player.UserId .. "Wins") end)
    -- были победы — ставим
    if ok and value then wins.Value = value end
-- конец функции
end
-- сохранить победы
local function Save(player)
    -- таблица
    local stats = player:FindFirstChild("leaderstats")
    -- сохраняем
    if stats then pcall(function() store:SetAsync(player.UserId .. "Wins", stats.Wins.Value) end) end
-- конец функции
end
-- новые игроки
Players.PlayerAdded:Connect(Setup)
-- кто зашёл раньше скрипта
for _, player in ipairs(Players:GetPlayers()) do Setup(player) end
-- вышел
Players.PlayerRemoving:Connect(function(player)
    -- сохраняем
    Save(player)
    -- больше не в раунде
    alive[player] = nil
    -- и не в самолёте
    onBoard[player] = nil
-- конец функции
end)
-- сервер выключается — сохраняем всех
game:BindToClose(function() for _, player in ipairs(Players:GetPlayers()) do Save(player) end end)

-- самолёт над островом?
local function OverRange(position)
    -- координаты относительно SpawnRange
    local p = spawnRange.CFrame:PointToObjectSpace(position)
    -- внутри по ширине и длине
    return math.abs(p.X) <= spawnRange.Size.X / 2 and math.abs(p.Z) <= spawnRange.Size.Z / 2
-- конец функции
end
-- выпустить игрока из самолёта
local function Drop(player)
    -- уже прыгнул
    if not onBoard[player] or not plane then return end
    -- больше не в самолёте
    onBoard[player] = nil
    -- интерфейс: подсказка пропадает
    player:SetAttribute("OnPlane", false)
    -- камера снова за персонажем
    planeEvent:FireClient(player, false)
    -- центр персонажа
    local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
    -- персонажа нет
    if not root then return end
    -- под самолётом
    root.CFrame = plane:GetPivot() * CFrame.new(0, -15, 0)
    -- падает
    root.Anchored = false
-- конец функции
end
-- игрок нажал пробел
planeEvent.OnServerEvent:Connect(function(player)
    -- только над островом
    if plane and OverRange(plane:GetPivot().Position) then Drop(player) end
-- конец функции
end)

-- посадить игроков в самолёт
local function Board(list)
    -- новый раунд
    table.clear(alive)
    -- самолёт пуст
    table.clear(onBoard)
    -- каждый из лобби
    for _, player in ipairs(list) do
        -- персонаж
        local character = player.Character
        -- Humanoid
        local humanoid = character and character:FindFirstChildOfClass("Humanoid")
        -- центр
        local root = character and character:FindFirstChild("HumanoidRootPart")
        -- живой и в игре
        if player.Parent and humanoid and root and humanoid.Health > 0 then
            -- в раунде
            alive[player] = character
            -- в самолёте
            onBoard[player] = true
            -- летит вместе с самолётом, а не падает
            root.Anchored = true
            -- интерфейс: «Пробел — прыгнуть»
            player:SetAttribute("OnPlane", true)
            -- камера смотрит на самолёт
            planeEvent:FireClient(player, true)
        -- конец проверки
        end
    -- конец перебора
    end
-- конец функции
end

-- полёт самолёта от PlaneStart к PlaneEnd
local function Fly()
    -- в начале смотрит на конец
    local startCF = CFrame.lookAt(planeStart.Position, planeEnd.Position)
    -- в конце — тот же поворот
    local endCF = startCF.Rotation + planeEnd.Position
    -- новый самолёт
    plane = planeSample:Clone()
    -- в начало
    plane:PivotTo(startCF)
    -- в мир
    plane.Parent = workspace
    -- сколько летим
    local t = 0
    -- пока не долетели
    while t < FLIGHT_TIME do
        -- прошёл кадр
        t += RunService.Heartbeat:Wait()
        -- точка на маршруте
        local cf = startCF:Lerp(endCF, math.min(t / FLIGHT_TIME, 1))
        -- самолёт летит
        plane:PivotTo(cf)
        -- кто внутри
        for player in pairs(onBoard) do
            -- центр
            local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
            -- внутри самолёта
            if root then root.CFrame = cf end
        -- конец перебора
        end
    -- конец полёта
    end
    -- кто не прыгнул — сбрасываем в конце маршрута
    for player in pairs(onBoard) do Drop(player) end
    -- самолёт улетел
    plane:Destroy()
    -- самолёта нет
    plane = nil
-- конец функции
end

-- убрать погибших и вышедших; вернуть число живых
local function CheckAlive()
    -- счётчик и последний живой
    local count, last = 0, nil
    -- каждый в раунде
    for player, character in pairs(alive) do
        -- Humanoid
        local humanoid = character:FindFirstChildOfClass("Humanoid")
        -- вышел, возродился или погиб
        if not player.Parent or player.Character ~= character or not humanoid or humanoid.Health <= 0 then
            -- выбыл
            alive[player] = nil
        -- жив
        else
            -- считаем
            count += 1
            -- запоминаем
            last = player
        -- конец проверки
        end
    -- конец перебора
    end
    -- интерфейс: «Живых: N»
    RS:SetAttribute("Alive", count)
    -- число и последний
    return count, last
-- конец функции
end

-- весь раунд
local function RunRound(list)
    -- полёт
    RS:SetAttribute("Phase", "Plane")
    -- в самолёт
    Board(list)
    -- один игрок — раунд до его смерти (для проверки)
    local solo = CheckAlive() <= 1
    -- летим 30 секунд
    Fly()
    -- бой
    RS:SetAttribute("Phase", "Fight")
    -- раунд идёт
    local running = true
    -- волны зоны — отдельно
    task.spawn(Zone.Run, function() return running end)
    -- победитель
    local winner = nil
    -- раз в секунду
    while true do
        -- секунда
        task.wait(1)
        -- кто жив
        local count, last = CheckAlive()
        -- список живых
        local players = {}
        -- собираем
        for player in pairs(alive) do table.insert(players, player) end
        -- урон тем, кто за зоной
        Zone.Hurt(players)
        -- остался один (или никого)
        if count == 0 or (count == 1 and not solo) then
            -- он победил
            winner = last
            -- раунд окончен
            break
        -- конец проверки
        end
    -- конец цикла
    end
    -- зона останавливается
    running = false
    -- есть победитель
    if winner then
        -- +1 победа
        winner.leaderstats.Wins.Value += 1
        -- все видят имя
        RS:SetAttribute("Winner", winner.DisplayName)
    -- конец проверки
    end
    -- празднуем
    task.wait(6)
    -- надпись пропадает
    RS:SetAttribute("Winner", "")
    -- предупреждения гаснут
    for _, player in ipairs(Players:GetPlayers()) do player:SetAttribute("OutsideZone", false) end
    -- победитель — в лобби
    if winner and winner.Character then winner.Character:PivotTo(lobbySpawn.CFrame + Vector3.new(0, 4, 0)) end
    -- раунда больше нет
    table.clear(alive)
    -- зона снова большая
    Zone.Reset()
    -- лобби снова набирает игроков
    RS:SetAttribute("Phase", "Lobby")
-- конец функции
end

-- лобби отправило игроков
roundStart.Event:Connect(function(list)
    -- раунд не идёт — начинаем
    if RS:GetAttribute("Phase") == "Lobby" and #list > 0 then RunRound(list) end
-- конец функции
end)

PlaneLocal

Где: StarterPlayer → StarterCharacterScripts · LocalScript
Зачем: Камера и прыжок.

-- общее хранилище
local RS = game:GetService("ReplicatedStorage")
-- каждый кадр
local RunService = game:GetService("RunService")
-- прыжок
local UIS = game:GetService("UserInputService")
-- игроки
local Players = game:GetService("Players")
-- сервер: «ты в самолёте» / «ты прыгнул»
local planeEvent = RS:WaitForChild("PlaneEvent")
-- камера
local camera = workspace.CurrentCamera
-- уже в самолёте?
local onPlane = Players.LocalPlayer:GetAttribute("OnPlane") == true

-- сервер сообщил
planeEvent.OnClientEvent:Connect(function(value)
    -- запоминаем
    onPlane = value
    -- прыгнул — камера за персонажем
    if not value then camera.CameraType = Enum.CameraType.Custom end
-- конец функции
end)
-- каждый кадр
RunService.RenderStepped:Connect(function()
    -- самолёт
    local plane = workspace:FindFirstChild("Plane")
    -- не летим
    if not onPlane or not plane then return end
    -- камерой управляет скрипт
    camera.CameraType = Enum.CameraType.Scriptable
    -- где самолёт
    local cf = plane:GetPivot()
    -- сзади-сверху смотрим на самолёт
    camera.CFrame = CFrame.lookAt(cf.Position - cf.LookVector * 120 + Vector3.new(0, 50, 0), cf.Position)
-- конец функции
end)
-- пробел (или кнопка прыжка на телефоне)
UIS.JumpRequest:Connect(function()
    -- просим выпрыгнуть — сервер проверит, что над островом
    if onPlane then planeEvent:FireServer() end
-- конец функции
end)

GateScript

Где: Workspace → Lobby → Gate · Script
Зачем: Отправка в раунд.

-- игроки
local Players = game:GetService("Players")
-- события
local RS = game:GetService("ReplicatedStorage")
-- сигнал начала раунда
local SS = game:GetService("ServerStorage")
-- модель Gate
local gate = script.Parent
-- модель Lobby
local lobby = gate.Parent
-- светящаяся рамка — войти в неё
local gatePart = gate:WaitForChild("Gate")
-- табло над воротами
local board = gate:WaitForChild("GuiPart"):WaitForChild("Board"):WaitForChild("Frame")
-- площадка готовых
local readyPart = lobby:WaitForChild("ReadyZone"):WaitForChild("TeleportPart")
-- куда вернуть, если передумал
local leavePart = lobby:WaitForChild("LeaveRoomPart")
-- кнопка «Выйти»
local leaveEvent = RS:WaitForChild("LeaveEvent")
-- затемнение экрана
local teleportingEvent = RS:WaitForChild("TeleportingEvent")
-- BindableEvent: RoundServer начинает раунд
local roundStart = SS:WaitForChild("RoundStart")
-- мест в раунде
local MAX_PLAYERS = 30
-- для проверки одному; в настоящей игре поставь 2 и больше
local MIN_PLAYERS = 1
-- секунд отсчёта
local WAIT_TIME = 30
-- игрок → true: кто вошёл в ворота
local ready = {}
-- идёт отправка — ворота закрыты
local sending = false

-- сколько готовых
local function Count()
    -- счётчик
    local n = 0
    -- каждый готовый
    for player in pairs(ready) do
        -- вышел из игры — убираем
        if player.Parent then n += 1 else ready[player] = nil end
    -- конец перебора
    end
    -- число
    return n
-- конец функции
end
-- обновить табло
local function UpdateBoard(status, seconds)
    -- готовые
    board.Players.Text = "Игроков: " .. Count() .. "/" .. MAX_PLAYERS
    -- состояние
    board.Status.Text = status
    -- отсчёт
    board.Time.Text = seconds and (seconds .. " с") or ""
-- конец функции
end

-- кто-то вошёл в ворота
gatePart.Touched:Connect(function(hit)
    -- модель персонажа
    local character = hit.Parent
    -- игрок (а не NPC или предмет)
    local player = Players:GetPlayerFromCharacter(character)
    -- Humanoid
    local humanoid = character:FindFirstChildOfClass("Humanoid")
    -- уже готов, отправка или погиб
    if not player or ready[player] or sending or not humanoid or humanoid.Health <= 0 then return end
    -- раунд уже идёт
    if RS:GetAttribute("Phase") ~= "Lobby" then return end
    -- мест нет
    if Count() >= MAX_PLAYERS then return end
    -- готов
    ready[player] = true
    -- на площадку готовых
    character:PivotTo(readyPart.CFrame + Vector3.new(math.random(-6, 6), 3, math.random(-6, 6)))
    -- показать кнопку «Выйти»
    leaveEvent:FireClient(player, true)
-- конец функции
end)
-- нажал «Выйти»
leaveEvent.OnServerEvent:Connect(function(player)
    -- не в списке или уже отправляем
    if not ready[player] or sending then return end
    -- не готов
    ready[player] = nil
    -- обратно в лобби
    if player.Character then player.Character:PivotTo(leavePart.CFrame + Vector3.new(0, 3, 0)) end
    -- спрятать кнопку
    leaveEvent:FireClient(player, false)
-- конец функции
end)
-- игрок зашёл
local function Setup(player)
    -- персонаж пропал — не готов
    player.CharacterRemoving:Connect(function() ready[player] = nil end)
-- конец функции
end
-- новые игроки
Players.PlayerAdded:Connect(Setup)
-- кто зашёл раньше скрипта
for _, player in ipairs(Players:GetPlayers()) do Setup(player) end

-- бесконечно
while true do
    -- идёт раунд
    while RS:GetAttribute("Phase") ~= "Lobby" do
        -- табло
        UpdateBoard("ИДЁТ РАУНД")
        -- ждём
        task.wait(1)
    -- конец ожидания
    end
    -- отсчёт с начала
    local left = WAIT_TIME
    -- пока не 0
    while left > 0 do
        -- мало игроков
        if Count() < MIN_PLAYERS then
            -- отсчёт заново
            left = WAIT_TIME
            -- табло
            UpdateBoard("ЖДЁМ ИГРОКОВ")
        -- хватает
        else
            -- табло с секундами
            UpdateBoard("ГОТОВЬСЯ", left)
            -- минус секунда
            left -= 1
        -- конец проверки
        end
        -- секунда
        task.wait(1)
    -- конец отсчёта
    end
    -- ворота закрыты
    sending = true
    -- кого отправляем
    local list = {}
    -- готовые
    for player in pairs(ready) do
        -- ещё в игре
        if player.Parent then
            -- в список
            table.insert(list, player)
            -- кнопка «Выйти» больше не нужна
            leaveEvent:FireClient(player, false)
            -- экран темнеет
            teleportingEvent:FireClient(player)
        -- конец проверки
        end
    -- конец перебора
    end
    -- список пуст
    table.clear(ready)
    -- табло
    UpdateBoard("ОТПРАВКА")
    -- экран успел потемнеть
    task.wait(2)
    -- RoundServer сажает их в самолёт
    roundStart:Fire(list)
    -- раунд успел начаться
    task.wait(1)
    -- ворота снова открыты
    sending = false
-- конец цикла
end

RoundLocal

Где: StarterGui → RoundGui · LocalScript
Зачем: Живые и победитель.

-- игроки
local Players = game:GetService("Players")
-- атрибуты раунда
local RS = game:GetService("ReplicatedStorage")
-- этот игрок
local player = Players.LocalPlayer
-- RoundGui
local gui = script.Parent
-- таймер зоны
local timerFrame = gui:WaitForChild("TimerFrame")
-- что происходит
local info = timerFrame:WaitForChild("Info")
-- мм:сс
local timeLabel = timerFrame:WaitForChild("Time")
-- «Ты за зоной!»
local warnText = gui:WaitForChild("WarnText")
-- сколько живых
local aliveText = gui:WaitForChild("AliveText")
-- подсказка и победитель
local centerText = gui:WaitForChild("CenterText")

-- обновить таймер
local function UpdateTimer()
    -- текст от модуля зоны
    local text = RS:GetAttribute("ZoneText") or ""
    -- секунды
    local left = RS:GetAttribute("TimeLeft") or 0
    -- нет раунда — таймер скрыт
    timerFrame.Visible = text ~= ""
    -- текст
    info.Text = text
    -- минуты и секунды двумя цифрами
    timeLabel.Text = string.format("%02d:%02d", left // 60, left % 60)
-- конец функции
end
-- текст сменился
RS:GetAttributeChangedSignal("ZoneText"):Connect(UpdateTimer)
-- секунда прошла
RS:GetAttributeChangedSignal("TimeLeft"):Connect(UpdateTimer)
-- сразу
UpdateTimer()

-- предупреждение
local function UpdateWarn()
    -- за зоной — видно
    warnText.Visible = player:GetAttribute("OutsideZone") == true
-- конец функции
end
-- вышел или вернулся
player:GetAttributeChangedSignal("OutsideZone"):Connect(UpdateWarn)
-- сразу
UpdateWarn()

-- живые, подсказка, победитель
local function UpdateRound()
    -- имя победителя
    local winner = RS:GetAttribute("Winner") or ""
    -- счётчик — только во время боя
    aliveText.Visible = RS:GetAttribute("Phase") == "Fight"
    -- сколько
    aliveText.Text = "Живых: " .. (RS:GetAttribute("Alive") or 0)
    -- раунд закончен
    if winner ~= "" then
        -- поздравляем
        centerText.Text = "Победитель: " .. winner
    -- в самолёте
    elseif player:GetAttribute("OnPlane") then
        -- подсказка
        centerText.Text = "Пробел — прыгнуть"
    -- иначе
    else
        -- пусто
        centerText.Text = ""
    -- конец проверки
    end
-- конец функции
end
-- атрибуты раунда
for _, name in ipairs({"Phase", "Alive", "Winner"}) do
    -- изменился — обновить
    RS:GetAttributeChangedSignal(name):Connect(UpdateRound)
-- конец перебора
end
-- сел в самолёт или прыгнул
player:GetAttributeChangedSignal("OnPlane"):Connect(UpdateRound)
-- сразу
UpdateRound()

Проверка

Запусти Play (или Run) и убедись: