当我们编写移动PvP射击游戏的网络代码时:客户端上的播放器同步

一篇文章中,我们回顾了新项目中使用的技术-移动设备的快速射击游戏。 现在,我想分享如何安排未来游戏的网络代码的客户端部分,我们遇到的困难以及如何解决它们。




通常,在过去20年中创建快速多人游戏的方法并没有太大变化。 网络代码体系结构中可以区分几种方法:

  1. 错误地计算了服务器上的世界状态,并在客户端上显示了结果,而没有对本地玩家的预测,并且可能会丢失玩家的输入(输入)。 顺便说一下,这种方法已在我们开发中的其他项目中使用-您可以在此处阅读有关方法。
  2. 锁步
  3. 在没有确定性逻辑的情况下将世界状态与本地玩家的预测同步。
  4. 具有完全确定性逻辑的输入同步和本地播放器的预测。

特殊之处在于,在射手中,最重要的是控制的响应能力-玩家按下按钮(或移动操纵杆),并希望立即看到其动作的结果。 首先,由于此类游戏的世界状态变化非常快,因此有必要立即对此情况做出响应。

因此,没有本地参与者动作(预测)的预测机制的方法不适合该项目,因此我们确定了一种在没有确定逻辑的情况下同步世界状态的方法。

该方法的优势:与交换输入时的同步方法相比,实现的复杂性更低。
减:将整个世界的状态发送给客户端时的流量增加。 我们必须应用几种不同的流量优化技术,才能使游戏在移动网络上稳定运行。

ECS是游戏架构的核心,我们已经讨论过了 。 这种体系结构使您可以方便地存储有关游戏世界的数据,进行序列化,复制和通过网络传输。 并且还要在客户端和服务器上执行相同的代码。

游戏世界的模拟以固定的每秒30滴答的频率进行。 这样您可以减少玩家输入的延迟,并且几乎不使用插值来直观地显示世界状况。 但是,在开发这样的系统时,应考虑一个重大缺陷:为了使本地玩家的预测系统正常工作,客户端必须以与服务器相同的频率模拟世界。 我们花了很多时间来针对目标设备进行足够优化的仿真。

本地玩家动作预测机制(预测)


由于客户端和服务器上都执行相同的系统,因此基于ECS实施客户端预测机制。 但是,不是所有系统都在客户端上执行,而是仅由负责本地播放器且不需要其他播放器相关数据的系统执行。

客户端和服务器上运行的系统列表示例:



目前,我们在客户端上运行着大约30个提供玩家预测的系统,在服务器上运行着大约80个系统。 但是我们不会预测诸如造成伤害,使用技能或治疗盟友之类的事情。 这些机制有两个问题:

  1. 客户对于进入其他玩家一无所知,并且预测诸如损坏或恢复之类的事情几乎总是会与服务器上的数据产生偏差。
  2. 由一个玩家在本地创建新实体(射击,炮弹,独特能力)会带来与服务器上创建的实体匹配的问题。

对于这样的技工,滞后以其他方式对玩家隐藏。

范例:我们立即从射击中获得击中的效果,只有在收到服务器确认击中的确认后,我们才会更新敌人的生命。

项目中网络代码的总体方案




客户端和服务器通过刻度号同步时间。 由于网络上的数据传输会花费一些时间,因此客户端始终比服务器领先RTT +服务器上输入缓冲区大小的一半。 上图显示了客户端发送的订单号20(a)的输入。 同时,在服务器上处理订单号15(b)。 当客户端的输入到达服务器时,刻度号20将在服务器上处理。

整个过程包括以下步骤:客户端将玩家的输入发送到服务器(a)→在HRTT +输入缓冲区大小(b)之后,服务器上将处理此输入→服务器将生成的世界状态发送给客户端(s)→客户端将确认的世界状态应用于服务器时间RTT +输入缓冲区大小+游戏状态插值缓冲区大小(d)。

