Version: 2017.2
Network System Concepts
Using the Network Manager

从头开始设置多人游戏项目

本文档将介绍使用新网络系统从头开始设置新的多人游戏项目的步骤。此分步过程是通用的,但是熟悉后,就可以针对许多类型的多人游戏进行自定义。

首先,创建一个空的 Unity 项目。

NetworkManager 设置

第一步是在项目中创建 NetworkManager 对象:

  • 从菜单 Game Object > Create Empty 中添加一个新的空游戏对象。
  • 在 Hierarchy 视图中找到新创建的对象,然后选择该对象
  • 从右键单击上下文菜单中将对象重命名为“NetworkManager”,或者单击对象的名称并键入新名称来重命名。
  • 在对象的 Inspector 窗口中,单击 Add Component 按钮
  • 找到 Network > NetworkManager 组件,然后将该组件添加到对象。该组件用于管理游戏的网络状态。
  • 找到 Network > NetworkManagerHUD 组件,然后将该组件添加到对象。该组件在游戏中提供了一个简单的用户界面,用于控制网络状态。

有关更多详细信息,请参阅使用 NetworkManager

设置玩家预制件

下一步是设置在游戏中代表玩家的 Unity 预制件。默认情况下,NetworkManager 通过克隆玩家预制件来为每个玩家实例化一个对象。在以下示例中,玩家对象将是一个简单立方体。

  • 通过菜单 Game Object > 3D Object > Cube 创建一个新的立方体
  • 在 Hierarchy 视图中找到该立方体,然后选择该立方体
  • 将对象重命名为“PlayerCube”
  • 在对象的 Inspector 窗口中,单击 Add Component 按钮
  • 为对象添加 Network > NetworkIdentity 组件。该组件用于在服务器和客户端之间标识对象。
  • 将 NetworkIdentity 上的“Local Player Authority”复选框设置为 true。这将允许客户端控制玩家对象的移动
  • 通过玩家立方体对象生成一个预制件,方法是将该对象拖动到 Asset 窗口中。这将创建一个名为“PlayerCube”的预制件
  • 从场景中删除 PlayerCube 对象:我们现在不需要这个对象了,因为我们有了一个预制件

请参阅玩家对象

注册玩家预制件

创建玩家预制件后,必须将其注册到网络系统。

  • 在 Hierarchy 视图中找到 NetworkManager 对象,然后选择该对象
  • 打开 NetworkManager Inspector 的“Spawn Info”折叠三角形
  • 找到“Player Prefab”字段
  • 将 PlayerCube 预制件拖入“Player Prefab”字段

现在该第一次保存项目了。请通过菜单 File > Save Project 保存项目。还应该保存场景。我们将这个场景称为“离线”场景。

玩家移动(单人游戏版本)

游戏的首个功能是移动玩家对象。我们先在没有任何网络的情况下实现此功能,所以只能在单人游戏模式下有效。

  • 在 Asset 视图中找到 PlayerCube 预制件。
  • 单击 Add Component 按钮,然后选择“New Script”
  • 输入新脚本的名称“PlayerMove”。此时将创建一个新脚本。
  • 在编辑器(如 Visual Studio)中双击这个新脚本以将其打开
  • 将以下简单的移动代码添加到脚本中:
using UnityEngine;

public class PlayerMove : MonoBehaviour
{
    void Update()
    {
        var x = Input.GetAxis("Horizontal")*0.1f;
        var z = Input.GetAxis("Vertical")*0.1f;

        transform.Translate(x, 0, z);
    }
}

此代码将连接由箭头键或游戏手柄控制的立方体。立方体现在只在客户端上移动(没有联网)。

再次保存项目。

测试托管的游戏

在编辑器中单击播放按钮进入播放模式。应该会看到 NetworkManagerHUD 默认用户界面:

按“Host”以游戏主机身份启动游戏。这将创建玩家对象,且 HUD 将更改以表明服务器处于活动状态。该游戏将作为“主机”运行,也就是在同一个进程中既作为服务器也作为客户端。

请参阅网络概念

按箭头键应该能使玩家立方体对象四处移动。

在编辑器中按停止按钮退出播放模式。

