【Unity Photon Fusion 2】多人联网插件,主机模式基础教程

概述

        Fusion是Unity的一個新的高性能狀態同步網路庫。Fusion在組建時考慮到了簡化性,可以自然地集成到普通的Unity工作流程中,同時也提供了先進的功能,如資料壓縮、客戶端預測和開箱即用的延遲補償。

        本質上,Fusion依靠最先進的壓縮演算法,以最小的CPU額外負荷來減少頻寬要求。資料以部分區塊的形式傳輸,最終具有一致性。提供一個完全可設置的興趣區域系統,以支援非常高的玩家計數。

        Fusion API設計為類似於常規Unity單行為程式碼。舉例而言,以方法上的屬性與單行為的屬性來定義RPC及網路狀態,而不需要明確的序列化程式碼,還有可以使用所有Unity的最近期的預製件功能,像是巢狀及變數,來定義網路物件為預製件

官网

Fusion 2 Introduction | Photon Engine

1、插件导入与配置准备

1.1 创建帐户,先创建一个PhotonEngine帐户

链接

Sign In | Photon Engine

1.2 下载SDK,最新的SDK可以从Getting Started > SDK & Release Notes页面。 

下载链接

SDK & Download | Photon Engine

1.3  创建一个空项目,官方文档最低要求是统一2021.3.x LTS或者以上

注意:Fusion是一个网络库,因此与所选择的渲染管道无关;它对所有渲染管线都有效。

1.4 项目设置

        一些融合设置将保存在ScriptableObject资源中。为了使这些设置始终清晰可见,资产序列化的模式必须设置为Force Text

        在Edit > Project Settings > Editor > Asset Serialization > Mode

        Fusion IL Weaver生成低级网络代码并将其注入Assembly-CSharp.dll。为了实现这一目标,使用了Mono Cecil软件包。软件包可以通过Unity软件包管理器安装。 

导入路径Window > Package Manager > Click the + icon > Add package from git URL

com.unity.nuget.mono-cecil@1.10.2

1.5 导入Fusion SDK

完成步骤1到5后,项目现在可以导入Fusion SDK了。SDK以。unitypackage文件的形式提供,可以使用Assets > Import Package > Custom Package工具。只需导航到下载SDK的位置并触发导入。

1.6 创建应用程序ID 

导入完成后,将弹出Fusion Hub向导。这Welcome屏幕将要求输入应用程序ID。在填写之前,需要创建一个新的应用ID。

应用程序ID是应用程序标识符,用于:

  • 识别应用程序;
  • 将应用程序与正确类型的服务器插件相关联-在本例中为Fusion而且,
  • 使用应用程序连接玩家。

1.7 添加AppID,复制仪表板上显示的应用程序Id,并粘贴到Fusion Hub中。 

 

2、主机模式基础使用

2.1 场景初始设置,并创建简单玩家生成逻辑

2.1.1  创建脚本BasicSpawner

1)、创建空物体NetworkRunner,

2)、创建脚本BasicSpawner,挂载到空物体上

3)、打开脚本,将INetworkRunnerCallbacks接口添加到BasicSpawner类,以及所有所需方法的存根

BasicSpawner 当前代码

using UnityEngine;
using UnityEngine.SceneManagement;
using Fusion;

public class BasicSpawner : MonoBehaviour, INetworkRunnerCallbacks
{
  public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) { }
  public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) { }
  public void OnInput(NetworkRunner runner, NetworkInput input) { }
  public void OnInputMissing(NetworkRunner runner, PlayerRef player, NetworkInput input) { }
  public void OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) { }
  public void OnConnectedToServer(NetworkRunner runner) { }
  public void OnDisconnectedFromServer(NetworkRunner runner, NetDisconnectReason reason) { }
  public void OnConnectRequest(NetworkRunner runner, NetworkRunnerCallbackArgs.ConnectRequest request, byte[] token) { }
  public void OnConnectFailed(NetworkRunner runner, NetAddress remoteAddress, NetConnectFailedReason reason) { }
  public void OnUserSimulationMessage(NetworkRunner runner, SimulationMessagePtr message) { }
  public void OnSessionListUpdated(NetworkRunner runner, List<SessionInfo> sessionList) { }
  public void OnCustomAuthenticationResponse(NetworkRunner runner, Dictionary<string, object> data) { }
  public void OnHostMigration(NetworkRunner runner, HostMigrationToken hostMigrationToken) { }
  public void OnSceneLoadDone(NetworkRunner runner) { }
  public void OnSceneLoadStart(NetworkRunner runner) { }
  public void OnObjectExitAOI(NetworkRunner runner, NetworkObject obj, PlayerRef player){ }
  public void OnObjectEnterAOI(NetworkRunner runner, NetworkObject obj, PlayerRef player){ }
  public void OnReliableDataReceived(NetworkRunner runner, PlayerRef player, ReliableKey key, ArraySegment<byte> data){ }
  public void OnReliableDataProgress(NetworkRunner runner, PlayerRef player, ReliableKey key, float progress){ }
}

        实现inetworkrunnercallback将允许Fusions NetworkRunner与BasicSpawner进行交互。NetworkRunner是Fusion的核心和灵魂,运行实际的网络模拟。


        NetworkRunner将自动检测BasicSpawner实现INetworkRunnerCallbacks并调用其方法,因为它将在StartGame方法中添加到相同的游戏对象中。


在BasicSpawner脚本中添加以下内容:

private NetworkRunner _runner;

async void StartGame(GameMode mode)
{
    // Create the Fusion runner and let it know that we will be providing user input
    _runner = gameObject.AddComponent<NetworkRunner>();
    _runner.ProvideInput = true;

    // Create the NetworkSceneInfo from the current scene
    var scene = SceneRef.FromIndex(SceneManager.GetActiveScene().buildIndex);
    var sceneInfo = new NetworkSceneInfo();
    if (scene.IsValid) {
        sceneInfo.AddSceneRef(scene, LoadSceneMode.Additive);
    }

    // Start or join (depends on gamemode) a session with a specific name
    await _runner.StartGame(new StartGameArgs()
    {
        GameMode = mode,
        SessionName = "TestRoom",
        Scene = scene,
        SceneManager = gameObject.AddComponent<NetworkSceneManagerDefault>()
    });
}

         StartGame方法首先创建Fusion NetworkRunner,并让它知道该客户端将提供输入。然后,它开始一个带有硬编码名称和指定游戏模式的新会话(稍后会有更多关于游戏模式的内容)。当前场景索引被传入,但这只与主机相关,因为客户端将被迫使用主机指定的场景。最后,为了更好地度量,还指定了一个默认的SceneManager。SceneManager处理直接放置在场景中的networkobject的实例化,严格地说,在这个例子中不需要,因为没有这样的对象


        Fusion支持多种网络拓扑,但本文将重点介绍Hosted Mode。在托管模式下,一个网络对等点既是服务器又是客户端并创建网络会话,而其他对等点只是加入现有会话的客户端。


        为了适应这种情况,需要有一种方法让用户选择他们是托管游戏还是加入一个现有会话-为了简单起见,下面的例子使用Unity lMGUl。

将以下方法添加到BasicSpawner.cs

private void OnGUI()
{
  if (_runner == null)
  {
    if (GUI.Button(new Rect(0,0,200,40), "Host"))
    {
        StartGame(GameMode.Host);
    }
    if (GUI.Button(new Rect(0,40,200,40), "Join"))
    {
        StartGame(GameMode.Client);
    }
  }
}

        运行此应用程序将允许用户托管一个新会话,并允许其他实例加入该会话,但由于没有交互,也没有传输数据,因此它看起来像一个单人玩家体验。
 

2.1.2 创建玩家预制体

        为了让这成为一款游戏,每个玩家都必须拥有一种提供输入的方式,并在游戏世界中拥有自己的存在感,如玩家角色。
在Unity编辑器中,
1)、创建一个名为PlayerPrefab的新空Gameobject

2)、向其中添加一个Networkobject组件。
        这将赋予它网络身份,以便所有对等体都可以引用它。因为用户将控制这个角色,它也需要一个NetworkCharacterController-这不是一个要求,但它是一个很好的起点,为大多数玩家控制的对象,所以继续并添加它。NetworkCharacterController需要默认的Unity字符控制器组件,它将被自动添加。
通常建议将网络对象与其视觉表示分开。要做到这一点,
1)、添加一个标准的Unity胶囊体作为PlayerPrefab的子对象

2)、重命名为Body
3)、移除碰撞器
玩家预制体设置如下

         保存您的项目,让Fusion烘烤新的网络对象,然后将其拖到项目文件夹中以创建角色预制件,并从场景中删除对象。        

2.1.3 生成游戏化身

        因为游戏是在托管模式下运行的,所以只有主机才有权生成新对象。这意味着所有玩家的头像必须在他们加入会话时由主机生成。方便的是,INetworkRunnerCallbacks接口的OnPlayerJoined方法在这种情况下被调用
        类似地,当播放器断开连接时,调用OnPlayerLeft方法。
用下面的代码替换空的OnPlayerJoined和OnPlayerLeft方法

[SerializeField] private NetworkPrefabRef _playerPrefab;
private Dictionary<PlayerRef, NetworkObject> _spawnedCharacters = new Dictionary<PlayerRef, NetworkObject>();

public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
    if (runner.IsServer)
    {
        // Create a unique position for the player
        Vector3 spawnPosition = new Vector3((player.RawEncoded % runner.Config.Simulation.PlayerCount) * 3, 1, 0);
        NetworkObject networkPlayerObject = runner.Spawn(_playerPrefab, spawnPosition, Quaternion.identity, player);
        // Keep track of the player avatars for easy access
        _spawnedCharacters.Add(player, networkPlayerObject);
    }
}

public void OnPlayerLeft(NetworkRunner runner, PlayerRef player)
{
    if (_spawnedCharacters.TryGetValue(player, out NetworkObject networkObject))
    {
        runner.Despawn(networkObject);
        _spawnedCharacters.Remove(player);
    }
}

        这看起来应该很熟悉,因为它实际上用runner.Spawn() 取代了Unity的Instantiate()方法。Spawn()接受类似的一组参数,除了最后一个。最后一个参数是指允许玩家为角色提供输入的参考-需要注意的是,这与“拥有”对象不同。稍后会详细介绍。
        不要忘记回到Unity编辑器,并将创建的预制角色拖放到BasicSpawner的Player prefab字段中

2.1.4 收集玩家输入

         拥有输入权限不允许客户端直接修改对象的网络状态。相反,它可以提供一个输入结构,然后主机将对其进行解释,以便更新网络状态。
        客户端也可以在本地应用输入,为用户提供即时反馈,但这只是一个本地预测,可能会被主机否决。
        在收集用户输入之前,必须定义一个数据结构来保存输入。创建一个名为NetworkInputData.cs的新文件,其中包含以下结构体