客户从服务器(d)收到新的确认的世界状态后,他需要完成对帐过程。 事实是,客户仅根据本地玩家的输入进行世界预测。 他不知道其他玩家的输入。 并且在计算服务器上的世界状态时,玩家可能处于与客户端预测的状态不同的状态。 当玩家被震惊或杀死时,可能会发生这种情况。

批准过程包括两个部分:

  1. 从服务器收到的滴答N的世界预测状态的比较。 比较中仅涉及与本地播放器有关的数据。 世界其他地方的数据始终是从服务器状态获取的,并不参与协调。
  2. 在比较期间,可能会发生两种情况:

-如果世界的预测状态与服务器确认的状态一致,则客户端使用本地玩家的预测数据和世界其他地区的新数据,继续以正常模式模拟世界;
-如果预测状态不匹配,则客户端将使用服务器的整个世界状态以及客户端输入的历史记录,并重新计算玩家世界的新预测状态。

在代码中,它看起来像这样:
GameState Reconcile(int currentTick, ServerGameStateData serverStateData, GameState currentState, uint playerID) { var serverState = serverStateData.GameState; var serverTick = serverState.Time; var predictedState = _localStateHistory.Get(serverTick); //if predicted state matches server last state use server predicted state with predicted player if (_gameStateComparer.IsSame(predictedState, serverState, playerID)) { _tempState.Copy(serverState); _gameStateCopier.CopyPlayerEntities(currentState, _tempState, playerID); return _localStateHistory.Put(_tempState); // replace predicted state with correct server state } //if predicted state doesn't match server state, reapply local inputs to server state var last = _localStateHistory.Put(serverState); // replace wrong predicted state with correct server state for (var i = serverTick; i < currentTick; i++) { last = _prediction.Predict(last); // resimulate all wrong states } return last; } 


两个世界状态的比较仅针对那些与本地玩家有关并参与预测系统的数据进行。 数据通过玩家ID进行采样。

比较方法:
 public bool IsSame(GameState s1, GameState s2, uint avatarId) { if (s1 == null && s2 != null || s1 != null && s2 == null) return false; if (s1 == null && s2 == null) return false; var entity1 = s1.WorldState[avatarId]; var entity2 = s2.WorldState[avatarId]; if (entity1 == null && entity2 == null) return false; if (entity1 == null || entity2 == null) return false; if (s1.Time != s2.Time) return false; if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId]) return false; foreach (var s1Weapon in s1.WorldState.Weapon) { if (s1Weapon.Value.Owner.Id != avatarId) continue; var s2Weapon = s2.WorldState.Weapon[s1Weapon.Key]; if (s1Weapon.Value != s2Weapon) return false; var s1Ammo = s1.WorldState.WeaponAmmo[s1Weapon.Key]; var s2Ammo = s2.WorldState.WeaponAmmo[s1Weapon.Key]; if (s1Ammo != s2Ammo) return false; var s1Reload = s1.WorldState.WeaponReloading[s1Weapon.Key]; var s2Reload = s2.WorldState.WeaponReloading[s1Weapon.Key]; if (s1Reload != s2Reload) return false; } if (entity1.Aiming != entity2.Aiming) return false; if (entity1.ChangeWeapon != entity2.ChangeWeapon) return false; return true; } 


特定组件的比较运算符与整个EC结构一起生成,由代码生成器专门编写。 例如,我将给出Transform组件比较运算符的生成代码:

代号
 public static bool operator ==(Transform a, Transform b) { if ((object)a == null && (object)b == null) return true; if ((object)a == null && (object)b != null) return false; if ((object)a != null && (object)b == null) return false; if (Math.Abs(a.Angle - b.Angle) > 0.01f) return false; if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f) return false; return true; } 


应该注意的是,我们的浮点值与较高的误差进行了比较。 这样做是为了减少客户端和服务器之间的不同步量。 对于播放器而言,这种错误将是不可见的,但这会大大节省系统的计算资源。

