服务器权限技术

*此内容使用人工智能(Beta)翻译,可能包含错误。若要查看英文页面,请点按 此处

本指南概述了使用 服务器权限模型 创建高质量、流畅的多人游戏的各种技术。

预测实例创建(实例拼接)

实例 拼接 允许客户端脚本在 RunService:BindToSimulation() 回调中预测性地创建 Instances。客户端立即创建 Instance,而无需等待服务器的往返;当服务器的授权副本到达时,客户端创建的实例和服务器的授权副本合并为一个。从脚本的角度来看,Instance 立即存在,并且与服务器一致。

实例拼接在实例必须尽快在客户端可见和活跃的情况下非常有用。虽然服务器最终将复制客户端所需的任何实例(连同它们对世界的任何影响),但由于服务器通信,此过程至少会产生一次往返延迟。示例包括发射火箭发射器和创建物理约束——如果没有拼接,客户端会发现在远处看到火箭弹出现,或者在新约束复制到他们那里的时候出现一些抖动。

技术行为

实例拼接通过在客户端和服务器生成相同的确定性 GUID 来工作。GUID 由四个输入生成:正在创建的 Instance 类型、源的身份(见下文)、当前仿真帧以及一个每帧重置的脚本调用计数器。

如果客户端和服务器对输入达成一致,它们会生成匹配的 GUID,拼接就成功了。

实现

要利用实例拼接,请在 ModuleScript 中从客户端 服务器的 RunService:BindToSimulation() 回调中调用 Instance.new()Instance:Clone()Instance.fromExisting()。您无需在这方面做其他事情;系统会自动处理 GUID 分配和协调。

您可以自由设置非 仿真访问 属性,例如 NameSizeParent,在实例被放入 DataModel 之前。

仿真(ModuleScript) - 在 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 -- 部件现在在数据模型中;在此之后的任何非仿真访问更改将出错
-- 部件立即在客户端存在,并将与服务器协调
end)
end
return Simulation

Instance:Clone()Instance.fromExisting() 在源实例已复制到客户端和服务器时正确拼接;两边都从匹配的源 GUID 克隆,并生成匹配的预测 GUID。

仿真(ModuleScript) - 在 BindToSimulation() 回调中克隆实例
local RunService = game:GetService("RunService")
local Simulation = {}
local sourceTemplate -- 一个已复制的实例
Simulation.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
local cloned = sourceTemplate:Clone()
cloned.Parent = workspace
-- 克隆的层次结构与服务器的授权副本拼接
end)
end
return Simulation

位置平滑

您可以通过呈现与被仿真对象不同的对象来在视觉上平滑误预测的同步对象的位置。

  1. 使 仿真 对象不可见。
  2. 创建一个 渲染器 对象,作为无质量的、不可碰撞的、仅用于视觉的克隆对象,以跟踪仿真对象。
  3. 将脚本附加到渲染器对象,该脚本平滑地跟踪不可见的仿真对象的位置。渲染与仿真之间的这种分离使您能够调整渲染器对象的位置,从而创建视觉上顺畅的体验。

在下面的示例 Script 中,渲染的对象(父对象)平滑地跟踪仿真对象。渲染的对象总是稍微“落后”于仿真对象,这在通常情况下是可以的,但在某些情况下可能是不可取的。

使用渲染器部件平滑跟踪 BasePart 位置
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
-- 要平滑跟踪的对象
local smoothTarget:BasePart = workspace.SimulatedPart
-- 将要平滑的视觉对象
local renderer:BasePart = script.Parent
-- 平滑时间;更小意味着更快
local smoothTime = 0.07
-- 存储计算平滑位置所需的数据
local smoothVelocity = Vector3.new()
-- 禁用渲染器对象的物理
renderer.Massless = true
renderer.Anchored = true
renderer.CanCollide = false
RunService.RenderStepped:Connect(function(deltaTime: number)
-- 平滑地跟踪目标对象
local smoothPosition, smoothVelocity = TweenService:SmoothDamp(
renderer.Position,
smoothTarget.Position,
smoothVelocity,
smoothTime,
math.huge,
deltaTime)
renderer.Position = smoothPosition
end)

