Ce guide présente diverses techniques pour créer des jeux multijoueurs de haute qualité et fluides en utilisant le modèle d'autorité serveur.
Création d'instance prédictive (épissurage d'instance)
L'épissurage d'instance permet aux scripts clients de créer de manière prédictive des Instances à l'intérieur des rappels de RunService:BindToSimulation(). Le client crée l’Instance immédiatement sans attendre un aller-retour avec le serveur; lorsque la copie autoritaire du serveur arrive, l'instance créée par le client et la copie autoritaire du serveur sont fusionnées en une seule. Du point de vue de votre script, l’Instance existe immédiatement et est cohérente avec le serveur.
L'épissurage d'instance est utile dans les cas où une instance doit être visible et active sur le client le plus rapidement possible. Bien que le serveur répliquera éventuellement toute instance dont le client a besoin (ainsi que tous les effets qu'ils ont sur le monde), ce processus engendre au moins un aller-retour de latence en raison de la communication avec le serveur. Les exemples incluent le tir d'un lance-roquettes et la création de contraintes physiques — sans épissurage, le client verra le roquette apparaître loin de lui, ou éprouvera des tremblements lorsque les nouvelles contraintes se répliquent vers lui.
Comportement technique
L'épissurage d'instance fonctionne en générant le même GUID déterministe à la fois sur le client et sur le serveur. Le GUID est dérivé de quatre entrées : le type de l’Instance créé, l'identité de la source (voir ci-dessous), la frame de simulation actuelle et un compteur d'appels par script qui se réinitialise à chaque frame.
- Pour Instance.new() — La source est le script lui-même (deux scripts avec le même texte sont considérés comme différents).
- Pour Instance.fromExisting() — La source est l’Instance sur lequel vous appelez Instance.fromExisting().
- Pour Instance:Clone() — Chaque instance clonée utilise le GUID de l’instance source comme graine de contexte.
Si le client et le serveur s'accordent sur les entrées, ils produisent des GUID correspondants et l'épissurage réussit.
Mise en œuvre
Pour utiliser l'épissurage d'instance, appelez Instance.new(), Instance:Clone(), ou Instance.fromExisting() à l'intérieur d'un rappel RunService:BindToSimulation() d'un ModuleScript qui est requis à la fois sur le client et le serveur. Rien d'autre n'est requis de votre part; le système gère automatiquement l'attribution des GUID et la réconciliation.
Vous pouvez librement définir des propriétés sans accès à la simulation telles que Name, Size, ou Parent sur une instance avant qu'elle ne soit parentée dans le 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 partie est maintenant dans le modèle de données; tout changement d'accès non-simulation provoquera une erreur après cela
-- La partie existe immédiatement sur le client et sera réconciliée avec le serveur
end)
end
return SimulationInstance:Clone() et Instance.fromExisting() s'épissent correctement lorsque l'instance source a été répliquée à la fois sur le client et sur le serveur; les deux côtés clonent à partir des GUID source correspondants et produisent des GUID prédits correspondants.
local RunService = game:GetService("RunService")
local Simulation = {}
local sourceTemplate -- une instance répliquée
Simulation.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
local cloned = sourceTemplate:Clone()
cloned.Parent = workspace
-- La hiérarchie clonée est épissée avec la copie autoritaire du serveur
end)
end
return SimulationLissage de position
Vous pouvez lisser visuellement la position d'objets synchronisés mal prévus en rendant un objet différent de celui qui est simulé.
- Rendez l'objet simulé invisible.
- Créez un objet rendu en tant que clone sans masse, non-collidable, uniquement visuel pour suivre l'objet simulé.
- Attachez un script à l'objet rendu qui suit en douceur la position de l'objet simulé invisible. Cette séparation entre rendu et simulation vous permet de modifier la position de l'objet rendu pour créer une expérience visuellement fluide.
Dans l'exemple suivant Script, l'objet rendu (parent) suit en douceur l'objet simulé. L'objet rendu est toujours légèrement "en retard" par rapport à l'objet simulé, ce qui est généralement acceptable mais peut être indésirable dans certaines situations.
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
-- Objet à suivre en douceur
local smoothTarget:BasePart = workspace.SimulatedPart
-- Objet visuel qui sera lissé
local renderer:BasePart = script.Parent
-- Temps pour l'aplatissement; plus petit signifie plus rapide
local smoothTime = 0.07
-- Stockez les données nécessaires pour calculer la position lissée
local smoothVelocity = Vector3.new()
-- Désactivez la physique de l'objet rendu
renderer.Massless = true
renderer.Anchored = true
renderer.CanCollide = false
RunService.RenderStepped:Connect(function(deltaTime: number)
-- Suivre en douceur l'objet cible
local smoothPosition, smoothVelocity = TweenService:SmoothDamp(
renderer.Position,
smoothTarget.Position,
smoothVelocity,
smoothTime,
math.huge,
deltaTime)
renderer.Position = smoothPosition
end)Le jeu d'exemple Soccer utilise une variation de cette technique pour activer et désactiver de manière plus intelligente le lissage de position pour le ballon de football. En particulier, le ballon de football lisse sa position uniquement lorsque le ballon simulé a "sauté" suffisamment loin de l'objet rendu. Cette approche offre le meilleur des deux mondes : le ballon de football n'a pas de latence visuelle dans des conditions normales, et le jeu interpole en douceur sa position uniquement après que le ballon simulé ait sauté de manière inattendue à un nouvel emplacement, probablement en raison d'un artefact réseau ou d'un changement côté serveur.
Écriture de code d'animation
Sous l'autorité du serveur, la simulation du client peut être annulée et resimulée lorsque le serveur corrige une mauvaise prédiction. Pendant l'annulation, l'état d'animation est rembobiné, ce qui signifie que les AnimationTrack que vous avez mis en cache dans les frames précédentes peuvent ne plus être valides.
Logique d'animation miroir
Comme pour toute logique de gameplay fondamentale, la logique de contrôle des animations doit être en synchronisation entre le serveur et le client, sinon il peut y avoir des erreurs de prédiction et un comportement tremblant. Voir synchronisation de simulation pour un modèle qui lie des fonctions via RunService:BindToSimulation() dans un ModuleScript qui est initialisé à la fois sur le client et le serveur.
Évitez le cache des pistes
Un modèle courant dans les scripts sans autorité serveur est de mettre en cache des objets AnimationTrack au moment du chargement et de les réutiliser de manière indéfinie. Ce modèle échoue dans un jeu avec autorité serveur lorsque le serveur corrige une mauvaise prédiction et que le client rembobine/rejoue sa simulation avec des données corrigées. Si votre script détient encore une référence à une piste arrêtée ou remplacée, des appels comme AdjustWeight() ou AdjustSpeed() fonctionneront sur une piste qui n'est plus représentée visuellement.
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")
-- Mettre en cache les pistes d'animation
local tracks = {}
tracks["WalkForward"] = animator:LoadAnimation(walkForwardAnim)
RunService:BindToSimulation(function(dt: number)
tracks["WalkForward"]:AdjustSpeed(1 + math.cos(time()))
end)Au lieu de conserver des objets de piste, stockez les ID d'animation (ou les instances Animation) et interrogez le Animator pour la piste en direct chaque fois que vous devez interagir avec elle. Deux API sont disponibles pour cela :
- Animator:GetTrackByAnimationId() — Renvoie la piste active pour un ID d'animation spécifique, ou nil s'il n'y a pas d'animations actives avec cet ID. Utilisez ceci lorsque vous savez quelle animation spécifique vous recherchez.
- Animator:GetPlayingAnimationTracks() — Renvoie toutes les pistes actives (jouées, en fondu, ou mises en pause). Utilisez ceci lorsque vous devez itérer sur tout ce qui est actif (par exemple, pour arrêter toutes les animations ou trouver des pistes par un critère).
ModuleScript nommé CustomAnimate dans ReplicatedStorage :
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local CustomAnimate = {}
-- Stockez les références d'animation (pas des pistes chargées)
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 CustomAnimateJouer des sons et des effets visuels
Dans une simulation prédictive, il est possible de déclencher des effets ou des sons pour des événements que le client a prédit se produiraient mais qui ne se sont jamais produits sur le serveur. Le système de rendu doit être préparé à "annuler" les effets mal prédites. Par exemple, un client pourrait prédire qu'une grenade a explosé et déclencher un effet de particules, mais si un autre joueur a désamorcé la grenade, le client devrait cacher l'effet de particules.
Une bonne stratégie pour rendre une simulation prédite est de synchroniser un modèle de machine d'état au sein de la boucle de simulation et de rendre les changements d'état dans une fonction d'étape de rendu. L'exemple suivant simule une grenade avec un modèle de machine d'état :
local module = {}
module.GrenadeStates = {
Idle = 0,
Lit = 1,
Exploded = 2,
Defused = 3,
}
module.GrenadeExplodeTime = 3.0
module.Initialize = function(grenade)
RunService:BindToSimulation(function(deltaTime)
-- Initialiser l'état de grenade vide
local grenadeState = grenade:GetAttribute("State")
if grenadeState == nil then
grenadeState = module.GrenadeStates.Idle
grenade:SetAttribute("State", grenadeState)
grenade:SetAttribute("Timer", 0.0)
end
-- Incrémenter le minuteur de grenade
local timer = grenade:GetAttribute("Timer")
timer = timer + deltaTime
grenade:SetAttribute("Timer", timer)
-- Faire exploser les grenades allumées
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 moduleAvec la machine d'état précédente en place, vous pouvez rendre les effets de grenade dans une connexion RunService.RenderStepped dans un script séparé basé sur l'état grenadié synchronisé :
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Simulation = require(ReplicatedStorage.Simulation)
local grenade = script.Parent
local previousGrenadeState = nil
-- Instance de surlignage pour indiquer l'état de la grenade
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")
-- Émettre les particules allumées si la grenade est allumée
grenade.LitEmitter.Enabled = grenadeState == Simulation.GrenadeStates.Lit
-- Jouer l'émetteur d'explosion si la grenade vient d'exploser
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
-- Changer la couleur de surlignage de la grenade en fonction de l'état et du temps
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)Concevoir autour de la latence réseau
Certaines mécaniques de gameplay se prêtent mieux à un multijoueur en réseau que d'autres. Les joueurs auront toujours un certain délai entre le moment où un autre joueur effectue une action et le moment où ils reçoivent l'entrée de cet autre joueur. La meilleure façon de créer un jeu multijoueur super fluide est de concevoir votre jeu en tenant compte de ces limitations.
Par exemple, un jeu avec une accélération plus lente sur le mouvement des joueurs apparaîtra plus fluide qu'un jeu avec une accélération plus élevée, car la différence de position causée par la latence réseau de l'entrée du joueur sera inférieure à celle d'un jeu avec une accélération plus élevée.
Comme autre exemple, une mécanique de gameplay où les joueurs peuvent instantanément déclencher une grande explosion en pressant une entrée aura plus d'artefacts réseau que si l'explosion est retardée après l'entrée, comme si on allumait une mèche. Cela place la resimulation sur l'effet de la mèche plutôt que sur l'effet d'explosion, ce qui est un artefact réseau moins perceptible.
Prédire les entrées d'autres joueurs
Par défaut, Roblox ne transmet pas les entrées de chaque client à tous les autres clients. Que ce soit approprié pour votre jeu dépend de sa conception :
- Pour les mouvements humanoïdes de base, le comportement par défaut signifie que les mouvements des autres personnages joueurs ne sont pas extrapolés de l'état autoritaire du serveur et, en conséquence, les autres personnages joueurs ne seront pas mal prédit mais seront rendus légèrement dans le passé.
- Dans un jeu de course, en revanche, le comportement par défaut signifie que les clients ne sauront pas si d'autres joueurs appliquent l'accélérateur ou d'autres entrées, de sorte que d'autres voitures peuvent apparaître derrière le joueur local, même si elles sont en réalité devant. Pour remédier à cela, vous pouvez stocker les entrées des joueurs dans des attributs sur le serveur et opérer sur ces attributs synchronisés côté client en utilisant RunService:BindToSimulation() comme le montre l'exemple de code suivant et le modèle Racing. Cette approche vous permet d'utiliser des attributs comme entrées dans votre simulation pour avoir des entrées de joueur entièrement répliquées.
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)
-- Écrire d'autres entrées dans les attributs...
end
module.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
if RunService:IsServer() then
-- Transmettre les entrées du serveur à tous les clients
for _, player in Players:GetPlayers() do
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local inputContext:InputContext = player.PlayerGui.InputContext
module.storePlayerInput(player, humanoidRootPart)
end
else
-- Écrire les entrées du joueur local en tant qu'attributs
local player = Players.LocalPlayer
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local inputContext:InputContext = player.PlayerGui.InputContext
module.storePlayerInput(player, humanoidRootPart)
end
-- Utiliser les attributs comme entrées pour le jeu
for _, player in Players:GetPlayers() do
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local throttle = humanoidRootPart:GetAttribute("Throttle")
if throttle then
-- Appliquer l'accélération au véhicule du joueur
end
end
end)
end)
return moduleDébogage
Il existe de nouveaux outils et techniques que vous pouvez utiliser pour déboguer un jeu avec autorité serveur.
Visualiseur d'autorité serveur
Appuyer sur CtrlShiftF6 (Windows) ou ⌘ShiftF6 (Mac) ouvre le visualiseur d'autorité serveur de Studio qui montre plusieurs pièces clés d'information :
| Détails | Description |
|---|---|
| Taux de succès de prédiction d'instance | Le pourcentage d'instances correctement prédites au cours des 8 dernières secondes. |
| Taux d'acceptation des entrées | Le pourcentage de toutes les entrées des joueurs qui sont arrivées à temps sur le serveur. Les entrées tardives feront baisser ce nombre. |
| Delta d'étape client-serveur | Le nombre de frames entre le client et le serveur, y compris le temps d'entrée du client. La stabilité de ce nombre représente la stabilité de votre connexion au serveur. |
| FPS du battement de cœur RCC | Le taux de frames de la simulation sur le serveur. Si ce nombre tombe en dessous de 59, le serveur ne peut pas suivre la simulation et la qualité du jeu se dégradera. |
| Compteur d'instances prédites | Le nombre d'instances que votre client prédit. |
| Comptes de raisons de perte d'entrée | Le nombre de fois que le serveur a perdu une entrée pour chaque raison :
|
Rayon de simulation
Lorsque vous dépendez de la prédiction automatique (Enum.PredictionMode.Automatic), vous pouvez visualiser le rayon de prédiction autour de votre personnage joueur en activant Les régions sont activées dans les paramètres de Studio (AltS sur Windows; ⌥S sur Mac). Le cylindre vert indique la portée autour de votre personnage dans laquelle les instances sont prédites, et son rayon grandit ou rétrécit en fonction des caractéristiques de performance de l'appareil.