协调机制的复杂性在于,如果客户端和服务器状态发生错误同步(错误预测),则需要重复模拟所有预测的客户端状态,直到服务器在一帧中的当前滴答声为止,这些预测的客户端状态都没有服务器的确认。 根据玩家的ping,这可能是5到20个模拟滴答声。 我们必须极大地优化仿真代码以适应时间范围:30 fps。

要完成批准过程,必须在客户端上存储两种类型的数据:

  1. 预测玩家状态的历史。
  2. 和历史的输入。

为此,我们使用循环缓冲区。 缓冲区大小为32个刻度。 以30 HZ的频率提供的时间大约为1秒。 客户端可以安全地继续使用预测机制,而无需从服务器接收新数据,直到填充此缓冲区为止。 如果客户端与服务器之间的时间差开始超过一秒,则客户端将被迫断开连接并尝试重新连接。 如果世界各州之间存在差异,则由于协调过程的成本,我们会有这样的缓冲区大小。 但是,如果客户端和服务器之间的差异超过一秒,则执行到服务器的完全重新连接会更便宜。

滞后时间减少


上图显示,在游戏中,数据传输方案中有两个缓冲区:

  • 服务器上的输入缓冲区;
  • 客户端上的世界状态缓冲区。

这些缓冲区的目的是相同的-补偿网络跳跃(抖动)。 事实是网络上的数据包传输不均匀。 并且由于网络引擎以30 HZ的固定频率运行,因此必须以相同的频率将数据提供给引擎。 在下一个数据包到达接收者之前,我们没有机会“等待”几毫秒。 我们将缓冲区用于输入数据和世界状态,以便有时间进行抖动补偿。 如果其中一个数据包丢失,我们还可以使用gamestate缓冲区进行插值。

在游戏开始时,客户端只有在从服务器接收到多个世界状态并且游戏状态缓冲区已满后,才开始与服务器同步。 通常,此缓冲区的大小为3个刻度(100毫秒)。

同时,当客户端与服务器同步时,客户端将在服务器时间之前“运行”服务器上输入缓冲区的值。 即 客户端本身控制着它距离服务器多远。 输入缓冲区的起始大小也等于3个刻度(100毫秒)。

最初,我们将这些缓冲区的大小实现为常量。 即 无论抖动是否确实存在于网络上,都存在200毫秒(输入缓冲区大小+游戏状态缓冲区大小)的固定延迟来更新数据。 如果再加上200毫秒左右的移动设备上的平均估计ping,那么在客户端上使用输入与从服务器确认应用之间的实际延迟为400毫秒!

这不适合我们。

事实是,某些系统仅在服务器上运行-例如,计算玩家的HP。 在这种延迟下,玩家射击,并且只有在400毫秒之后才能看到他如何杀死对手。 如果这种情况发生在运动中,那么通常玩家会设法跑到墙后或躲进掩护中并且已经死在那里。 团队内部的游戏测试表明,这种延迟完全破坏了整个游戏过程。

解决此问题的方法是实现输入缓冲区和游戏状态的动态大小:
  • 对于游戏状态缓冲区,客户端始终知道当前缓冲区的内容。 在计算下一个滴答时,客户端检查缓冲区中已经有多少个状态。
  • 对于输入缓冲区-服务器除了游戏状态外,还开始向客户端发送特定客户端的输入缓冲区当前填充值。 客户端依次分析这两个值。

游戏状态缓冲区大小调整算法大致如下:

  1. 客户端会考虑一段时间内缓冲区大小的平均值和方差。
  2. 如果方差在正常范围内(即,在给定的时间段内,缓冲区的填充和读取没有大的跳跃),则客户端会检查该时间段内平均缓冲区大小的值。
  3. 如果平均缓冲区填充量大于上限条件(也就是说,缓冲区填充量将超过要求的上限),则客户端将通过执行附加的模拟刻度来“减小”缓冲区的大小。
  4. 如果平均缓冲区填充量小于下边界条件(也就是说,在客户端开始读取缓冲区之前缓冲区没有足够的时间填充)-在这种情况下,客户端通过跳过模拟的一滴答声来“增加”缓冲区的大小。
  5. 在方差高于正常值的情况下,我们不能依赖这些数据,因为 给定时间段内的网络激增太大。 然后,客户端将丢弃所有当前数据,并再次开始收集统计信息。