针对客户端测试玩家移动

  • 使用菜单 File > Build Settings 打开 Build Settings 对话框。
  • 按“Add Open Scenes”按钮将当前场景添加到构建中
  • 按“Build and Run”按钮创建构建版本。此时,系统将提示输入可执行文件的名称,请输入一个名称,如“networkTest”
  • 独立平台播放器将启动,并显示分辨率选择对话框。
  • 选中“windowed”复选框,并选择较低分辨率,如 640x480
  • 独立平台播放器将启动,并显示 NetworkManager HUD。
  • 从菜单中选择“Host”以主机身份启动。此时应该会创建一个玩家立方体
  • 按箭头键将玩家稍微移动一点
  • 切回到编辑器,然后选择 Build Settings 对话框。
  • 使用 Play 按钮进入播放模式
  • 从 NetworkManagerHUD 用户界面中,选择“LAN Client”以客户端身份连接到主机
  • 应该有两个立方体,一个用于主机上的本地玩家,另一个用于此客户端的远程玩家
  • 按箭头键移动立方体
  • 两个立方体现在都在移动!这是因为移动脚本无法感知网络。

使“玩家移动”联网

  • 关闭独立平台播放器
  • 在编辑器中退出播放模式
  • 打开 PlayerMove 脚本。
  • 更新脚本以便仅移动本地玩家
  • 添加“using UnityEngine.Networking”
  • 将“MonoBehaviour”更改为“NetworkBehaviour”
  • 在 Update 函数中添加针对“isLocalPlayer”的检查,以便仅本地玩家处理输入
using UnityEngine;
using UnityEngine.Networking;

public class PlayerMove : NetworkBehaviour
{
    void Update()
    {
        if (!isLocalPlayer)
            return;

        var x = Input.GetAxis("Horizontal")*0.1f;
        var z = Input.GetAxis("Vertical")*0.1f;

        transform.Translate(x, 0, z);
    }
}
  • 在 Asset 视图中找到 PlayerCube 预制件并选择该预制件
  • 单击“Add Component”按钮,然后添加 Networking > NetworkTransform 组件。该组件将使对象在网络中同步自己的位置。
  • 再次保存项目

测试多人游戏玩家移动

  • 再次构建并运行独立平台播放器,然后以主机身份启动
  • 在编辑器中进入播放模式并以客户端身份进行连接
  • 玩家对象现在应该彼此独立移动,并由客户端上的本地玩家控制。

识别玩家

游戏中的立方体目前都是白色的,所以用户无法分辨哪个是自己的立方体。为了识别玩家,我们将本地玩家的立方体变成红色。

  • 打开 PlayerMove 脚本
  • 添加 OnStartLocalPlayer 函数的实现以更改玩家对象的颜色。
    public override void OnStartLocalPlayer()
        {
            GetComponent<MeshRenderer>().material.color = Color.red;
        }

此函数仅针对客户端上的本地玩家调用。这将使用户看到其立方体为红色。OnStartLocalPlayer 函数适合于执行仅限于本地玩家的初始化,例如配置摄像机和输入。

NetworkBehaviour 基类中还有一些其他有用的虚拟函数。请参阅生成

  • 构建并运行游戏
  • 由本地玩家控制的立方体现在应该是红色的,而其他的仍然是白色的。

射击子弹(未联网)

多人游戏的一个共同特点是让玩家发射子弹。本节将在示例中添加非联网子弹。下一节添加子弹的联网功能。

  • 创建球体游戏对象
  • 将球体对象重命名为“Bullet”
  • 将子弹的缩放从 1.0 更改为 0.2
  • 将子弹拖到 assets 文件夹中以生成子弹的预制件
  • 从场景中删除子弹对象
  • 向子弹添加刚体 (Rigidbody) 组件
  • 将刚体上的“Use Gravity”复选框设置为 false
  • 更新 PlayerMove 脚本以发射子弹:
  • 为子弹预制件添加一个公共字段
  • 在 Update() 函数中添加输入处理
  • 添加一个函数来发射子弹
using UnityEngine;
using UnityEngine.Networking;

public class PlayerMove : NetworkBehaviour
{
    public GameObject bulletPrefab;

    public override void OnStartLocalPlayer()
    {
        GetComponent<MeshRenderer>().material.color = Color.red;
    }