using Fusion;
using UnityEngine;

public struct NetworkInputData : INetworkInput
{
  public Vector3 direction;
}

为了简单起见,本示例使用矢量来指示所需的移动方向,但要知道,有更少带宽开销的方法来做到这一点。例如,每个方向有一个位的位场。需要注意的是,Fusion会压缩输入,只发送有实际变化的数据,所以不要过于仓促地进行优化。
客户端需要在OnInput回调中由Fusion轮询时从用户收集输入,所以回到BasicSpawner.cs并用以下内容替换OnInput()方法

public void OnInput(NetworkRunner runner, NetworkInput input)
{
  var data = new NetworkInputData();

  if (Input.GetKey(KeyCode.W))
    data.direction += Vector3.forward;

  if (Input.GetKey(KeyCode.S))
    data.direction += Vector3.back;

  if (Input.GetKey(KeyCode.A))
    data.direction += Vector3.left;

  if (Input.GetKey(KeyCode.D))
    data.direction += Vector3.right;

  input.Set(data);
}

        同样,这看起来应该很熟悉――处理程序使用标准的Unity输入来收集和存储来自本地客户端的输入,并保存在之前定义的结构中。该方法的最后一行将预先填充的输入结构传递给Fusion,然后使其对主机和该客户端具有输入权限的任何对象可用。

2.1.5 使用玩家输入

最后一步是将收集到的输入数据应用于玩家角色。
1)、选择PlayerPrefab
2)、向其中添加一个名为Player的新脚本组件。
3)、打开新脚本,将MonoBehaviour替换为NetworkBehaviour

4)、实现FixedUpdateNetwork(),使行为可以参与Fusion模拟循环

using Fusion;

public class Player : NetworkBehaviour
{
  public override void FixedUpdateNetwork(){}
}

        fixedupdatenet在每个模拟时刻被调用。这可能在每个渲染帧中发生多次,因为Fusion应用了一个较旧的已确认的网络状态,然后从那个tick一直重新模拟到当前(预测的)本地tick。
        应该在fixedupdatenetwork中应用输入,以确保为每个tick应用正确的输入。Fusion提供了一种简单的方法来获取相关标记的输入,它被恰当地命名为Get input)。一旦获得了输入,就调用NetworkCharacterController来将实际的移动应用于角色转换
完整的Player类是这样的

using Fusion;

public class Player : NetworkBehaviour
{
  private NetworkCharacterController _cc;

  private void Awake()
  {
    _cc = GetComponent<NetworkCharacterController>();
  }

  public override void FixedUpdateNetwork()
  {
    if (GetInput(out NetworkInputData data))
    {
      data.direction.Normalize();
      _cc.Move(5*data.direction*Runner.DeltaTime);
    }
  }
}

请注意,所提供的输入是标准化的,以防止作弊。


2.1.6 测试

        现在剩下的就是验证游戏确实有可移动的玩家角色,但首先场景需要一个地板,这样生成的物体就不会从视野中掉出来。
        创建一个立方体对象,将其置于(0,-1,0),并在X和z上缩放100。注意创建并分配一个新材料,所以所有东西都不是相同的颜色。
        构建应用程序并启动多个实例(或直接从Unity中运行其中一个实例)。确保其中一个客户端按下Host按钮,其他客户端按下Join。
        关于在Unity中运行的一个重要注意事项:当在编辑器中运行游戏时,帧率可能非常不稳定,因为编辑器本身需要偶尔进行渲染。这会影响fusion正确预测时间的能力,并可能导致在构建的应用程序中不会发生的少量抖动。如果有疑问,请尝试运行两个独立的构建。

2.2 Prediction 预测

概述
Fusion 将解释预测以及如何在服务器权威网络游戏中为客户端提供快速反馈。
在这个部分的最后,项目将允许玩家生成一个预测的运动球。

2.2.1 运动的物体,创建运动的球

为了能够刷出任何物体,它必须首先有一个预制件。
1)、在Unity编辑器中创建一个新的空Gameobject
2)、将其重命名为Ball
3)、向它添加一个新的NetworkTransform组件。
4)、Fusion将显示关于丢失Networkobject component的警告,因此继续并单击Add Network Object
5)、给球添加一个子物体球
6)、所有方向都缩小到0.2

7)、从子球体中移除碰撞器
8)、在父对象上创建一个新的球体碰撞器,并赋予其半径0.1,以便它完全覆盖子对象的视觉表示。
9)、向游戏对象添加一个新脚本,并将其命名为Ball.cs
10)、最后,将整个Ball object拖到项目文件夹中以创建预制件

11)、保存场景以烘烤网络对象,并从场景中删除预制实例。


 

2.2.2 预测运动,让球移动

 目标是使Ball的实例在所有对等节点上同时表现相同。
“同时”在这里意味着“在相同的模拟时间”,而不是相同的现实世界时间。实现方法如下:
1)、服务器在特定的、均匀间隔的时间点运行模拟,并在每个时间点上调用FixedUpdateNetwork()。服务器只并且总是从一个时间点向前移动到下一个时间点―—这就像FixedUpdate()在本地物理模拟中用于常规Unity行为。在每个模拟标记之后,服务器计算、压缩并广播网络状态相对于前一个标记的变化。
2)、客户机定期接收这些快照,但显然总是落后于服务器。当接收到快照时,客户端将其内部状态设置回该快照的刻度,但随后通过运行自己的模拟,立即重新模拟接收到的快照和客户端当前刻度之间的所有刻度。
3)、客户端当前的时间间隔总是远远超过服务器,因此它从用户那里收集的输入可以在服务器到达给定的时间间隔之前发送到服务器,并且需要输入来运行其模拟。