服务器延迟补偿


由于客户端会延迟(滞后)从服务器接收世界更新,因此玩家看到的世界与服务器上存在的世界有些不同。 玩家将看到自己在现在以及过去的世界中。 在服务器上,整个世界一次存在。


因此,情况是玩家在本地射击位于另一位置服务器上的目标。

为了补偿延迟,我们使用服务器上的时间倒带。 操作算法大致如下:

  1. 具有每个输入的客户端还向服务器发送滴答时间,在滴答时间中他可以看到世界的其他地方。
  2. 服务器会验证此时间:是置信区间中当前时间与客户世界的可见时间之间的差。
  3. 如果时间有效,服务器将在当前时间离开玩家,世界其他地区会回滚到玩家看到的状态并计算射击结果。
  4. 如果有玩家命中,则在当前服务器时间内造成伤害。

服务器上的倒带时间的工作方式如下:世界历史(在ECS中)和物理历史(在Volatile Physics引擎的支持下)存储在北部。 在计算射门次数时,玩家的数据取自当前的世界状况,其余的玩家则取自历史记录。

镜头验证系统的代码如下所示:
 public void Execute(GameState gs) { foreach (var shotPair in gs.WorldState.Shot) { var shot = shotPair.Value; var shooter = gs.WorldState[shotPair.Key]; var shooterTransform = shooter.Transform; var weaponStats = gs.WorldState.WeaponStats[shot.WeaponId]; // DeltaTime shouldn't exceed physics history size var shootDeltaTime = (int) (gs.Time - shot.ShotPlayerWorldTime); if (shootDeltaTime > PhysicsWorld.HistoryLength) { continue; } // Get the world at the time of shooting. var oldState = _immutableHistory.Get(shot.ShotPlayerWorldTime); var potentialTarget = oldState.WorldState[shot.Target.Id]; var hitTargetId = _singleShotValidator.ValidateTargetAvailabilityInLine(oldState, potentialTarget, shooter, shootDeltaTime, weaponStats.ShotDistance, shooter.Transform.Angle.GetDirection()); if (hitTargetId != 0) { gs.WorldState.CreateEntity().AddDamage(gs.WorldState[hitTargetId], shooter, weaponStats.ShotDamage); } } } 


该方法的一个重大缺点是,我们信任客户所看到的有关滴答声时间的数据。 潜在地,玩家可以通过人为地增加ping来获得优势。 因为 玩家的ping越多,过去的射击就越远。

我们遇到的一些问题


在实施该网络引擎的过程中,我们遇到了许多问题,其中一些问题值得单独撰写,但是在这里,我将仅涉及其中一些问题。

在预测系统中模拟整个世界并进行复制


最初,ECS中的所有系统只有一种方法:void Execute(GameState gs)。 在这种方法中,通常处理与所有玩家有关的组件。

初始实现中的运动系统示例:
 public sealed class MovementSystem : ISystem { public void Execute(GameState gs) { foreach (var movementPair in gs.WorldState.Movement) { var transform = gs.WorldState.Transform[movementPair.Key]; transform.Position += movementPair.Value.Velocity * GameState.TickDuration; } } } 


但是在本地玩家预测系统中,我们只需要处理与特定玩家相关的组件。 最初,我们使用copy来实现。

预测过程如下:

  1. 游戏状态的副本已创建。
  2. 副本已提供给ECS输入。
  3. ECS中模拟了整个世界。
  4. 从新接收到的游戏状态复制了与本地玩家有关的所有数据。

预测方法如下所示:
 void PredictNewState(GameState state) { var newState = _stateHistory.Get(state.Tick+1); var input = _inputHistory.Get(state.Tick); newState.Copy(state); _tempGameState.Copy(state); _ecsExecutor.Execute(_tempGameState, input); _playerEntitiesCopier.Copy(_tempGameState, newState); } 


此实现中存在两个问题:

  1. 因为 我们使用类而不是结构-复制对于我们来说是一项昂贵的操作(在iPhone 5S上约为0.1-0.15毫秒)。
  2. 整个世界的模拟也要花费很多时间(在iPhone 5S上约为1.5-2毫秒)。

如果我们考虑到在协调过程中有必要在一帧中重新计算5至15个世界状态,那么采用这种实施方式,一切都将非常缓慢。

解决方案非常简单:学会部分模拟世界,即仅模拟特定玩家。 我们重写了所有系统,以便您可以转移玩家的ID并仅模拟他。

更改后的运动系统示例:
 public sealed class MovementSystem : ISystem { public void Execute(GameState gs) { foreach (var movementPair in gs.WorldState.Movement) { Move(gs.WorldState.Transform[movementPair.Key], movementPair.Value); } } public void ExecutePlayer(GameState gs, uint playerId) { var movement = gs.WorldState.Movement[playerId]; if(movement != null) { Move(gs.WorldState.Transform[playerId], movement); } } private void Move(Transform transform, Movement movement) { transform.Position += movement.Velocity * GameState.TickDuration; } } 


进行更改后,我们可以消除预测系统中不必要的副本,并减少匹配系统的负担。

代码:
 void PredictNewState(GameState state, uint playerId) { var newState = _stateHistory.Get(state.Tick+1); var input = _inputHistory.Get(state.Tick); newState.Copy(state); _ecsExecutor.Execute(newState, input, playerId); } 


在预测系统中创建和删除实体


在我们的系统中,服务器和客户端上的实体匹配通过整数标识符(id)进行。 对于所有实体,我们使用标识符的端到端编号,每个新实体的值都为id = oldID + 1。

这种方法的实现非常方便,但是它有一个明显的缺点:在客户端和服务器上创建新实体的顺序可能不同,结果,实体的标识符也会不同。

当我们实施一个预测球员投篮的系统时,这个问题就很明显了。 与我们一起拍摄的每个镜头都是具有镜头组成部分的单独实体。 对于每个客户,预测系统中镜头实体的ID是连续的。 但是,如果同时有其他玩家射击,则服务器上所有射击的ID与客户端不同。

服务器上的镜头以不同的顺序创建:



对于射击,我们根据游戏的游戏功能来规避此限制。 镜头是快活的实体,在创建后一秒钟之内就会在系统中被破坏。 在客户端上,我们突出显示了一个单独的ID范围,这些ID不会与服务器ID相交,并且不再考虑协调系统中的镜头。 即 始终仅根据预测系统在游戏中绘制本地玩家的镜头,而不会考虑来自服务器的数据。

使用这种方法,玩家不会在屏幕上看到伪像(删除,重新创建,回滚镜头),并且与服务器的差异很小,并且不会整体上影响游戏性。

这种方法可以解决拍摄问题,但不能解决在客户端整体上创建实体的整个问题。 我们仍在研究可能的方法来解决客户端和服务器上已创建对象的比较。

还应注意,此问题仅涉及新实体(具有新ID)的创建。 在已创建的实体上添加和删除组件不会出现问题:组件没有标识符,每个实体只能有一个特定类型的组件。 因此,我们通常在服务器上创建实体,而在预测系统中,我们仅添加/删除组件。

总之,我想说的是,实现多人游戏的任务并不是最简单,最快的,但是有关如何执行此操作的信息很多。

读什么


Source: https://habr.com/ru/post/zh-CN415959/


All Articles