    void Update()
    {
        if (!isLocalPlayer)
            return;

        var x = Input.GetAxis("Horizontal")*0.1f;
        var z = Input.GetAxis("Vertical")*0.1f;

        transform.Translate(x, 0, z);

        if (Input.GetKeyDown(KeyCode.Space))
        {
            Fire();
        }
    }

    void Fire()
    {
        // 通过子弹预制件创建子弹对象
        var bullet = (GameObject)Instantiate(
            bulletPrefab,
            transform.position - transform.forward,
            Quaternion.identity);

        // 让子弹在玩家面前远离
        bullet.GetComponent<Rigidbody>().velocity = -transform.forward*4;
        
        // 让子弹在 2 秒后消失
        Destroy(bullet, 2.0f);        
    }
}
  • 保存脚本,然后返回到编辑器
  • 选择 PlayerCube 预制件并找到 PlayerMove 组件
  • 在组件上找到 bulletPrefab 字段
  • 将子弹预制件拖入 bulletPrefab 字段中
  • 创建一个构建版本,然后以主机身份启动独立平台播放器
  • 在编辑器中进入播放模式并以客户端身份进行连接
  • 按空格键应该会产生子弹并从玩家对象发射子弹
  • 其他客户端上不会发射子弹,只有按空格键的客户端才会发射。

联网的发射子弹

本节将在示例中为子弹添加联网功能。

  • 找到子弹预制件并选择该预制件
  • 将 NetworkIdentity 添加到子弹预制件
  • 将 NetworkTransform 组件添加到子弹预制件
  • 在子弹预制件的 NetworkTransform 组件上,将发射速度设置为零。子弹发射后不会改变方向或速度,所以不需要发送移动更新。
  • 选择 NetworkManager 并打开“Spawn Info”折叠三角形
  • 用加号按钮添加一个新的生成预制件
  • 将子弹预制件拖入新的生成预制件字段中
  • 打开 PlayerMove 脚本
  • 更新 PlayerMove 脚本使子弹联网:
  • 通过添加 [Command] 自定义属性和“Cmd”前缀,将 Fire 函数更改为联网命令
  • 在子弹对象上使用 Network.Spawn()
using UnityEngine;
using UnityEngine.Networking;

public class PlayerMove : NetworkBehaviour
{
    public GameObject bulletPrefab;
    
    public override void OnStartLocalPlayer()
    {
        GetComponent<MeshRenderer>().material.color = Color.red;
    }

    [Command]
    void CmdFire()
    {
       // 此 [Command] 代码是在服务器上运行的!

       // 在本地创建子弹对象
       var bullet = (GameObject)Instantiate(
            bulletPrefab,
            transform.position - transform.forward,
            Quaternion.identity);

       bullet.GetComponent<Rigidbody>().velocity = -transform.forward*4;
       
       // 在客户端上生成子弹
       NetworkServer.Spawn(bullet);
       
       // 当子弹在服务器上销毁时,也将自动在客户端上销毁
       Destroy(bullet, 2.0f);
    }

    void Update()
    {
        if (!isLocalPlayer)
            return;

        var x = Input.GetAxis("Horizontal")*0.1f;
        var z = Input.GetAxis("Vertical")*0.1f;

        transform.Translate(x, 0, z);

        if (Input.GetKeyDown(KeyCode.Space))
        {
            // Command 函数从客户端中调用,但是在服务器上唤出
            CmdFire();
        }
    }
}

此代码使用 [Command] 在服务器上发射子弹。有关更多信息,请参阅网络化操作

  • 创建一个构建版本,然后以主机身份启动独立平台播放器
  • 在编辑器中进入播放模式并以客户端身份进行连接
  • 按空格键应使所有客户端上的正确玩家(仅限正确玩家)发射子弹

子弹碰撞

此处将添加一个碰撞处理程序,以便子弹在击中玩家立方体对象后消失。

  • 找到子弹预制件并选择该预制件
  • 选择 Add Component 按钮,然后添加一个新脚本
  • 将新脚本命名为“Bullet”
  • 打开新脚本并添加碰撞处理程序(用于在子弹击中玩家对象后销毁子弹)
using UnityEngine;