足球 示例游戏使用这种技术的变体,更智能地在足球的情况下打开和关闭位置平滑。具体而言,只有当仿真的足球“跳”得足够远离渲染的足球时,足球才会平滑其位置。这种方法提供了两全其美:在正常条件下,足球没有视觉延迟,而在仿真球意外跳到新位置(可能由于网络伪影或服务器端更改)后,游戏才平滑插值其位置。

编写动画代码

在服务器权限下,当服务器纠正误预测时,客户端的仿真可以被 回滚和重新仿真。在回滚期间,动画状态被倒带,这意味着您在早期帧中缓存的 AnimationTrack 可能不再有效。

镜像动画逻辑

与任何核心游戏逻辑一样,控制动画的逻辑必须在服务器和客户端之间保持同步,否则可能会出现误预测和抖动行为。请参见 仿真同步 以获取通过 RunService:BindToSimulation() 在客户端和服务器上初始化的 ModuleScript 中绑定函数的模式。

避免跟踪缓存

在非服务器权限脚本中,一个常见的模式是缓存 AnimationTrack 对象,在加载时并无限期重用。在服务器授权游戏中,当服务器纠正误预测并且客户端使用更正的数据卷回/重放其仿真时,这种模式会失败。如果您的脚本仍持有对停止或替换跟轨的引用,则像 AdjustWeight()AdjustSpeed() 的调用将对不再在视觉上表示的轨道操作。

在客户端缓存轨道(不可靠)
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")
-- 缓存动画轨道
local tracks = {}
tracks["WalkForward"] = animator:LoadAnimation(walkForwardAnim)
RunService:BindToSimulation(function(dt: number)
tracks["WalkForward"]:AdjustSpeed(1 + math.cos(time()))
end)

与其保留轨道对象,不如存储 动画 ID(或 Animation 实例),并在需要与之交互时查询 Animator 获取实时轨道。可用的两个 API 如下:

  • Animator:GetTrackByAnimationId() — 返回特定动画 ID 的当前活动轨道,如果没有使用该 ID 的活动动画,则返回 nil。当您知道您在寻找哪个特定动画时,请使用此方法。
  • Animator:GetPlayingAnimationTracks() — 返回所有活动轨道(播放、淡出或暂停)。当您需要遍历所有活动轨道时使用此方法(例如,停止所有动画或按某些标准查找轨道)。

ModuleScript 命名为 CustomAnimate,位于 ReplicatedStorage

CustomAnimate
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local CustomAnimate = {}
-- 存储动画引用(未加载轨道)
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

播放声音和视觉效果

在预测仿真中,客户端可以触发事件效果或声音,这些事件是客户端预测会发生但在服务器上实际上并未发生的。渲染系统应该准备好“撤销”任何误预测的效果。例如,客户端可能预测到手榴弹爆炸并触发粒子效果,但如果其他玩家解除引信,客户端应该隐藏粒子效果。

渲染预测仿真的一种不错的策略是在仿真循环内同步一个状态机模式,并在渲染步骤函数中渲染状态变化。以下示例模拟一个带有状态机模式的手榴弹:

跟踪手榴弹的简单状态机(ModuleScript)
local module = {}
module.GrenadeStates = {
Idle = 0,
Lit = 1,
Exploded = 2,
Defused = 3,
}
module.GrenadeExplodeTime = 3.0
module.Initialize = function(grenade)
RunService:BindToSimulation(function(deltaTime)
-- 初始化空的手榴弹状态
local grenadeState = grenade:GetAttribute("State")
if grenadeState == nil then
grenadeState = module.GrenadeStates.Idle
grenade:SetAttribute("State", grenadeState)
grenade:SetAttribute("Timer", 0.0)
end
-- 增加手榴弹计时器
local timer = grenade:GetAttribute("Timer")
timer = timer + deltaTime
grenade:SetAttribute("Timer", timer)
-- 爆炸点燃的手榴弹
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

有了上面的状态机,您可以在基于同步手榴弹状态的单独脚本中,在 RunService.RenderStepped 连接中渲染手榴弹效果:

根据同步手榴弹状态渲染粒子和声音
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Simulation = require(ReplicatedStorage.Simulation)
local grenade = script.Parent
local previousGrenadeState = nil
-- 高亮实例以指示手榴弹状态
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")
-- 如果手榴弹点燃,则发出点燃的粒子
grenade.LitEmitter.Enabled = grenadeState == Simulation.GrenadeStates.Lit
-- 如果手榴弹刚刚爆炸,则播放爆炸发射器
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
-- 根据状态和时间改变手榴弹的高亮颜色
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)