这有以下几点含义:
1)、客户端每帧运行fixedupdatennetwork()多次,并在接收更新的快照时多次模拟相同的节拍。这适用于网络状态,因为Fusion在调用FixedUpdateNetwork()之前将其重置为正确的刻度,但对于非网络状态则不是这样,因此在FixedUpdateNetwork()中使用本地状态时要非常小心。
2)、每个对等体都可以基于已知的先前位置、速度、加速度和其他确定性属性来模拟任何物体的预测未来状态。它无法预测的一件事是来自其他玩家的输入,所以预测会失败。
3)、虽然本地输入立即应用于客户端以获得即时反馈,但它们并不具有权威性。它仍然是由服务器生成的快照,最终定义了输入的本地应用程序,这只是一个预测。

考虑到这一点,打开Ball脚本,将基类更改为networkbehaviour,将其包含在Fusion的模拟循环中,并将预生成的样板代码替换为Fusion的覆盖FixedUpdateNetwork()

在这个简单的例子中,我们将让球以恒定的速度向前移动5秒,然后再去刷出。继续并添加一个简单的线性运动的对象变换,像这样

using Fusion;

public class Ball : NetworkBehaviour
{
  public override void FixedUpdateNetwork()
  {
    transform.position += 5 * transform.forward * Runner.DeltaTime;
  }
}

        这几乎与用于移动常规非联网Unity对象的代码完全相同,除了时间步长不是time.deltatime而是Runner.DeltaTime对应于tick之间的时间。为什么它在像Unity转换这样的局部属性上跨网络工作的秘密当然是之前添加的NetworkTransform组件。NetworkTransform是一种方便的方法来确保转换属性是网络状态的一部分。
        代码仍然需要在设定的时间过期后去刷出对象,这样它就不会飞到无限远。最终循环并击中玩家的脖子。Fusion为计时器提供了一种方便的助手类型,它被恰当地命名为TickTimer。它不存储当前剩余时间,而是以刻度存储结束时间。这意味着计时器不需要在每次滴答时同步,而只需要在创建时同步一次。
        要将TickTimer添加到游戏的网络状态,需要为球添加一个名为life的TickTimer类型的属性,为getter和setter提供空存根,并用[networked]属性标记它。

[Networked] private TickTimer life { get; set; }

最后,FixedUpdateNetwork()必须更新以检查计时器是否已过期,如果过期,则销毁球:

if(life.Expired(Runner))
  Runner.Despawn(Object);

Ball类现在最终代码为

using Fusion;

public class Ball : NetworkBehaviour
{
  [Networked] private TickTimer life { get; set; }

  public void Init()
  {
    life = TickTimer.CreateFromSeconds(Runner, 5.0f);
  }

  public override void FixedUpdateNetwork()
  {
    if(life.Expired(Runner))
      Runner.Despawn(Object);
    else
      transform.position += 5 * transform.forward * Runner.DeltaTime;
  }
}

2.2.3 生成预制体

        生成任何预制件的工作原理与生成玩家角色相同,但当玩家生成是由网络事件(玩家加入游戏会话)触发时,球将根据用户输入生成。
要做到这一点,需要用额外的数据来扩充输入数据结构。这与移动的模式相同,需要三个步骤:
1)、向输入结构中添加数据
2)、从Unity的输入中收集数据
3)、在玩家的fixedupdatennetwork()实现中应用Input
打开NetworkInputData并添加一个名为buttons的新字节字段,并为第一个鼠标按钮定义一个const:

using Fusion;
using UnityEngine;

public struct NetworkInputData : INetworkInput
{
    public const byte MOUSEBUTTON0 = 1;

    public NetworkButtons buttons;
    public Vector3 direction;
}

NetworkButtons类型是一种Fusion类型,它帮助跟踪具有最佳带宽使用的多个联网按钮的输入状态。
打开BasicSpawner,转到OnInput()方法并添加对鼠标主按钮的检查,并设置按钮字段的第一个位,如果它是向下的。为了确保不会错过快速点击,在Update()中对鼠标按钮进行采样,并在输入结构中记录后重置:

private bool _mouseButton0;
private void Update()
{
  _mouseButton0 = _mouseButton0 | Input.GetMouseButton(0);
}

public void OnInput(NetworkRunner runner, NetworkInput input)
{
  var data = new NetworkInputData();

  if (Input.GetKey(KeyCode.W))
    data.direction += Vector3.forward;

  if (Input.GetKey(KeyCode.S))
    data.direction += Vector3.back;

  if (Input.GetKey(KeyCode.A))
    data.direction += Vector3.left;

  if (Input.GetKey(KeyCode.D))
    data.direction += Vector3.right;

  data.buttons.Set( NetworkInputData.MOUSEBUTTON0, _mouseButton0);
  _mouseButton0 = false;

  input.Set(data);
}

打开Player类,在Getlnput()检查中,检查按钮是否被按下并生成一个预制件。预制可以提供一个常规的Unity [SerializeField]成员,可以从Unity检查器中分配。为了能够在不同的方向上刷出,还添加了一个成员变量来存储最后的移动方向,并将其用作球的前进方向。
 