public class Bullet : MonoBehaviour
{
    void OnCollisionEnter(Collision collision)
    {
        var hit = collision.gameObject;
        var hitPlayer = hit.GetComponent<PlayerMove>();
        if (hitPlayer != null)
        {
            Destroy(gameObject);
        }
    }
}

现在,当子弹击中玩家对象后,子弹就会被销毁。当服务器上的子弹被销毁时,因为这个子弹是由网络管理的生成对象,所以也将在客户端上被销毁。

玩家状态(非联网生命值)

与子弹相关的一个常见特征是,玩家对象具有一个“health”属性,该属性开始时是一个最大值,然后在玩家受到子弹击中造成的伤害后随之下降。本节向玩家对象添加非联网生命值 (health)。

  • 选择 PlayerCube 预制件
  • 选择 Add Component 按钮,然后添加一个新脚本
  • 将脚本命名为“Combat”
  • 打开 Combat 脚本,添加 health 变量和 TakeDamage 函数
using UnityEngine;

public class Combat : MonoBehaviour 
{
    public const int maxHealth = 100;
    public int health = maxHealth;

    public void TakeDamage(int amount)
    {
        health -= amount;
        if (health <= 0)
        {
            health = 0;
            Debug.Log("Dead!");
        }
    }
}

Bullet 脚本需要更新,以便在击中时调用 TakeDamage 函数。 * 打开 Bullet 脚本 * 在碰撞处理程序函数中添加对 Combat 脚本中 TakeDamage() 函数的调用

using UnityEngine;

public class Bullet : MonoBehaviour
{
    void OnCollisionEnter(Collision collision)
    {
        var hit = collision.gameObject;
        var hitPlayer = hit.GetComponent<PlayerMove>();
        if (hitPlayer != null)
        {
            var combat = hit.GetComponent<Combat>();
            combat.TakeDamage(10);

            Destroy(gameObject);
        }
    }
}

当被子弹击中时,将使玩家对象的生命值下降。但现在无法在游戏中看到这一点。我们需要添加一个简单的生命值血条。

  • 选择 PlayerCube 预制件
  • 选择 Add Component 按钮,然后添加一个名为 HealthBar 的新脚本
  • 打开 HealthBar 脚本

这里有很多代码使用旧的 GUI 系统。这对联网没太大影响,所以我们现在只使用,但不作解释。

using UnityEngine;
using System.Collections;

public class HealthBar : MonoBehaviour 
{
    GUIStyle healthStyle;
    GUIStyle backStyle;
    Combat combat;

    void Awake()
    {
        combat = GetComponent<Combat>();
    }

    void OnGUI()
    {
        InitStyles();

        // 绘制一个生命值血条

        Vector3 pos = Camera.main.WorldToScreenPoint(transform.position);
        
        // 绘制生命值血条背景
        GUI.color = Color.grey;
        GUI.backgroundColor = Color.grey;
        GUI.Box(new Rect(pos.x-26, Screen.height - pos.y + 20, Combat.maxHealth/2, 7), ".", backStyle);
        
        // 绘制生命值血条量
        GUI.color = Color.green;
        GUI.backgroundColor = Color.green;
        GUI.Box(new Rect(pos.x-25, Screen.height - pos.y + 21, combat.health/2, 5), ".", healthStyle);
    }

    void InitStyles()
    {
        if( healthStyle == null )
        {
            healthStyle = new GUIStyle( GUI.skin.box );
            healthStyle.normal.background = MakeTex( 2, 2, new Color( 0f, 1f, 0f, 1.0f ) );
        }

        if( backStyle == null )
        {
            backStyle = new GUIStyle( GUI.skin.box );
            backStyle.normal.background = MakeTex( 2, 2, new Color( 0f, 0f, 0f, 1.0f ) );
        }
    }
    
    Texture2D MakeTex( int width, int height, Color col )
    {
        Color[] pix = new Color[width * height];
        for( int i = 0; i < pix.Length; ++i )
        {
            pix[ i ] = col;
        }
        Texture2D result = new Texture2D( width, height );
        result.SetPixels( pix );
        result.Apply();
        return result;
    }
}
  • 保存项目
  • 构建并运行游戏,然后查看玩家对象上的生命值血条
  • 如果一名玩家现在射击了另一名玩家,那么该特定客户端上的生命值血条就会下降,但其他客户端不会受到影响。

