Ten przewodnik przedstawia różne techniki tworzenia wysokiej jakości, płynnych gier wieloosobowych przy użyciu modelu autorytetu serwera.
Predykcyjna kreacja instancji (zszywanie instancji)
Zszywanie instancji pozwala skryptom klientów na predykcyjne tworzenie Instances w callbackach RunService:BindToSimulation(). Klient tworzy natychmiast Instance, nie czekając na okrążenie serwera; kiedy dotrze autorytatywna kopia z serwera, instancja utworzona przez klienta i autorytatywna kopia serwera są scalane w jedną. Z perspektywy twojego skryptu, Instance istnieje natychmiast i jest spójne z serwerem.
Zszywanie instancji jest przydatne w przypadkach, gdy instancja musi być widoczna i aktywna na kliencie tak szybko, jak to możliwe. Chociaż serwer w końcu zreplikuje każdą instancję, której klient potrzebuje (razem z efektami, które miały na świat), proces ten wiąże się z co najmniej jednym okrążeniem opóźnienia związanego z komunikacją z serwerem. Przykładami są wystrzelenie wyrzutni rakiet i stworzenie ograniczeń fizycznych — bez zszywania klient zobaczy, jak rakieta pojawia się daleko od niego, lub pewne drgania, gdy nowe ograniczenia replikują się do niego.
Zachowanie techniczne
Zszywanie instancji działa poprzez generowanie tego samego deterministycznego GUID na kliencie i serwerze. GUID jest pochodną czterech wejść: typu tworzonej Instance, tożsamości źródła (patrz niżej), bieżącej klatki symulacji oraz lokalnego licznika wywołań, który resetuje się z każdą klatką.
- Dla Instance.new() — Źródłem jest sam skrypt (dwa skrypty z tym samym tekstem są uważane za różne).
- Dla Instance.fromExisting() — Źródłem jest Instance, dla którego wywołujesz Instance.fromExisting().
- Dla Instance:Clone() — Każda sklonowana instancja używa GUID źródłowej instancji jako ziarna kontekstu.
Jeżeli klient i serwer zgadzają się co do wejść, produkują pasujące GUID-y i zszywanie wykonuje się pomyślnie.
Wdrożenie
Aby skorzystać z zszywania instancji, wywołaj Instance.new(), Instance:Clone() lub Instance.fromExisting() w callbacku RunService:BindToSimulation() z ModuleScript, który jest wymagany zarówno po stronie klienta, jak i serwera. Nic więcej nie jest wymagane z twojej strony; system automatycznie obsługuje przypisanie i uzgadnianie GUID.
Możesz swobodnie ustawiać właściwości, które nie mają dostępu do symulacji, takie jak Name, Size lub Parent na instancji, zanim zostanie ona współdzielona w DataModel.
Symulacja (ModuleScript) - Utwórz instancję w callbacku BindToSimulation()
local RunService = game:GetService("RunService")
local Simulation = {}
Simulation.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
local part = Instance.new("Part")
part.Name = "PredictedPart"
part.Size = Vector3.new(2, 2, 2)
part.Parent = workspace -- Część jest teraz w DataModel; wszelkie zmiany bez dostępu do symulacji będą błędne po tym
-- Część istnieje natychmiast na kliencie i zostanie uzgodniona z serwerem
end)
end
return Simulation
Instance:Clone() i Instance.fromExisting() zwracają poprawne zszywanie, gdy instancja źródłowa została zreplikowana zarówno na kliencie, jak i na serwerze; obie strony klonują z odpowiadających GUID-ów źródłowych i produkują pasujące przewidywane GUID-y.
Symulacja (ModuleScript) - Klonuj instancję w callbacku BindToSimulation()
local RunService = game:GetService("RunService")
local Simulation = {}
local sourceTemplate -- zreplikowana instancja
Simulation.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
local cloned = sourceTemplate:Clone()
cloned.Parent = workspace
-- Klatka klonowana jest zszywana z autorytatywną kopią serwera
end)
end
return Simulation
Wygładzanie pozycji
Możesz wizualnie wygładzić pozycję nieprawidłowo przewidzianych obiektów synchronizowanych, renderując inny obiekt niż ten, który jest symulowany.
- Uczyń obiekt symulowany niewidocznym.
- Stwórz obiekt renderer jako bezmasowy, niekolizyjny, wizualny klon do śledzenia symulowanego obiektu.
- Dołącz skrypt do obiektu renderera, który płynnie śledzi pozycję niewidocznego, symulowanego obiektu. Ta separacja między renderowaniem a symulacją pozwala na zmianę pozycji obiektu renderera w celu uzyskania wizualnie płynnego doświadczenia.
W następującym przykładzie Script, renderowany obiekt (rodzic) płynnie śledzi symulowany obiekt. Renderowany obiekt jest zawsze nieco "za" symulowanym obiektem, co zazwyczaj jest w porządku, ale może być niepożądane w niektórych sytuacjach.
Płynne śledzenie pozycji BasePart za pomocą obiektu renderer
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Workspace = game:GetService("Workspace")
-- Obiekt do płynnego śledzenia
local smoothTarget:BasePart = Workspace.SimulatedPart
-- Wizualny obiekt, który będzie wygładzany
local renderer:BasePart = script.Parent
-- Czas na wygładzanie; mniejszy oznacza szybsze
local smoothTime = 0.07
-- Przechowuj dane potrzebne do obliczenia płynnej pozycji
local smoothVelocity = Vector3.new()
-- Wyłącz fizykę obiektu renderera
renderer.Massless = true
renderer.Anchored = true
renderer.CanCollide = false
RunService.RenderStepped:Connect(function(deltaTime: number)
-- Płynnie śledź obiekt docelowy
local smoothPosition, smoothVelocity = TweenService:SmoothDamp(
renderer.Position,
smoothTarget.Position,
smoothVelocity,
smoothTime,
math.huge,
deltaTime)
renderer.Position = smoothPosition
end)
Gra Soccer wykorzystuje wariację tej techniki, aby inteligentniej włączać i wyłączać wygładzanie pozycji dla piłki nożnej. Konkretnie, piłka nożna wygładza swoją pozycję tylko wtedy, gdy symulowana piłka "skoczyła" wystarczająco daleko od renderowanej piłki. To podejście zapewnia najlepsze z obu światów: piłka nożna nie ma wizualnego opóźnienia w normalnych warunkach, a gra płynnie interpoluje jej pozycję dopiero po tym, jak symulowana piłka niespodziewanie przeskoczyła w nowe miejsce, prawdopodobnie z powodu artefaktu sieciowego lub zmiany po stronie serwera.
Pisanie kodu animacji
Pod autorytetem serwera, symulacja klienta może być cofnięta i ponownie symulowana gdy serwer poprawia nieprawidłowe przewidywanie. Podczas cofania, stan animacji jest cofywany, co oznacza, że AnimationTrack obsługujące, które zapisałeś w wcześniejszych klatkach, mogą być już nieważne.
Lustrzana logika animacji
Jak w przypadku każdej kluczowej logiki rozgrywki, logika kontrolująca animacje musi być zsynchronizowana między serwerem a klientem, w przeciwnym razie mogą wystąpić nieprawidłowe przewidywania i drgania. Zobacz synchronizację symulacji dla wzoru, który wiąże funkcje przez RunService:BindToSimulation() w ModuleScript, który jest inicjowany zarówno po stronie klienta, jak i serwera.
Unikaj buforowania torów
Powszechnym wzorem w skryptach bez autorytetu serwera jest buforowanie obiektów AnimationTrack podczas ładowania i nieograniczone ich ponowne używanie. Ten wzór zawodzi w grze z autorytetem serwera, gdy serwer poprawia nieprawidłowe przewidywanie, a klient cofa/powtarza swoją symulację z poprawionymi danymi. Jeśli twój skrypt nadal ma odniesienie do zatrzymanego lub zastąpionego toru, wywołania takie jak AdjustWeight() lub AdjustSpeed() będą działać na torze, który nie jest już wizualnie reprezentowany.
Buforuj tory po stronie klienta (niesprawdzony)
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local player = Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")
local animator = humanoid:WaitForChild("Animator")
-- Buforuj tory animacji
local tracks = {}
tracks["WalkForward"] = animator:LoadAnimation(walkForwardAnim)
RunService:BindToSimulation(function(dt: number)
tracks["WalkForward"]:AdjustSpeed(1 + math.cos(time()))
end)
Zamiast trzymać obiekty torów, przechowuj identyfikatory animacji (lub instancje Animation) i zapytaj Animator o żywy tor, kiedy potrzebujesz z nim interać. Dwie API są dostępne:
- Animator:GetTrackByAnimationId() — Zwraca aktualnie aktywny tor dla konkretnego identyfikatora animacji lub nil, jeśli nie ma aktywnych animacji z tym identyfikatorem. Użyj tego, gdy wiesz, którą konkretną animację chcesz znaleźć.
- Animator:GetPlayingAnimationTracks() — Zwraca wszystkie aktywne tory (grające, wygasające lub wstrzymane). Użyj tego, gdy musisz przejść przez wszystko, co jest aktywne (na przykład, aby zatrzymać wszystkie animacje lub znaleźć tory według jakiegoś kryterium).
ModuleScript o nazwie CustomAnimate w ReplicatedStorage:
CustomAnimate
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local CustomAnimate = {}
-- Przechowuj odniesienia do animacji (nie załadowane tory)
local animations = {
WalkForward = ReplicatedStorage.Animations.WalkForward,
}
local function getOrLoadTrack(animator: Animator, animation: Animation): AnimationTrack
local track = animator:GetTrackByAnimationId(animation.AnimationId)
if not track then
track = animator:LoadAnimation(animation)
end
return track
end
CustomAnimate.SyncAnimations = function(character)
local humanoid = character:WaitForChild("Humanoid")
local animator = humanoid:WaitForChild("Animator")
RunService:BindToSimulation(function(dt: number)
local walkTrack = getOrLoadTrack(animator, animations.WalkForward)
if not walkTrack.isPlaying then
walkTrack.Looped = true
walkTrack.Priority = Enum.AnimationPriority.Core
walkTrack:Play()
end
walkTrack:AdjustSpeed(1 + math.cos(time()))
end)
end
return CustomAnimate
Odgrywanie dźwięków i efektów wizualnych
W przewidywanej symulacji możliwe jest wyzwolenie efektów lub dźwięków dla zdarzeń, które klient przewidywał, że się wydarzą, ale które nigdy nie miały miejsca na serwerze. System renderowania powinien być przygotowany do "cofania" wszelkich nieprawidłowych efektów. Na przykład, klient może przewidzieć, że granat eksplodował i wyzwala efekt cząsteczkowy, ale jeśli inny gracz rozbraja granat, klient powinien ukryć efekt cząsteczkowy.
Dobrą strategią na renderowanie przewidywanej symulacji jest synchronizacja wzoru maszyny stanów wewnątrz pętli symulacji i renderowanie zmian stanu w funkcji kroku renderowania. Następujący przykład symuluje granat za pomocą wzoru maszyny stanów:
Prosta maszyna stanów do śledzenia granatu (ModuleScript)
local Workspace = game:GetService("Workspace")
local module = {}
module.GrenadeStates = {
Idle = 0,
Lit = 1,
Exploded = 2,
Defused = 3,
}
module.GrenadeExplodeTime = 3.0
module.Initialize = function(grenade)
RunService:BindToSimulation(function(deltaTime)
-- Zainicjuj pusty stan granatu
local grenadeState = grenade:GetAttribute("State")
if grenadeState == nil then
grenadeState = module.GrenadeStates.Idle
grenade:SetAttribute("State", grenadeState)
grenade:SetAttribute("Timer", 0.0)
end
-- Zwiększ timer granatu
local timer = grenade:GetAttribute("Timer")
timer = timer + deltaTime
grenade:SetAttribute("Timer", timer)
-- Eksploduj zapalone granaty
if grenadeState == module.GrenadeStates.Lit then
if timer >= module.GrenadeExplodeTime then
grenadeState = module.GrenadeStates.Exploded
grenade:SetAttribute("State", grenadeState)
grenade:SetAttribute("Timer", 0.0)
end
end
end)
end
return module
Mając poprzednią maszynę stanów, możesz renderować efekty granatu w połączeniu RunService.RenderStepped w oddzielnym skrypcie opartym na zsynchronizowanym stanie granatu:
Renderuj cząsteczki i dźwięki na podstawie zsynchronizowanego stanu granatu
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Simulation = require(ReplicatedStorage.Simulation)
local grenade = script.Parent
local previousGrenadeState = nil
-- Wskaźnik instancji do wskazania stanu granatu
local highlight = Instance.new("Highlight")
highlight.Parent = grenade
highlight.FillTransparency = 1
highlight.OutlineTransparency = 1
highlight.DepthMode = Enum.HighlightDepthMode.Occluded
RunService.RenderStepped:Connect(function(deltaTime: number)
local grenadeState = grenade:GetAttribute("State")
local grenadeTimer = grenade:GetAttribute("Timer")
-- Emituj cząsteczki, jeśli granat jest zapalony
grenade.LitEmitter.Enabled = grenadeState == Simulation.GrenadeStates.Lit
-- Odtwórz efekt eksplozji, jeśli granat właśnie eksplodował
if previousGrenadeState ~= grenadeState then
if grenadeState == Simulation.GrenadeStates.Exploded and grenadeTimer < 0.2 then
grenade.ExplosionEmitter:Emit(100)
grenade.ExplosionSound:Play()
end
previousGrenadeState = grenadeState
end
-- Zmień kolor podświetlenia granatu w zależności od stanu i czasu
if grenadeState == Simulation.GrenadeStates.Lit then
highlight.FillColor = Color3.fromRGB(255, 0, 0)
highlight.FillTransparency = 1 - (grenadeTimer / Simulation.GrenadeExplodeTime)
elseif grenadeState == Simulation.GrenadeStates.Idle then
highlight.FillTransparency = 1
elseif grenadeState == Simulation.GrenadeStates.Exploded then
highlight.FillTransparency = 1
elseif grenadeState == Simulation.GrenadeStates.Defused then
highlight.FillColor = Color3.fromRGB(0, 255, 125)
highlight.FillTransparency = 0.5
end
end)
Projektowanie z uwzględnieniem opóźnienia sieciowego
Niektóre mechaniki rozgrywki lepiej nadają się do gier wieloosobowych niż inne mechaniki. Gracze zawsze będą mieli pewne opóźnienie między tym, kiedy inny gracz podejmuje akcję a tym, kiedy otrzymują dane wejściowe od tego gracza. Najlepszym sposobem na stworzenie super płynnej gry wieloosobowej jest projektowanie gry z uwzględnieniem tych ograniczeń.
Na przykład, gra o wolniejszym przyspieszeniu ruchu gracza wydaje się płynniejsza niż ta z wyższym przyspieszeniem, ponieważ różnica w pozycji spowodowana opóźnieniem sieciowym wynikającym z wejścia gracza będzie mniejsza w grze o wyższym przyspieszeniu.
Kolejnym przykładem może być mechanika rozgrywki, w której gracze mogą natychmiast wyzwolić dużą eksplozję, naciskając jakieś pole, co spowoduje więcej artefaktów sieciowych niż w przypadku, gdy eksplozja jest opóźniona po wejściu, jakby zapalali lont. To przenosi resymulację na efekt lontu, a nie na efekt eksplozji, co jest mniej zauważalnym artefaktem sieciowym.
Przewidywanie wejść innych graczy
Domyślnie Roblox nie przesyła wejść z każdego klienta do każdego innego klienta. Czy to jest odpowiednie dla twojej gry, zależy od jej projektu:
- Dla podstawowego ruchu humanoidalnego, domyślne zachowanie oznacza, że ruchy postaci innych graczy nie są ekstrapolowane z autorytatywnego stanu serwera i w rezultacie postacie innych graczy nie będą źle przewidzieli, ale będą renderowane nieco w przeszłości.
- W grze wyścigowej, przeciwnie, domyślne zachowanie oznacza, że klienci nie będą wiedzieć, czy inni gracze naciskają na pedał gazu lub wykonują inne polecenia, więc inne samochody mogą wydawać się za lokalnym graczem, nawet jeśli są faktycznie z przodu. Aby złagodzić to, możesz przechować wejścia graczy w atrybutach na serwerze i operować na tych zsynchronizowanych atrybutach po stronie klienta, używając RunService:BindToSimulation(), jak pokazano w poniższym przykładzie kodu oraz w szablonie Racing. Takie podejście pozwala wykorzystać atrybuty jako dane wejściowe do twojej symulacji, aby uzyskać w pełni zreplikowane wejścia graczy.
Przechowywanie wejść gracza w atrybutach (ModuleScript)
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local module = {}
module.storePlayerInput = function(player:Player, humanoidRootPart:BasePart)
local inputContext:InputContext = player.PlayerGui.InputContext
local throttle = inputContext.DefuseAction:GetState()
humanoidRootPart:SetAttribute("Throttle", throttle)
-- Zapisz wszelkie inne wejścia w atrybutach...
end
module.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
if RunService:IsServer() then
-- Przesyłaj wejścia z serwera do wszystkich klientów
for _, player in Players:GetPlayers() do
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local inputContext:InputContext = player.PlayerGui.InputContext
module.storePlayerInput(player, humanoidRootPart)
end
else
-- Zapisz lokalne wejścia gracza jako atrybuty
local player = Players.LocalPlayer
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local inputContext:InputContext = player.PlayerGui.InputContext
module.storePlayerInput(player, humanoidRootPart)
end
-- Użyj atrybutów jako wejść do gry
for _, player in Players:GetPlayers() do
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local throttle = humanoidRootPart:GetAttribute("Throttle")
if throttle then
-- Zastosuj przyspieszenie do pojazdu gracza
end
end
end)
end)
return module
Debugowanie
Istnieje kilka nowych narzędzi i technik, które możesz wykorzystać do debugowania gry z autorytetem serwera.
Wizualizator autorytetu serwera
Naciśnięcie CtrlShiftF6 (Windows) lub ⌘ShiftF6 (Mac) otwiera wizualizator autorytetu serwera w Studio, który pokazuje kilka kluczowych informacji:
| Szczegóły | Opis |
|---|---|
| Wskaźnik sukcesu przewidywania instancji | Procent poprawnie przewidzianych instancji w ciągu ostatnich 8 sekund. |
| Wskaźnik akceptacji wejść | Procent wszystkich wejść graczy, które dotarły na czas do serwera. Spóźnione wejścia obniżają tę liczbę. |
| Delta krok klient-serwer | Liczba klatek między klientem a serwerem, w tym czas dołączenia klienta. Stabilność tej liczby reprezentuje stabilność twojego połączenia z serwerem. |
| RCC liczba FPS | Częstotliwość klatek symulacji na serwerze. Jeśli ta liczba spadnie poniżej 59, serwer nie jest w stanie nadążyć za symulacją, a jakość gry ulegnie pogorszeniu. |
| Liczba przewidywanych instancji | Liczba instancji, które klient przewiduje. |
| Liczba powodów zrzutu wejść | Liczba razy, kiedy serwer odrzucił wejście z każdego powodu:
|
Promień symulacji
Kiedy polegasz na automatycznej predykcji (Enum.PredictionMode.Automatic), możesz wizualizować promień predykcji wokół swojej postaci gracza, włączając Czy regiony są włączone w ustawieniach Studio (AltS na Windows; ⌥S na Mac). Zielony cylinder wskazuje zakres wokół twojej postaci, w którym instancje są przewidywane, a jego promień rośnie i kurczy się w zależności od charakterystyki wydajności urządzenia.
