Esta guía describe varias técnicas para crear juegos multijugador de alta calidad y fluidos utilizando el modelo de autoridad del servidor.
Creación predictiva de instancias (costura de instancias)
La costura de instancias permite que los scripts del cliente creen Instances de manera predictiva dentro de las devoluciones de llamada de RunService:BindToSimulation(). El cliente crea la Instance inmediatamente sin esperar un viaje de ida y vuelta al servidor; cuando llega la copia autorizada del servidor, la instancia creada por el cliente y la copia autorizada del servidor se fusionan en una sola. Desde la perspectiva de tu script, la Instance existe inmediatamente y es consistente con el servidor.
La costura de instancias es útil en casos donde una instancia debe ser visible y activa en el cliente lo antes posible. Aunque el servidor eventualmente replicará cualquier instancia que el cliente necesite (junto con cualquier efecto que hayan tenido en el mundo), este proceso incurre en al menos un viaje de ida y vuelta de latencia debido a la comunicación con el servidor. Los ejemplos incluyen disparar un lanzacohetes y crear restricciones físicas — sin costura, el cliente verá el cohete aparecer a gran distancia de ellos, o un poco de temblor cuando las nuevas restricciones se repliquen a ellos.
Comportamiento técnico
La costura de instancias funciona generando el mismo GUID determinista tanto en el cliente como en el servidor. El GUID se deriva de cuatro entradas: el tipo de la Instance que se está creando, la identidad de la fuente (ver abajo), el marco de simulación actual y un contador de llamadas por script que se reinicia cada marco.
- Para Instance.new() — La fuente es el script mismo (dos scripts con el mismo texto se consideran diferentes).
- Para Instance.fromExisting() — La fuente es la Instance sobre la que estás llamando a Instance.fromExisting().
- Para Instance:Clone() — Cada instancia clonada usa el GUID de la instancia fuente como semilla de contexto.
Si el cliente y el servidor están de acuerdo en las entradas, producen GUIDs coincidentes y la costura tiene éxito.
Implementación
Para utilizar la costura de instancias, llama a Instance.new(), Instance:Clone() o Instance.fromExisting() dentro de una devolución de llamada de BindToSimulation() desde un ModuleScript que se requiera en el cliente y el servidor. No se requiere nada más de tu parte; el sistema se encarga automáticamente de la asignación y reconciliación del GUID.
Puedes establecer libremente propiedades de acceso no simuladas como Name, Size, o Parent en una instancia antes de ser padre en el 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 -- La Parte ahora está en el modelo de datos; cualquier cambio de acceso no simulado generará un error después de esto
-- La Parte existe inmediatamente en el cliente y se reconciliará con el servidor
end)
end
return SimulationInstance:Clone() y Instance.fromExisting() se cosen correctamente cuando la instancia fuente se replicó tanto al cliente como al servidor; ambos lados clonan desde GUIDs fuente coincidentes y producen GUIDs predictivos coincidentes.
local RunService = game:GetService("RunService")
local Simulation = {}
local sourceTemplate -- una Instancia replicada
Simulation.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
local cloned = sourceTemplate:Clone()
cloned.Parent = workspace
-- La jerarquía clonada se cose con la copia autorizada del servidor
end)
end
return SimulationSuavizado de posición
Puedes suavizar visualmente la posición de objetos sincronizados mal predichos renderizando un objeto diferente al que se está simulando.
- Haz que el objeto simulado sea invisible.
- Crea un objeto renderer como un clon sin masa, no colisionable, solo visual, para rastrear el objeto simulado.
- Adjunta un script al objeto renderer que siga suavemente la posición del objeto simulado e invisible. Esta separación entre renderizado y simulación te permite alterar la posición del objeto renderer para crear una experiencia visualmente suave.
En el siguiente ejemplo de Script, el objeto renderizado (padre) sigue suavemente al objeto simulado. El objeto renderizado siempre está ligeramente "detrás" del objeto simulado, lo cual es típicamente aceptable, pero puede ser indeseable en ciertas situaciones.
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
-- Objeto a seguir suavemente
local smoothTarget:BasePart = workspace.SimulatedPart
-- Objeto visual que será suavizado
local renderer:BasePart = script.Parent
-- Tiempo para suavizar; más pequeño significa más rápido
local smoothTime = 0.07
-- Almacena datos necesarios para calcular la posición suavizada
local smoothVelocity = Vector3.new()
-- Desactiva la física del objeto renderizador
renderer.Massless = true
renderer.Anchored = true
renderer.CanCollide = false
RunService.RenderStepped:Connect(function(deltaTime: number)
-- Sigue suavemente al objeto objetivo
local smoothPosition, smoothVelocity = TweenService:SmoothDamp(
renderer.Position,
smoothTarget.Position,
smoothVelocity,
smoothTime,
math.huge,
deltaTime)
renderer.Position = smoothPosition
end)El ejemplo del juego Soccer utiliza una variación de esta técnica para activar y desactivar de manera más inteligente el suavizado de posición para el balón de fútbol. Específicamente, el balón solo suaviza su posición cuando el balón simulado ha "saltado" lo suficientemente lejos del balón renderizado. Este enfoque proporciona lo mejor de ambos mundos: el balón de fútbol no tiene latencia visual en condiciones normales, y el juego suaviza su posición solo después de que el balón simulado ha saltado inesperadamente a una nueva ubicación, probablemente debido a un artefacto de red o un cambio en el servidor.
Escribiendo código de animación
Bajo la autoridad del servidor, la simulación del cliente puede ser retrospeccionada y resimulada cuando el servidor corrige una mala predicción. Durante la retrospección, el estado de la animación se retrocede, lo que significa que AnimationTrack que hayas almacenado en marcos anteriores puede ya no ser válido.
Lógica de animación espejo
Al igual que con cualquier lógica de juego principal, la lógica para controlar animaciones debe estar sincronizada entre el servidor y el cliente o puede haber malas predicciones y comportamiento tembloroso. Consulta sincronización de simulación para un patrón que vincula funciones a través de RunService:BindToSimulation() en un ModuleScript que se inicializa tanto en el cliente como en el servidor.
Evitar el almacenamiento en caché de pistas
Un patrón común en scripts que no tienen autoridad del servidor es almacenar objetos AnimationTrack en el momento de carga y reutilizarlos indefinidamente. Este patrón falla en un juego con autoridad del servidor cuando el servidor corrige una mala predicción y el cliente retrocede/reproduce su simulación con datos corregidos. Si tu script aún mantiene una referencia a una pista detenida o reemplazada, llamadas como AdjustWeight() o AdjustSpeed() funcionarán en una pista que ya no está representada 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")
-- Almacenar pistas de animación
local tracks = {}
tracks["WalkForward"] = animator:LoadAnimation(walkForwardAnim)
RunService:BindToSimulation(function(dt: number)
tracks["WalkForward"]:AdjustSpeed(1 + math.cos(time()))
end)En lugar de retener objetos de pista, almacena los IDs de animación (o instancias de Animation) y consulta al Animator por la pista activa cada vez que necesites interactuar con ella. Dos APIs están disponibles para esto:
- Animator:GetTrackByAnimationId() — Devuelve la pista actualmente activa para un ID de animación específico, o nil si no hay animaciones activas con ese ID. Utiliza esto cuando sepas qué animación específica estás buscando.
- Animator:GetPlayingAnimationTracks() — Devuelve todas las pistas activas (reproduciendo, apagándose o en pausa). Utiliza esto cuando necesites iterar sobre todo lo que esté activo (por ejemplo, para detener todas las animaciones o encontrar pistas según algún criterio).
ModuleScript llamado CustomAnimate en ReplicatedStorage:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local CustomAnimate = {}
-- Almacenar referencias de animación (no pistas cargadas)
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 CustomAnimateReproducción de sonidos y efectos visuales
En una simulación predictiva, es posible activar efectos o sonidos para eventos que el cliente predijo que ocurrirían pero que nunca ocurrieron en el servidor. El sistema de renderizado debe estar preparado para "deshacer" cualquier efecto mal predicho. Por ejemplo, un cliente podría predecir que una granada explotó y activar un efecto de partículas, pero si otro jugador desactivó la granada, el cliente debería ocultar el efecto de partículas.
Una buena estrategia para renderizar una simulación predictiva es sincronizar un patrón de máquina de estados dentro del bucle de simulación y renderizar los cambios en el estado en una función de paso de renderizado. El siguiente ejemplo simula una granada con un patrón 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)
-- Inicializar estado vacío de la granada
local grenadeState = grenade:GetAttribute("State")
if grenadeState == nil then
grenadeState = module.GrenadeStates.Idle
grenade:SetAttribute("State", grenadeState)
grenade:SetAttribute("Timer", 0.0)
end
-- Incrementar el temporizador de la granada
local timer = grenade:GetAttribute("Timer")
timer = timer + deltaTime
grenade:SetAttribute("Timer", timer)
-- Explotar granadas encendidas
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 moduleCon la máquina de estados anterior en su lugar, puedes renderizar efectos de granada en una conexión de RunService.RenderStepped dentro de un script separado basado en el estado de granada sincronizado:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Simulation = require(ReplicatedStorage.Simulation)
local grenade = script.Parent
local previousGrenadeState = nil
-- Resaltar instancia para indicar el estado de la 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")
-- Emitir las partículas encendidas si la granada está encendida
grenade.LitEmitter.Enabled = grenadeState == Simulation.GrenadeStates.Lit
-- Reproducir el emisor de explosión si la granada acaba de explotar
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
-- Cambiar el color de resaltado de la granada según el estado y el tiempo
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)Diseñando alrededor de la latencia de red
Ciertas mecánicas de juego se adaptan mejor al multijugador en red que otras. Los jugadores siempre tendrán un retraso entre el momento en que otro jugador realiza una acción y cuando reciben la entrada de ese jugador. La mejor manera de crear un juego multijugador súper suave es diseñar tu juego teniendo en cuenta estas limitaciones.
Por ejemplo, un juego con aceleración más lenta en el movimiento del jugador se verá más suave que uno con mayor aceleración porque la diferencia en la posición causada por la latencia de red de la entrada del jugador será menor que en un juego con mayor aceleración.
Como otro ejemplo, una mecánica de juego donde los jugadores pueden activar instantáneamente una gran explosión presionando una entrada tendrá más artefactos de red que si la explosión se retrasa después de la entrada, como si se encendiera una mecha. Esto coloca la resimulación en el efecto de la mecha en lugar del efecto de explosión, que es un artefacto de red menos notable.
Prediciendo otras entradas de jugadores
Por defecto, Roblox no reenvía las entradas de cada cliente a cada otro cliente. Si esto es correcto para tu juego depende de su diseño:
- Para el movimiento humanoide básico, el comportamiento por defecto significa que los movimientos de otros personajes de jugadores no se extrapolan desde el estado autorizado del servidor y, como resultado, otros personajes de jugadores no tendrán malas predicciones, pero se renderizarán ligeramente en el pasado.
- En un juego de carreras, por el contrario, el comportamiento por defecto significa que los clientes no sabrán si otros jugadores están aplicando el acelerador u otras entradas, por lo que otros coches pueden parecer detrás del jugador local incluso si realmente están delante. Para aliviar esto, puedes almacenar entradas de jugadores en atributos en el servidor y operar sobre esos atributos sincronizados del lado del cliente utilizando RunService:BindToSimulation() como se demuestra en el siguiente ejemplo de código y la plantilla de Racing. Este enfoque te permite usar atributos como entradas para tu simulación para tener entradas de jugadores 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)
-- Escribir cualquier otra entrada en atributos...
end
module.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
if RunService:IsServer() then
-- Reenviar entradas desde el servidor a todos los 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
-- Escribir entradas de jugador local como atributos
local player = Players.LocalPlayer
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local inputContext:InputContext = player.PlayerGui.InputContext
module.storePlayerInput(player, humanoidRootPart)
end
-- Usar los atributos como entradas para el juego
for _, player in Players:GetPlayers() do
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local throttle = humanoidRootPart:GetAttribute("Throttle")
if throttle then
-- Aplicar el acelerador al vehículo del jugador
end
end
end)
end)
return moduleDepuración
Hay algunas nuevas herramientas y técnicas que puedes usar para depurar un juego con autoridad del servidor.
Visualizador de autoridad del servidor
Presionar CtrlShiftF6 (Windows) o ⌘ShiftF6 (Mac) abre el visualizador de autoridad del servidor de Studio que muestra varias piezas clave de información:
| Detalles | Descripción |
|---|---|
| Tasa de éxito de predicción de instancias | El porcentaje de instancias correctamente predichas durante los últimos 8 segundos. |
| Tasa de aceptación de entradas | El porcentaje de entradas de todos los jugadores que llegaron a tiempo al servidor. Las entradas tardías reducirán este número. |
| Delta de paso cliente-servidor | El número de fotogramas entre el cliente y el servidor, incluyendo el tiempo de unión del cliente. La estabilidad de este número representa la estabilidad de tu conexión al servidor. |
| FPS de pulso RCC | La tasa de fotogramas de la simulación en el servidor. Si este número cae por debajo de 59, el servidor no puede mantenerse al día con la simulación y el juego perderá calidad. |
| Conteo de instancias predichas | El número de instancias que tu cliente está prediciendo. |
| Conteos de razones de caída de entradas | El número de veces que el servidor ha descartado una entrada por cada razón:
|
Radio de simulación
Cuando confías en la predicción automática (Enum.PredictionMode.Automatic), puedes visualizar el radio de predicción alrededor de tu personaje jugador habilitando ¿Están habilitadas las regiones? en la configuración de Studio (AltS en Windows; ⌥S en Mac). El cilindro verde indica el rango alrededor de tu personaje en el que se predicen instancias, y su radio crece y se reduce según las características de rendimiento del dispositivo.