[SerializeField] private Ball _prefabBall;

private Vector3 _forward = Vector3.forward;
...
if (GetInput(out NetworkInputData data))
{
  ...
  if (data.direction.sqrMagnitude > 0)
    _forward = data.direction;
  if (data.buttons.IsSet(NetworkInputData.MOUSEBUTTON0))
  {
      Runner.Spawn(_prefabBall,
      transform.position+_forward, Quaternion.LookRotation(_forward),
      Object.InputAuthority);
  }
  ...
}
...

        为了限制刷出频率,将刷出调用包装在一个网络计时器中,该计时器必须在每次刷出之间过期。仅当检测到按钮按下时才重置计时器:

[Networked] private TickTimer delay { get; set; }
...
if (HasStateAuthority && delay.ExpiredOrNotRunning(Runner))
{
  if (data.buttons.IsSet(NetworkInputData.MOUSEBUTTON0))
  {
    delay = TickTimer.CreateFromSeconds(Runner, 0.5f);
    Runner.Spawn(_prefabBall,
    transform.position+_forward, Quaternion.LookRotation(_forward),
    Object.InputAuthority);
...

        需要检查StateAuthority,因为只有StateAuthority(主机)才能生成networkobject。因此,与移动不同,这些对象的衍生将只在主机上执行,而不会在客户端上预测。
        对Spawn的实际调用需要进行一些修改,因为球需要在同步之前进行额外的初始化。具体来说,必须调用前面添加的lnit()方法来确保正确设置tick计时器。
        为此,fusion允许为Spawn()提供一个回调,该回调将在实例化预制之后调用,但在同步之前调用。
Player类现在代码为

using Fusion;
using UnityEngine;

public class Player : NetworkBehaviour
{
  [SerializeField] private Ball _prefabBall;

  [Networked] private TickTimer delay { get; set; }

  private NetworkCharacterController _cc;
  private Vector3 _forward;

  private void Awake()
  {
    _cc = GetComponent<NetworkCharacterController>();
    _forward = transform.forward;
  }

  public override void FixedUpdateNetwork()
  {
    if (GetInput(out NetworkInputData data))
    {
      data.direction.Normalize();
      _cc.Move(5*data.direction*Runner.DeltaTime);

      if (data.direction.sqrMagnitude > 0)
        _forward = data.direction;

      if (HasStateAuthority && delay.ExpiredOrNotRunning(Runner))
      {
        if (data.buttons.IsSet(NetworkInputData.MOUSEBUTTON0))
        {
          delay = TickTimer.CreateFromSeconds(Runner, 0.5f);
            Runner.Spawn(_prefabBall,
            transform.position+_forward, Quaternion.LookRotation(_forward),
            Object.InputAuthority, (runner, o) =>
            {
              // Initialize the Ball before synchronizing it
              o.GetComponent<Ball>().Init();
            });
        }
      }
    }
  }
}

测试前的最后一步是将预制件分配给Player预制件上的_prefabBall字段。在项目中选择PlayerPrefab,然后将球预制件拖到prefab Ball字段中。

2.3 Physics 物理

概述
Fusion 将在服务器权威游戏中检查Fusion如何与PhysX交互。
在这部分的最后,项目将允许玩家刷出并与物理控制的球互动。
 

2.3.1 设置

默认情况下,Fusion会在主机上运行物理模拟,客户端也会跟着运行。然而,这将物理对象置于与本地预测对象不同的时间,如果这些对象发生碰撞,将会导致物理模拟出现问题。
双时间是一个复杂的话题,可以用多种方式来处理――有些是纯粹的hack,有些对于一般应用来说太昂贵,或者不能提供玩家期望的即时反馈。不幸的是,没有一个简单的解决方案适用于所有情况。
在本教程中,我们将使用Fusion的物理插件来本地预测物理对象,将物理控制的对象与玩家控制的角色同时放置。这是一个可靠的解决方案,也可以在生产中使用,但请记住,在PhysX中运行预测和重新模拟是昂贵的(即每tick多次)。
要获得物理插件,请进入下载页面,选择Unity包并将其导入到项目中。

下载链接
Download | Photon Engine



2.3.2 物理对象,创建具体物理组件的球

一个联网的、PhysX控制的预制对象使用与普通Unity PhysX对象相同的Rigidbody,但是有一个不同的Fusion组件来同步名为NetworkRigidbody的视觉子对象。就网络而言,它取代了NetworkTransform。
1)、在Unity编辑器中创建一个新的空GameObject
2)、将GameObject重命名为PhysxBall
3)、添加一个新的NetworkRigidbody3D组件。
4)、Fusion将显示关于丢失Networkobject组件的警告,因此继续并单击Add Network Object。Fusion也会自动添加一个常规的Unity RigidBody,因为这是PhysX模拟所需要的。
5)、给PhysxBall添加一个子物体球
6)、在各个方向都把孩子缩小到0.2
7)、将子对象拖拽到父对象上NetworkRigidbody3D组件的InterpolationTarget中。
8)、从球体中移除碰撞器
9)、在父对象上创建一个半径为0.1的新球体碰撞器,使其完全覆盖子对象。

10)、向游戏对象添加一个新脚本,并将其命名为PhysxBall.cs
11)、将整个对象拖到项目文件夹中以创建预制件
12)、保存场景以烘烤网络对象,并从场景中删除预制实例。

