Este guia descreve várias técnicas para criar jogos multijogador de alta qualidade e suaves usando o modelo de autoridade do servidor.
Criação preditiva de instâncias (costura de instâncias)
A costura de instâncias permite que scripts do cliente criem preditivamente Instances dentro de callbacks de RunService:BindToSimulation(). O cliente cria a Instance imediatamente sem esperar pela comunicação com o servidor; quando a cópia autoritativa do servidor chega, a instância criada pelo cliente e a cópia autoritativa do servidor são fundidas em uma só. Do ponto de vista do seu script, a Instance existe imediatamente e está consistente com o servidor.
A costura de instâncias é útil em casos onde uma instância deve ser visível e ativa no cliente o mais rápido possível. Embora o servidor eventualmente replique qualquer instância que o cliente precise (junto com quaisquer efeitos que eles tiveram no mundo), esse processo incorrerá em pelo menos uma latência de ida e volta devido à comunicação com o servidor. Exemplos incluem disparar um lançador de foguetes e criar restrições físicas — sem costura, o cliente verá o foguete aparecer longe dele ou alguma tremulação quando as novas restrições se replicarem para ele.
Comportamento técnico
A costura de instâncias funciona gerando o mesmo GUID determinístico tanto no cliente quanto no servidor. O GUID é derivado de quatro entradas: o tipo de Instance sendo criada, a identidade da fonte (veja abaixo), o quadro de simulação atual e um contador de chamadas por script que reinicia a cada quadro.
- Para Instance.new() — A fonte é o próprio script (dois scripts com o mesmo texto são considerados diferentes).
- Para Instance.fromExisting() — A fonte é a Instance na qual você está chamando Instance.fromExisting().
- Para Instance:Clone() — Cada instância clonada usa o GUID da instância fonte como a semente de contexto.
Se cliente e servidor concordarem com as entradas, eles produzem GUIDs correspondentes e a costura é bem-sucedida.
Implementação
Para utilizar a costura de instâncias, chame Instance.new(), Instance:Clone() ou Instance.fromExisting() dentro de um callback de BindToSimulation() de um ModuleScript que é requerido tanto no cliente quanto no servidor. Nada mais é necessário do seu lado; o sistema lida com a atribuição e reconciliação do GUID automaticamente.
Você pode definir livremente propriedades de acesso não-de simulação como Name, Size ou Parent em uma instância antes que ela seja parentada no DataModel.
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 -- A parte agora está no modelo de dados; quaisquer alterações de acesso não-simulação gerarão erro após isso
-- A parte existe imediatamente no cliente e será reconciliada com o servidor
end)
end
return SimulationInstance:Clone() e Instance.fromExisting() costuram corretamente quando a instância fonte foi replicada tanto ao cliente quanto ao servidor; ambos os lados clonam a partir de GUIDs fonte correspondentes e produzem GUIDs preditivos correspondentes.
local RunService = game:GetService("RunService")
local Simulation = {}
local sourceTemplate -- uma Instância replicada
Simulation.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
local cloned = sourceTemplate:Clone()
cloned.Parent = workspace
-- A hierarquia clonada é costurada com a cópia autoritativa do servidor
end)
end
return SimulationSuavização de posição
Você pode suavizar visualmente a posição de objetos sincronizados mal preditivos renderizando um objeto diferente do que está sendo simulado.
- Deixe o objeto simulado invisível.
- Crie um objeto renderer como um clone visual não colidível e sem massa para rastrear o objeto simulado.
- Anexe um script ao objeto renderer que rastreie suavemente a posição do objeto invisível e simulado. Essa separação entre a renderização e a simulação permite que você altere a posição do objeto renderer para criar uma experiência visualmente suave.
No seguinte exemplo de Script, o objeto renderizado (pai) rastreia suavemente o objeto simulado. O objeto renderizado está sempre um pouco "atrás" do objeto simulado, o que geralmente é aceitável, mas pode ser indesejável em certas situações.
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
-- Objeto a ser rastreado suavemente
local smoothTarget:BasePart = workspace.SimulatedPart
-- Objeto visual que será suavizado
local renderer:BasePart = script.Parent
-- Tempo para suavizar; menor significa mais rápido
local smoothTime = 0.07
-- Armazena dados necessários para calcular a posição suave
local smoothVelocity = Vector3.new()
-- Desabilitar a física do objeto renderer
renderer.Massless = true
renderer.Anchored = true
renderer.CanCollide = false
RunService.RenderStepped:Connect(function(deltaTime: number)
-- Rastrear suavemente o objeto alvo
local smoothPosition, smoothVelocity = TweenService:SmoothDamp(
renderer.Position,
smoothTarget.Position,
smoothVelocity,
smoothTime,
math.huge,
deltaTime)
renderer.Position = smoothPosition
end)O exemplo de jogo Soccer usa uma variação desta técnica para ativar e desativar de forma mais inteligente a suavização da posição da bola de futebol. Especificamente, a bola de futebol apenas suaviza sua posição quando a bola simulada "salta" longe o suficiente do bola renderizada. Essa abordagem proporciona o melhor dos dois mundos: a bola de futebol não tem latência visual em condições normais, e o jogo suaviza sua posição apenas após a bola simulada ter saltado inesperadamente para uma nova localização, provavelmente devido a um artefato de rede ou mudança do lado do servidor.
Escrevendo código de animação
Sob a autoridade do servidor, a simulação do cliente pode ser retrocedida e ressimulada quando o servidor corrige uma predição incorreta. Durante o retrocesso, o estado de animação é retrocedido, o que significa que AnimationTrack que você armazenou em quadros anteriores pode não ser mais válido.
Lógica de animação espelhada
Como com qualquer lógica central de jogabilidade, a lógica para controlar as animações deve estar em sincronia entre o servidor e o cliente ou pode haver predições incorretas e comportamento tremulante. Veja sincronização de simulação para um padrão que conecta funções através de RunService:BindToSimulation() em um ModuleScript que é inicializado tanto no cliente quanto no servidor.
Evitar cache de track
Um padrão comum em scripts que não têm autoridade do servidor é armazenar objetos AnimationTrack no tempo de carregamento e reutilizá-los indefinidamente. Este padrão falha em um jogo com autoridade do servidor quando o servidor corrige uma predição incorreta e o cliente retrocede/reproduz sua simulação com dados corrigidos. Se o seu script ainda manter uma referência a um track parado ou substituído, chamadas como AdjustWeight() ou AdjustSpeed() funcionarão em um track que não está mais sendo representado visualmente.
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")
-- Cache de tracks de animação
local tracks = {}
tracks["WalkForward"] = animator:LoadAnimation(walkForwardAnim)
RunService:BindToSimulation(function(dt: number)
tracks["WalkForward"]:AdjustSpeed(1 + math.cos(time()))
end)Em vez de manter objetos de track, armazene os IDs de animação (ou instâncias Animation) e consulte o Animator para o track ao vivo sempre que precisar interagir com ele. Duas APIs estão disponíveis para isso:
- Animator:GetTrackByAnimationId() — Retorna o track atualmente ativo para um ID de animação específico, ou nil se não houver animações ativas com aquele ID. Use isso quando souber qual animação específica está procurando.
- Animator:GetPlayingAnimationTracks() — Retorna todos os tracks ativos (tocando, diminuindo ou pausados). Use isso quando precisar iterar sobre tudo que está ativo (por exemplo, para parar todas as animações ou encontrar tracks por algum critério).
ModuleScript nomeado CustomAnimate em ReplicatedStorage:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local CustomAnimate = {}
-- Armazena referências de animação (não tracks carregados)
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 CustomAnimateTocando sons e efeitos visuais
Em uma simulação preditiva, é possível disparar efeitos ou sons para eventos que o cliente previu que aconteceriam, mas que nunca ocorreram no servidor. O sistema de renderização deve estar preparado para "desfazer" quaisquer efeitos preditivos incorretos. Por exemplo, um cliente pode prever que uma granada explodiu e disparar um efeito de partícula, mas se outro jogador desativou a granada, o cliente deve ocultar o efeito de partícula.
Uma boa estratégia para renderizar uma simulação preditiva é sincronizar um padrão de máquina de estados dentro do loop de simulação e renderizar alterações no estado em uma função de passo de renderização. O seguinte exemplo simula uma granada com um padrão de máquina de estados:
local module = {}
module.GrenadeStates = {
Idle = 0,
Lit = 1,
Exploded = 2,
Defused = 3,
}
module.GrenadeExplodeTime = 3.0
module.Initialize = function(grenade)
RunService:BindToSimulation(function(deltaTime)
-- Inicializa o estado vazio da granada
local grenadeState = grenade:GetAttribute("State")
if grenadeState == nil then
grenadeState = module.GrenadeStates.Idle
grenade:SetAttribute("State", grenadeState)
grenade:SetAttribute("Timer", 0.0)
end
-- Incrementa o timer da granada
local timer = grenade:GetAttribute("Timer")
timer = timer + deltaTime
grenade:SetAttribute("Timer", timer)
-- Explode granadas acesas
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 moduleCom a máquina de estados anterior em funcionamento, você pode renderizar efeitos de granada em uma conexão RunService.RenderStepped dentro de um script separado baseado no estado da granada sincronizado:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Simulation = require(ReplicatedStorage.Simulation)
local grenade = script.Parent
local previousGrenadeState = nil
-- Destaque da instância para indicar o estado da granada
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")
-- Emite as partículas acesas se a granada estiver acesa
grenade.LitEmitter.Enabled = grenadeState == Simulation.GrenadeStates.Lit
-- Toca o emissor de explosão se a granada acabou de explodir
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
-- Muda a cor do destaque da granada com base no estado e no tempo
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)Projeto considerando a latência de rede
Certos mecânicas de jogabilidade se adaptam melhor a jogos multijogador em rede do que outras. Os jogadores sempre terão algum atraso entre quando outro jogador realiza uma ação e quando recebem a entrada daquele jogador. A melhor maneira de criar um jogo multijogador super suave é projetar seu jogo levando essas limitações em consideração.
Por exemplo, um jogo com aceleração mais lenta no movimento do jogador parecerá mais suave do que um com aceleração maior porque a diferença de posição causada pela latência de rede da entrada do jogador será menor do que em um jogo com maior aceleração.
Como outro exemplo, uma mecânica de jogabilidade onde os jogadores podem acionar instantaneamente uma grande explosão pressionando uma entrada terá mais artefatos de rede do que se a explosão for atrasada após a entrada, como se acendesse um fusível. Isso coloca a ressimulação no efeito do fusível em vez no efeito de explosão, que é um artefato de rede menos perceptível.
Predizendo as entradas de outros jogadores
Por padrão, Roblox não encaminha as entradas de cada cliente para todos os outros clientes. Se isso é adequado para seu jogo depende de seu design:
- Para o movimento básico de humanoides, o comportamento padrão significa que os movimentos das personagens dos outros jogadores não são extrapolados a partir do estado autoritativo do servidor e, como resultado, as personagens dos outros jogadores não terão predições incorretas, mas serão renderizadas ligeiramente no passado.
- Em um jogo de corrida, em contraste, o comportamento padrão significa que os clientes não saberão se outros jogadores estão aplicando o acelerador ou outras entradas, então outros carros podem parecer atrás do jogador local, mesmo que estejam realmente à frente. Para aliviar isso, você pode armazenar as entradas dos jogadores em atributos no servidor e operar nesses atributos sincronizados do lado do cliente usando RunService:BindToSimulation(), conforme demonstrado no seguinte exemplo de código e no template Racing. Essa abordagem permite que você use atributos como entradas para sua simulação para ter entradas de jogador totalmente replicadas.
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)
-- Escreva qualquer outra entrada em atributos...
end
module.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
if RunService:IsServer() then
-- Encaminha entradas do servidor para todos os clientes
for _, player in Players:GetPlayers() do
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local inputContext:InputContext = player.PlayerGui.InputContext
module.storePlayerInput(player, humanoidRootPart)
end
else
-- Escreve entradas do jogador local como atributos
local player = Players.LocalPlayer
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local inputContext:InputContext = player.PlayerGui.InputContext
module.storePlayerInput(player, humanoidRootPart)
end
-- Use os atributos como entradas para o jogo
for _, player in Players:GetPlayers() do
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local throttle = humanoidRootPart:GetAttribute("Throttle")
if throttle then
-- Aplique o acelerador ao veículo do jogador
end
end
end)
end)
return moduleDepuração
Existem algumas novas ferramentas e técnicas que você pode usar para depurar um jogo com autoridade do servidor.
Visualizador de autoridade do servidor
Pressionar CtrlShiftF6 (Windows) ou ⌘ShiftF6 (Mac) abre o visualizador de autoridade do servidor do Studio que mostra várias peças-chave de informação:
| Detalhes | Descrição |
|---|---|
| Taxa de sucesso na predição de instâncias | A porcentagem de instâncias preditas corretamente nos últimos 8 segundos. |
| Taxa de aceitação de entradas | A porcentagem de todas as entradas dos jogadores que chegaram a tempo no servidor. Entradas atrasadas irão diminuir esse número. |
| Delta de passos cliente-servidor | O número de quadros entre o cliente e o servidor, incluindo o tempo de entrada do cliente. A estabilidade desse número representa a estabilidade de sua conexão com o servidor. |
| FPS do batimento do RCC | A taxa de quadros da simulação no servidor. Se esse número cair abaixo de 59, o servidor não conseguirá acompanhar a simulação e a qualidade do jogo será degradada. |
| Contagem de instâncias preditivas | O número de instâncias que seu cliente está prevendo. |
| Contagens de razão de queda de entradas | O número de vezes que o servidor descartou uma entrada por cada razão:
|
Raio de simulação
Ao confiar na predição automática (Enum.PredictionMode.Automatic), você pode visualizar o raio de predição ao redor da sua personagem jogador ativando Regiões Ativadas nas configurações do Studio (AltS no Windows; ⌥S no Mac). O cilindro verde indica a faixa ao redor de sua personagem na qual instâncias são preditas, e seu raio cresce e diminui com base nas características de desempenho do dispositivo.