玩家状态(联网生命值)

对生命值的更改现在将应用到所有位置,与客户端和主机无关。因此,不同玩家的生命值可能显示不同。只应在服务器上应用生命值,然后将更改复制到客户端。我们将这种情况称为生命值的“服务器授权”。

  • 打开 Combat 脚本
  • 将脚本更改为 NetworkBehaviour
  • 将生命值设为 [SyncVar]
  • 为 TakeDamage 添加 isServer 检查,使其仅在服务器上应用

有关 SyncVars 的更多信息,请参阅状态同步

using UnityEngine;
using UnityEngine.Networking;

public class Combat :  NetworkBehaviour 
{
    public const int maxHealth = 100;

    [SyncVar]
    public int health = maxHealth;

    public void TakeDamage(int amount)
    {
        if (!isServer)
            return;

        health -= amount;
        if (health <= 0)
        {
            health = 0;
            Debug.Log("Dead!");
        }
    }
}

死亡和重新生成

当前,当玩家的生命值达到零时,什么都不会发生(除了日志消息)。为了使游戏更像样,当生命值达到零时,玩家应该以完整生命值被传送回起始位置。

  • 打开 Combat 脚本
  • 添加 [ClientRpc] 函数以重新生成玩家对象。有关更多信息,请参阅网络化操作
  • 当生命值达到零时,在服务器上调用 respawn 函数
using UnityEngine;
using UnityEngine.Networking;

public class Combat :  NetworkBehaviour 
{
    public const int maxHealth = 100;

    [SyncVar]
    public int health = maxHealth;

    public void TakeDamage(int amount)
    {
        if (!isServer)
            return;

        health -= amount;
        if (health <= 0)
        {
            health = maxHealth;

            // 在服务器上调用,将在客户端上唤出
            RpcRespawn();
        }
    }

    [ClientRpc]
    void RpcRespawn()
    {
        if (isLocalPlayer)
        {
            // 移回到零位置
            transform.position = Vector3.zero;
        }
    }
}

在此游戏中,客户端控制玩家对象的位置 - 玩家对象在客户端上有“本地授权”。如果服务器只是将玩家的位置设置为起始位置,则将被客户端覆盖,因为客户端具有授权。为了避免这种情况,服务器告诉拥有玩家对象的客户端将玩家对象移动到起始位置。

  • 构建并运行游戏
  • 将玩家对象从起始位置移开
  • 向一个玩家发射子弹,直到其生命值降到零为止
  • 该玩家对象应该传送到起始位置。

非玩家对象

虽然玩家对象是在客户端连接到主机时生成的,但大多数游戏都有存在于游戏世界中的非玩家对象,例如敌人。在本节中将添加一个生成器,作用是创建可以被射击和杀死的非玩家对象。

  • 从 GameObject 菜单中创建一个新的空游戏对象
  • 将该对象重命名为“EnemySpawner”
  • 选择 EnemySpawner 对象
  • 选择 Add Component 按钮,然后向对象添加 NetworkIdentity
  • 在 NetworkIdentity 中,单击“Server Only”复选框。这使得生成器不被发送给客户端。
  • 选择 Add Component 按钮,然后创建一个名为“EnemySpawner”的新脚本
  • 编辑新脚本
  • 将其设为 NetworkBehaviour
  • 实现虚拟函数 OnStartServer 以创建敌人
using UnityEngine;
using UnityEngine.Networking;

public class EnemySpawner : NetworkBehaviour {

    public GameObject enemyPrefab;
    public int numEnemies;

    public override void OnStartServer()
    {
        for (int i=0; i < numEnemies; i++)
        {
            var pos = new Vector3(
                Random.Range(-8.0f, 8.0f),
                0.2f,
                Random.Range(-8.0f, 8.0f)
                );

            var rotation = Quaternion.Euler( Random.Range(0,180), Random.Range(0,180), Random.Range(0,180));

            var enemy = (GameObject)Instantiate(enemyPrefab, pos, rotation);
            NetworkServer.Spawn(enemy);
        }
    }
}