设计网络延迟

某些游戏机制相比其他机制更适合网络化的多人模式。玩家始终会在其他玩家进行操作与收到该玩家输入之间存在一定的延迟。创建一个非常流畅的多人游戏的最佳方法是设计您的游戏时考虑到这些限制。

例如,玩家移动的游戏具有较慢的加速将比加速更快的游戏看起来更平滑,因为网络延迟造成的位置差异在加速较低的游戏中会小于加速较高的游戏。

另一个例子是,玩家通过按下输入 瞬间 触发大爆炸的游戏机制将比输入后爆炸延迟的游戏机制产生更多网络伪影,就好像是点燃引信。这将使重仿真放在引信效果上,而不是爆炸效果上,这是一种不太明显的网络伪影。

预测其他玩家输入

默认情况下,Roblox 不会将每个客户端的输入转发给每个其他客户端。这是否适合您的游戏取决于其设计:

  • 对于基本的人形移动,默认行为意味着其他玩家角色的移动不会从授权的服务器状态中推断,因此其他玩家角色不会出现误预测,但将稍微渲染在过去。
  • 相比之下,在赛车游戏中,默认行为意味着客户端将不知道其他玩家是否在加油或进行其他输入,因此其他汽车可能在本地玩家后面显示,即使它们实际上在前面。为缓解此情况,您可以在服务器上将玩家输入存储在 属性 中,并在客户端使用 RunService:BindToSimulation() 操作这些同步属性,如以下代码示例和 赛车 模版所示。这种方法让您能够将属性用作输入到仿真中,以便完全复制玩家输入。
在属性中存储玩家输入(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)
-- 将其他输入写入属性...
end
module.Initialize = function()
RunService:BindToSimulation(function(deltaTime)
if RunService:IsServer() then
-- 从服务器转发输入到所有客户端
for _, player in Players:GetPlayers() do
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local inputContext:InputContext = player.PlayerGui.InputContext
module.storePlayerInput(player, humanoidRootPart)
end
else
-- 将本地玩家输入写入属性
local player = Players.LocalPlayer
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local inputContext:InputContext = player.PlayerGui.InputContext
module.storePlayerInput(player, humanoidRootPart)
end
-- 使用属性作为游戏的输入
for _, player in Players:GetPlayers() do
local humanoidRootPart:BasePart = player.Character.HumanoidRootPart
local throttle = humanoidRootPart:GetAttribute("Throttle")
if throttle then
-- 将油门应用于玩家的车辆
end
end
end)
end)
return module

调试

您可以使用一些新工具和技术来调试服务器授权游戏。

服务器权限可视化工具

按下 CtrlShiftF6(Windows)或 ShiftF6(Mac)将打开 Studio 的 服务器权限可视化工具,该工具显示若干关键信息:

详细信息描述
实例预测成功率过去 8 秒正确预测的实例百分比。
输入接受率所有玩家输入在服务器上按时到达的百分比。延迟的输入将降低此数字。
客户端-服务器步进增量客户端和服务器之间的帧数,包括客户端的加入时间。这个数字的稳定性代表您与服务器连接的稳定性。
RCC 心跳 FPS服务器上仿真的帧率。如果这个数字低于 59,服务器将无法赶上仿真,游戏质量将下降。
预测实例计数您的客户端正在 预测 的实例数量。
输入丢弃原因计数

服务器因每个原因丢弃输入的次数:

  • [x] 太旧 — 输入来得太晚,意味着您的网络恶化或客户端无法跟上仿真。
  • [x] 无序 — 发生网络错误,导致输入被重新排序并丢弃。
  • [x] 缓冲区满 — 服务器无法缓冲您的输入。要么您的网络突然改善,要么服务器无法跟上仿真。

仿真半径

当依赖自动预测(Enum.PredictionMode.Automatic)时,您可以通过启用 Studio 设置中的 是否启用区域 来可视化您的玩家角色周围的预测半径(Windows 上为 AltS;Mac 上为 S)。绿色圆柱表示您的角色周围的范围,在该范围内实例被预测,其半径根据设备的性能特征而增大或减小。

带有服务器权限运行的玩家角色周围的仿真半径
©2026 Roblox Corporation、Roblox、Roblox 标志及 Powering Imagination 是我们在美国及其他国家或地区的注册与未注册商标。