对于物理对象,建议分离视觉效果并将其分配为插值目标,但并不总是必要的。碰撞器和刚体必须在父对象上NetworkRigidbody。

2.3.3 PhysxBall脚本

因为球是由PhysX驱动的,NetworkRigidbody负责网络数据,所以它比它的运动学表兄需要更少的特殊代码才能工作。所有需要添加到PhysxBall.cs的是计时器,它将在几秒钟后使球消失(这与运动球完全相同),以及设置初始前进速度的方法。
两者都被Init()方法覆盖,如下所示:
 

using UnityEngine;
using Fusion;

public class PhysxBall : NetworkBehaviour
{
  [Networked] private TickTimer life { get; set; }

  public void Init(Vector3 forward)
  {
    life = TickTimer.CreateFromSeconds(Runner, 5.0f);
    GetComponent<Rigidbody>().velocity = forward;
  }

  public override void FixedUpdateNetwork()
  {
    if(life.Expired(Runner))
      Runner.Despawn(Object);
  }
}

2.3.4 输入,再次添加一个输入控制球发射,并补全代码

为了生成球,代码需要按照与运动球相同的三个步骤进行扩展,但改为使用第二个鼠标按钮:
1)、NetworkInputData
在NetworkInputData.cs只需添加一个新的按钮标志:

using Fusion;
using UnityEngine;

public struct NetworkInputData : INetworkInput
{
  public const byte MOUSEBUTTON0 = 1;
  public const byte MOUSEBUTTON1 = 2;

  public NetworkButtons buttons;
  public Vector3 direction;
}

2)、BasicSpawner
在BasicSpawner.cs中,以与第一个相同的方式轮询第二个鼠标按钮,并相应地设置标志:

private bool _mouseButton0;
private bool _mouseButton1;
private void Update()
{
  _mouseButton0 = _mouseButton0 || Input.GetMouseButton(0);
  _mouseButton1 = _mouseButton1 || Input.GetMouseButton(1);
}

public void OnInput(NetworkRunner runner, NetworkInput input)
{
  var data = new NetworkInputData();

  ...

  data.buttons.Set(NetworkInputData.MOUSEBUTTON0, _mouseButton0);
  _mouseButton0 = false;
  data.buttons.Set(NetworkInputData.MOUSEBUTTON1, _mouseButton1);
  _mouseButton1 = false;

  input.Set(data);
}

3)、Player

cs持有的代码,产生实际的球预制,所以除了需要这样的预制参考,

[SerializeField] private PhysxBall _prefabPhysxBall;

 玩家还必须调用刷出并使用之前创建的Init()方法设置速度(只是一个常数乘以最后一个前进方向)。

public override void FixedUpdateNetwork()
{
  if (GetInput(out NetworkInputData data))
  {
    data.direction.Normalize();
    _cc.Move(5*data.direction*Runner.DeltaTime);

    if (data.direction.sqrMagnitude > 0)
      _forward = data.direction;

    if (HasStateAuthority && delay.ExpiredOrNotRunning(Runner))
    {
      if (data.buttons.IsSet(NetworkInputData.MOUSEBUTTON0))
      {
        delay = TickTimer.CreateFromSeconds(Runner, 0.5f);
        Runner.Spawn(_prefabBall,
          transform.position+_forward,
          Quaternion.LookRotation(_forward),
          Object.InputAuthority,
          (runner, o) =>
          {
            // Initialize the Ball before synchronizing it
            o.GetComponent<Ball>().Init();
          });
      }
      else if (data.buttons.IsSet(NetworkInputData.MOUSEBUTTON1))
      {
        delay = TickTimer.CreateFromSeconds(Runner, 0.5f);
        Runner.Spawn(_prefabPhysxBall,
          transform.position+_forward,
          Quaternion.LookRotation(_forward),
          Object.InputAuthority,
          (runner, o) =>
          {
            o.GetComponent<PhysxBall>().Init( 10*_forward );
          });
      }
    }
  }
}

为了使网络物理工作,runner对象上需要一个RunnerSimulatePhysics3D组件。因此,打开BasicSpawner.cs,在StartGame函数中添加跑步者组件的行之后添加以下行:

gameObject.AddComponent<RunnerSimulatePhysics3D>();

最后,为了测试新球,将创建的预制件分配给Player预制件上的预制件Physx球场,并构建和运行它。

 2.4 Property Changes 属性变更

概述
本节展示了如何通过网络同步其他数据,除了玩家的位置使用网络属性。

2.4.1 网络属性

        当您向networkobject添加NetworkTransform组件时,Fusion会同步它们的转换。其他状态,如脚本中的变量,不通过网络同步。要使状态在网络上同步,需要一个[Networked]属性。网络属性将其状态从StateAuthority同步到所有其他客户端。
        如果客户端在一个对象上更改了一个它没有StateAuthority的Networked Property,那么这个更改不会在网络上同步,而是作为一个本地预测应用,并且可以在将来被StateAuthority的更改覆盖。如果您希望在每个客户机上更新StateAuthority上的Networked Properties,请注意只更新它。
        网络属性的一个简单例子就是玩家的颜色。首先创建一个新脚本并命名为PlayerColor。添加一个Networked属性和一个公共字段来引用对象的MeshRender。
在这个例子中,我们的目标是在球发射时将玩家立方体涂成白色,然后将其渐变为蓝色。