现在创建敌人预制件:

  • 从 GameObject 菜单中创建一个新胶囊体。
  • 将对象重命名为“Enemy”
  • 选择 Add Component 按钮,然后向 Enemy 添加 NetworkIdentity 组件
  • 选择 Add Component 按钮,然后向 Enemy 添加 NetworkTransform 组件
  • 将 Enemy 对象拖入 Asset 视图以创建一个预制件
  • 现在应该有一个名为“Enemy”的预制件资源
  • 从场景中删除 Enemy 对象
  • 选择 Enemy 预制件
  • 选择 Add Component 按钮,然后将 Combat 脚本添加到 Enemy
  • 选择 Add Component 按钮,然后将 HealthBar 脚本添加到 Enemy
  • 选择 NetworkManager 并在 Spawn Info 中添加一个新的可生成预制件
  • 将新的生成预制件设置为 Enemy 预制件

在前面,Bullet 脚本被设置为只适用于玩家。现在,需要更新 Bullet 脚本,以便能够处理任何包含 Combat 脚本的对象:

  • 打开 Bullet 脚本
  • 将碰撞检查改为使用 Combat,而不是 PlayerMove:
using UnityEngine;

public class Bullet : MonoBehaviour
{
    void OnCollisionEnter(Collision collision)
    {
        var hit = collision.gameObject;
        var hitCombat = hit.GetComponent<Combat>();
        if (hitCombat != null)
        {
            hitCombat.TakeDamage(10);
            Destroy(gameObject);
        }
    }
}

将 EnemySpawner 与 Enemy 对象连接起来:

  • 选择 EnemySpawner 对象
  • 在 EnemySpawner 组件上找到“Enemy”字段
  • 将 Enemy 预制件拖入该字段
  • 将 numEnemies 的值设置为 4

测试敌人:

  • 构建并运行游戏
  • 以主机身份启动时,应该会在任意位置创建四个敌人
  • 玩家应该能够射击敌人,并且敌人的生命值应该下降
  • 当客户端加入时,他们应该看到敌人处于相同位置,并且与服务器上的生命值相同

销毁敌人

当敌人被子弹射中时,敌人的生命值就会下降,然后像玩家一样重生。敌人的生命值达到零时应该被销毁,而不是重生。

  • 打开 Combat 脚本
  • 添加“destroyOnDeath”变量
  • 生命值达到零时检查 destroyOnDeath
using UnityEngine;
using UnityEngine.Networking;

public class Combat :  NetworkBehaviour 
{
    public const int maxHealth = 100;
    public bool destroyOnDeath;

    [SyncVar]
    public int health = maxHealth;

    public void TakeDamage(int amount)
    {
        if (!isServer)
            return;

        health -= amount;
        if (health <= 0)
        {
            if (destroyOnDeath)
            {
                Destroy(gameObject);
            }
            else
            {
                health = maxHealth;

                // 在服务器上调用,将在客户端上唤出
                RpcRespawn();
            }
        }
    }

    [ClientRpc]
    void RpcRespawn()
    {
        if (isLocalPlayer)
        {
            // 移回到零位置
            transform.position = Vector3.zero;
        }
    }
}

  • 选择 Enemy 预制件
  • 对于 Enemy,将 destroyOnDeath 复选框设置为 true

当生命值达到零时,敌人将被销毁,但玩家将重生。

玩家生成位置

目前,玩家在创建后都会出现在零点。这意味着多个玩家之间会发生重叠。玩家应该在不同的地方生成。可以使用 NetworkStartPosition 组件来实现这一目标。

  • 创建一个新的空游戏对象

  • 将对象重命名为“Pos1”

  • 选择 Add Component 按钮,然后添加 NetworkStartPosition 组件

  • 将 Pos1 对象移动到位置 (–3,0,0)

  • 创建第二个新的空游戏对象

  • 将对象重命名为“Pos2”

  • 选择 Add Component 按钮,然后添加 NetworkStartPosition 组件

  • 将 Pos2 对象移动到位置 (3,0,0)

  • 找到 NetworkManager 并选中。

  • 打开“Spawn Info”折叠三角形

  • 将“Player Spawn Method”更改为“Round Robin”

  • 构建并运行游戏

  • 玩家对象现在应该在 Pos1 和 Pos2 对象的位置创建,而不是在零点。

Network System Concepts
Using the Network Manager