2.4.2 声明网络属性

        为了触发这种效果,主机将在网络变量中切换单个比特。因为在一个滴答声中只能生成一个球,所以每次新刷出都会将比特的值改变为与前一个滴答声不同的值,从而触发变化检测。
        在添加代码之前,请注意这种设计可能会失败一正如已经提到的,更改可能无法检测到,特别是如果它们是翻转/触发器类型的更改。为了使它更适应这种情况,可以用字节或int替换bool,并在每次调用时将其替换。最后,这是一个关于视觉效果与它所消耗的带宽的重要性的问题。

打开Player类并添加一个新的网络属性:

[Networked]
public bool spawnedProjectile { get; set; }

        在定义网络属性时,Fusion将用自定义代码替换提供的get和set存根,以访问网络状态。这意味着应用程序不能使用这些方法来处理属性值的更改,并且创建单独的setter方法只能在本地工作。
        要解决这个问题,可以通过ChangeDector检测更改。在脚本中添加一个新的ChangeDector,并在spawn中初始化它,如下所示:

private ChangeDetector _changeDetector;

public override void Spawned()
{
    _changeDetector = GetChangeDetector(ChangeDetector.Source.SimulationState);
}

 同时添加一个材质字段来引用立方体材质,并将该字段在Awake中获取组件

public Material _material;

private void Awake()
{
    ...
    _material = GetComponentInChildren<MeshRenderer>().material;
}

然后添加Rander函数如下

public override void Render()
{
    foreach (var change in _changeDetector.DetectChanges(this))
    {
        switch (change)
        {
            case nameof(spawnedProjectile):
                _material.color = Color.white;
                break;
        }
    }
}

        这段代码遍历自上次调用ChangeDetector以来发生在Networked Properties上的所有更改。在这个例子中,从上次渲染开始。如果在任何客户端检测到颜色值的变化,则更新MeshRenderer
        这对于生成响应状态变化的局部视觉效果或执行不直接影响玩法逻辑的其他任务非常有用。这是一个重要的警告,因为属性更改可能由于重新模拟而发生多次(或者,准确地说,两次,一次是预测,如果预测不正确,则再次发生),或者如果属性在两个值之间来回更改的速度比网络状态发送的速度快(或数据包被丢弃),则可能完全跳过。
        与RPC等常见消息相比,使用更改回调的主要好处是回调在值更改后立即执行,而RPC可能会在游戏处于完全不同的状态时晚些时候到达。

2.4.3 让颜色变浅

        颜色应该在Render()中更新为从当前颜色到蓝色的线性插值。这是在Render()而不是Update()中完成的,因为它保证在fixedupdatennetwork()之后运行,并且它使用Time。deltaTime而不是Runner。DeltaTime,因为它运行在Unity的渲染循环中,而不是作为Fusion模拟的一部分。
将以下行添加到Render函数的末尾。

_material.color = Color.Lerp(_material.color, Color.blue, Time.deltaTime);

剩下的就是在调用后通过切换spawned抛射属性来触发回调Runner.Spawn ():
 

...
Runner.Spawn(_prefabBall, transform.position+_forward, Quaternion.LookRotation(_forward));
spawnedProjectile = !spawnedProjectile;
...

请记住,有两个地方的Spawn()被调用,都应该切换产卵射。

2.4.4 但是为什么呢?

问:为什么不直接在调用spawn时设置颜色呢?
虽然这将适用于主机和具有输入权限的客户端,因为两者都是基于玩家的输入进行预测,但它不适用于代理。
问:但如果颜色是一个网络属性,只是在Render()中本地应用于所有客户端。当然不需要变更检测器了吧?
这确实可行,但它需要在主机上动画化,并且会产生大量不必要的流量。一般来说,视觉效果应该由state authority触发,然后在每个客户端上自主运行。就像每个人都喜欢火花一样,没有人关心一个特定的火花是否朝一个方向飞行。

 

 2.5 Remote Procedure Calls 远程过程调用

概述
        远程过程调用(Remote Procedure call, rpc)是任何网络库最常见的特性之一,它与常规方法的直观映射使其成为尝试将多个客户机聚集在一个共享世界中的首选方法。不幸的是,这往往不是最好的选择。
        rpc在基于节拍的状态同步库(如Fusion)中可能会出现问题,因为它们没有绑定到特定的节拍,并且会在不同的客户端上在不同的时间执行。但也许更重要的是,它们不是网络状态的一部分,所以任何在RPC发送后连接或重新连接的玩家,或者只是因为发送不可靠而没有收到它,都不会看到它的后果。
        在大多数情况下,状态同步本身就足以让玩家保持一致,在网络属性中添加OnChange侦听器可以处理大多数过渡情况,其中应用程序关心状态的变化,而不仅仅是实际状态本身。

尽管如此,在某些用例中,rpg仍然是一个不错的选择,以下是一些例子:
1)、在玩家之间发送嘲讽信息或其他不稳定的非游戏互动。
2)、从游戏内商店购买装备并不重要,RPC调用的直接结果(扣除资金和增加库存)只对发出呼叫的玩家重要。(即不要使用RPC来装备上述购买)
3)、设置初始玩家属性,如名称,颜色,皮肤。(游戏邦注:即任何来自游戏的“稀有”输入的直接结果的球员。基本上,任何你不希望在per-tick input结构中出现的播放器输入)
4)、启动游戏(对游戏模式、地图进行投票,或者只是向主持人表明玩家已经准备好了)。

有关此主题的深入描述,请参阅手册
Remote Procedure Calls | Photon Engine

2.5.1 Fusion RPCs

在少数情况下,rpg是正确的选择,Fusion让它变得非常简单。只需在任何NetworkBehaviour上标记一个带有RPC属性的常规方法,并指出谁可以调用该方法以及谁将接收该调用。确保该方法以“RPC”作为前缀或后缀(没有特定的大写字母),然后准备调用它。

这个小示例的目标是在按R键时向所有其他玩家发送“Hello Mate!”消息。

2.5.2 调用RPC

首先在场景中添加一个文本字段:

GameObject>Ul>Text。更改文本字段的大小以填充整个屏幕,以便更容易阅读文本。

在添加RPC本身之前,需要扩展输入处理。由于RPC调用是实际的网络消息,因此不需要扩展输入结构。此外,由于rpc无论如何都不是tick对齐的,所以没有必要使用融合输入处理,所以打开Player.cs并添加:
 

private void Update()
{
  if (Object.HasInputAuthority && Input.GetKeyDown(KeyCode.R))
  {
    RPC_SendMessage("Hey Mate!");
  }
}

注意对象的检查。HasInputAuthority 这是因为该代码在所有客户端上运行,但只有控制该播放器的客户端应该调用RPC

2.5.3 RPC实现

虽然仍然在Player.cs中,也添加RPC体本身。它被标记为[Rpc]属性,方法名称以“Rpc”开头。是这样的:
 

private TMP_Text _messages;

[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority, HostMode = RpcHostMode.SourceIsHostPlayer)]
public void RPC_SendMessage(string message, RpcInfo info = default)
{
    RPC_RelayMessage(message, info.Source);
}

[Rpc(RpcSources.StateAuthority, RpcTargets.All, HostMode = RpcHostMode.SourceIsServer)]
public void RPC_RelayMessage(string message, PlayerRef messageSource)
{
    if (_messages == null)
        _messages = FindObjectOfType<TMP_Text>();

    if (messageSource == Runner.LocalPlayer)
    {
        message = $"You said: {message}\n";
    }
    else
    {
        message = $"Some other player said: {message}\n";
    }

    _messages.text += message;
}

为什么这里有两个rpc而不是一个?这是因为Fusion主机模式是星型拓扑结构。客户端无法使用RPC直接向其他客户端发送数据。客户端只能向主机发送rpc,然后主机必须将消息转发给所有客户端。

让我们看一下SendMessage RPC的属性:

  • RpcSources.InputAuthority => 只有对对象具有输入权限的客户端才能触发RPC发送消息。
  •  RpcTargets.StateAuthority => SendMessage RPC被发送到主机(StateAuthority)。
  • m RpcHostMode.SourcelsHostPlayer => 由于主机既是服务器又是客户端,因此需要指定哪一个正在调用RPC。

对于RelayMessage RPC:

  • RpcSources.StateAuthority => 服务器/主机正在发送RPC。
  • RpcTargets.All => 所有客户端都应该接收到这个RPC。
  • HostMode = RpcHostMode.SourcelsServer => 主机应用程序的服务器部分正在发送此RPC

有了它,每当玩家按下键盘 R 键时,消息就会传输到每个客户端。

2.5.4 测试

这样主机模式基础教程就完成了。在两个客户端上创建构建并进入游戏模式。一个是Host,一个是Client。玩家可以四处移动,用鼠标按键生成球体,按R键发送npc。
 

  • 14
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
Unity用来和服务器通信可以用原生的WWW,但是WWW所提供的功能并不多,不能满足很多需求。因此我们可以自己封装Http协议来满足更多的需要。在Unity游戏里使用Http协议的情况很常见,因为它操作简单,便于实现,经常用在登陆等场景下,还例如下载上传一些资源。如果想要实现进一步的控制,就要使用Socket并定义自己的协议了。 使用这个插件还有一个重点就在跨平台,因为用C#自己的HttpWebRequest也能实现。 下面简要介绍一下HTTP和Socket: Http连接:http连接就是所谓的短连接,即客户端向服务器端发送一次请求,服务器端响应后连接即会断掉;慢,不太适合游戏中实时数据的传输。数据量。 由于HTTP在每次请求结束后都会主动释放连接,因此HTTP连接是一种“短连接”,要保持客户端程序的在线状态,需要不断地向服务器发起连接请求。通常的做法是即时不需要获得任何数据,客户端也保持每隔一段固定的时间向服务器发送一次“保持连接”的请求,服务器在收到该请求后对客户端进行回复,表明知道客户端“在线”。若服务器长时间无法收到客户端的请求,则认为客户端“下线”,若客户端长时间无法收到服务器的回复,则认为网络已经断开。 Socket连接:socket连接就是所谓的长连接,理论上客户端和服务器端一旦建立起连接将不会主动断掉;但是由于各种环境因素可能会是连接断开,比如说:服务器端或客户端主机down了,网络故障,或者两者之间长时间没有数据传输,网络防火墙可能会断开该连接以释放网络资源。所以当一个socket连接中没有数据的传输,那么为了维持连接需要发送心跳消息~~具体心跳消息格式是开发者自己定义的。 BestHttp是基于RFC 2616的Http/1.1实现,支持几乎所有Unity支持的移动和主机平台,具体请见官方文档。 以下介绍主要来自于官方文档,会有一些补充信息。 BestHttp的目标是成为一款充分发挥Http/1.1潜力的,易用并且强大的Unity插件

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值