Unity Networking开发多人联机射击游戏

UNet开发多人联机射击游戏

引言: Networking作为Unity官方的用于开发多人在线游戏的网络模块,开发者可以不用自己搭建网络模块的底层,通过使用Unity提供的一些相关组件,可以轻松实现简单的多人在线游戏。本片博客为泰课在线贾老师的《Unity多人网络系统讲解》的学习笔记,链接地址在文末。
开发版本: Unity 2017.2

1. 网络管理器

创建空对象,添加Network Manager和Network Manager HUD组件,如下图所示:
Network Manager

2. 创建Player预制体

玩家可以分为LocalPlayer和RemotePlayer:
LocalPlayer指本地玩家控制的对象
RemotePlayer指多人游戏中其他玩家控制的对象
为提供的坦克Player添加Network Identity组件,勾选Local Player Authority,表示该对象由本地玩家控制,而不是服务器。并将该对象制作为预制体。


Network Identity
演示

Network Identity:网络物体最基本的组件,客户端与服务器确认是否是一个物体(netID),也用来表示各个状态,比如判断是否是服务器,是否是客户端,是否有权限,是否是本地玩家等。举一个简单的栗子,A是Host(又是服务器,又是客户端),B是一个Client(客户端),A与B分别有一个玩家PlayA与PlayB。在机器A上,playA与playB的isServer为true,isClent为true,其中playA有权限,是本地玩家,B没权限,也不是本地玩家。在机器B上,playA与playB的isServer为false,isClent为true,其中playB有权限,是本地玩家,A没权限,也不是本地玩家。机器A与机器B上的PlayA的netID相同,机器A与机器B上的PlayB的netID也相同,其中netID用来表示他们是在不同机器上的同一网络对象。

3. 注册Player

将Player预制体添加到Network Manager组件中的Player Prefab中,并将场景中的Player删除,如下所示:
注册Player
运行游戏,点击左上角的LAN Host按钮,将其作为服务器,又作为客户端使用,如下所示:
LAN Host
然后,Network Manager会自动在原点生成一个LocalPlayer,左上角表示客户端连接的IP为本地IP,端口号为7777
Network Manager

4. 控制玩家移动

为Player添加脚本PlayerController,可以实现WASD键或者方向键控制塔克移动旋转,脚本如下:

public float rotateSpeed = 150;
public float moveSpeed = 6;

private void Update()
{
    var x = Input.GetAxis("Horizontal") * Time.deltaTime * rotateSpeed;
    var z = Input.GetAxis("Vertical") * Time.deltaTime * moveSpeed;

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

打包一个PC端用于测试多人在线,编辑器点击LAN Host,打包的点击LAN Client按钮,效果如下所示:
运行效果
我们发现如下问题:

  • 无论在Host端或者Client端,进行移动或者旋转操作,两个Player都会有响应。
  • 一方有位移或者角度变化,并一方不会保持相同变化

修改代码如下,isLocalPlayer用于判断是否是本地玩家,只有本地玩家才可以做出响应

using UnityEngine.Networking;

public class PlayerController : NetworkBehaviour 
{
    public float rotateSpeed = 150;
    public float moveSpeed = 6;

	private void Update()
	{
        if (isLocalPlayer == false) return;

        var x = Input.GetAxis("Horizontal") * Time.deltaTime * rotateSpeed;
        var z = Input.GetAxis("Vertical") * Time.deltaTime * moveSpeed;

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

为Player添加Network Transform组件,用于网络间同步Transform数据,其中Network Send Rate(Seconds)表示网络数据同步的频率,如果同步频率太频繁会导致网络延迟等问题,而频率太低又会影响用户的体验。
Network Transform

5. 初始化LocalPlayer颜色

为PlayerController脚本添加如下方法

//用于本地玩家初始化
public override void OnStartLocalPlayer()
{
    MeshRenderer[] renderers = gameObject.GetComponentsInChildren<MeshRenderer>();
    foreach (var render in renderers)
    {
        render.material.color = Color.blue;
    }
}

演示效果

6. 添加射击功能

创建一个球体,根据坦克炮筒口径,调整大小,勾选Collider的isTrigger,为其添加Rigidbody组件,并取消勾选UseGravity。添加NetworkIdentityNetworkTransform组件,将NetworkSendRate调整为0,因为在子弹生成的时候,我们规定了其位置和发射方向,可以由本地计算子弹接下来的位置,而不用网络同步来调整子弹位置,可以减少网络同步数据的压力。最后,将其作为预制体保存。

为PlayerController添加发射子弹的方法

using UnityEngine.Networking;

public class PlayerController : NetworkBehaviour 
{
    public float rotateSpeed = 150;
    public float moveSpeed = 6;
    public GameObject bulletPrefab;
    public Transform bulletSpawnPos;

	private void Update()
	{
        if (isLocalPlayer == false) return;

        var x = Input.GetAxis("Horizontal") * Time.deltaTime * rotateSpeed;
        var z = Input.GetAxis("Vertical") * Time.deltaTime * moveSpeed;

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

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

    //用于本地玩家初始化
	public override void OnStartLocalPlayer()
	{
        MeshRenderer[] renderers = gameObject.GetComponentsInChildren<MeshRenderer>();
        foreach (var render in renderers)
        {
            render.material.color = Color.blue;
        }
    }

	private void Fire()
	{
        GameObject bullet = (GameObject)Instantiate(bulletPrefab, bulletSpawnPos.position, bulletSpawnPos.rotation);
        bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 20;
        Destroy(bullet, 2);
	}
}

在坦克炮口位置创建一个空物体,作为子弹生成的位置
子弹生成位置

将子弹预制体和BulletSpawnPos对象赋值到PlayerController上,如下所示:
赋值

此时,打包测试,会发现一方发射子弹,另一方不会同步,如下所示:
发射子弹不同步
解决该问题,需要先将子弹在Network Manager中注册为可生成预制体,如下:
注册
然后将Fire方法修改为Command方法,并且将生成的Bullet对象,放到服务器的管理生成对象的集合中,如果后面有个客户端连接进来,可以保证生成的预制体一致。

[Command]
private void CmdFire()
{
    GameObject bullet = (GameObject)Instantiate(bulletPrefab, bulletSpawnPos.position, bulletSpawnPos.rotation);
    bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 20;
    NetworkServer.Spawn(bullet);
    Destroy(bullet, 2);
}

Command:在客户端调用,服务器端执行。客户端调用的参数必须要UNet可以序列化,这样服务器在执行时才能把参数反序列化。需要注意,在客户端需要有权限的NetworkIdentity组件才能调用Command命令。
NetworkServer:主要持有一个NetworkScene并且做一些只有在服务器上才能对网络服务做的事,如spawn, destory等。以及维护所有客户端连接。

打包测试效果如下:
打包测试

7. 显示玩家生命值

为Player添加Helath脚本

public class Health : MonoBehaviour
{
    public const int maxHealth = 100;
    public int currentHealth = maxHealth;
    public RectTransform bloodNum;

    public void TakeDamage(int count)
    {  
        currentHealth -= count;
        if (currentHealth <= 0)
        {
            currentHealth = 0;
        }
        bloodNum.sizeDelta = new Vector2(currentHealth, bloodNum.sizeDelta.y);
    }
}

为bullet添加Bullet的脚本

public class Bullet : MonoBehaviour 
{
	private void OnTriggerEnter(Collider other)
	{
        Health health = other.gameObject.GetComponent<Health>();
        if (health != null)
            health.TakeDamage(10);
        Destroy(gameObject);
	}
}

创建血条UI,设置为World Space模式,如下:
血条UI设置
需要将BloodNum图片的锚点设置在左侧,然后将其赋值给Health中的bloodNum,如下:
锚点设置
为了让HealthBar永远朝向摄像机,添加BillBoard脚本

public class BillBoard : MonoBehaviour 
{
	void Update () 
    {
        transform.LookAt(Camera.main.transform);
	}
}

经打包测试,发现已经可以子弹打中后掉血的功能,但目前掉血是由于两方的子弹打中坦克后,都触发TakeDamage方法。如果一方的子弹已经打中对方并销毁,由于网络延迟,另一方的子弹还没打中对象,由于子弹是服务器统一管理,所以子弹还没打中对象就直接销毁子弹了,这样就会导致两方的数据不一致现象。

如何解决这个问题呢,需要使用SyncVar特性

SyncVar:服务器的值能自动同步到客户端,保持客户端的值与服务器一致。客户端值改变并不会影响服务器的值。

修改Health脚本,TakeDamage方法只在服务器执行,即数据逻辑在服务器处理,其他客户端的数据均以服务器为准,当currentHealth的值发生变化时,自动同步到所有客户端,并调用OnChangeHealth方法,currentHealth作为方法形参传入。

using UnityEngine.Networking;

public class Health : NetworkBehaviour
{
    public const int maxHealth = 100;
    public RectTransform bloodNum;

    [SyncVar(hook = "OnChangeHealth")]
    public int currentHealth = maxHealth;

    public void TakeDamage(int count)
    {  
        if (isServer == false) return;
    
        currentHealth -= count;
        if (currentHealth <= 0)
        {
            currentHealth = 0;
        }
    }

    public void OnChangeHealth(int currentHealth)
    {
        bloodNum.sizeDelta = new Vector2(currentHealth, bloodNum.sizeDelta.y);
    }
}

打包测试,血条可以正常同步,如下所示:
血条测试

8. 处理死亡

ClientRpc:服务端调用,客户端执行。服务端的参数序列化到客户端执行,一般来说,服务端会找到上面的NetworkIdentity组件,确定那些客户端在监视这个NetworkIdentity,Rpc命令会发送给所有的监视客户端。注意方法名要以“Rpc”开头。

using UnityEngine.Networking;

public class Health : NetworkBehaviour
{
    public const int maxHealth = 100;
    public RectTransform bloodNum;
    public bool destroyOnDeath;

    [SyncVar(hook = "OnChangeHealth")]
    public int currentHealth = maxHealth;

	public void TakeDamage(int count)
    {
        if (isServer == false) return;

        currentHealth -= count;
        if (currentHealth <= 0)
        {
            if (destroyOnDeath)
            {
                Destroy(gameObject);
            }else
            {
                currentHealth = maxHealth;
                RpcRespawn();
            }
        }
    }

    public void OnChangeHealth(int currentHealth)
    {
        bloodNum.sizeDelta = new Vector2(currentHealth, bloodNum.sizeDelta.y);
    }

    [ClientRpc]
    private void RpcRespawn()
    {
        if (isLocalPlayer)
            transform.position = Vector3.zero;
    }
}

9. 添加敌人

服务器端生成非玩家对象,首先创建一个空对象,命名为EnemySpawner,添加NetworkIdentity组件,勾选Server Only,添加EnemySpawner脚本。
设置

public class EnemySpawner : NetworkBehaviour 
{
    public GameObject enemyPrefab;
    public int numOfEnemy;

    //用于服务器的初始化操作
	public override void OnStartServer()
	{
        for (int i = 0; i < numOfEnemy; i++)
        {
            Vector3 spawnPos = new Vector3(Random.Range(-15, 15), 0, Random.Range(-15, 15));
            Quaternion spawnRotation = Quaternion.Euler(0, Random.Range(0, 180), 0);
            GameObject enemy = (GameObject)Instantiate(enemyPrefab, spawnPos, spawnRotation);
            NetworkServer.Spawn(enemy);
        }
    }
}

复制一个Player预制体,修改为Enemy预制体,并删除PlayerController组件,需要勾选Health组件中的DestroyOnDeath。然后将其注册到NetworkManager中的RegisteredSpawnablePrefabs中。运行后如下:
生成Enemy

10. 修改出生位置

创建空的预制体,添加Network Start Position组件
NetworkStartPosition
将Network Manager中的Player Spawn Method修改为Round Robin,表示按生成点顺序一个一个生成
Round Robin

修改Health脚本,修改其生成位置

using UnityEngine.Networking;

public class Health : NetworkBehaviour
{
    public const int maxHealth = 100;
    public RectTransform bloodNum;
    public bool destroyOnDeath;

    [SyncVar(hook = "OnChangeHealth")]
    public int currentHealth = maxHealth;

    private NetworkStartPosition[] spawnPoints;

	private void Start()
	{
        OnChangeHealth(currentHealth);

        if (isLocalPlayer)
        {
            spawnPoints = FindObjectsOfType<NetworkStartPosition>();
        }
    }

	public void TakeDamage(int count)
    {
        if (isServer == false) return;

        currentHealth -= count;
        if (currentHealth <= 0)
        {
            if (destroyOnDeath)
            {
                Destroy(gameObject);
            }else
            {
                currentHealth = maxHealth;
                RpcRespawn();
            }
        }
    }

    public void OnChangeHealth(int currentHealth)
    {
        bloodNum.sizeDelta = new Vector2(currentHealth, bloodNum.sizeDelta.y);
    }

    [ClientRpc]
    private void RpcRespawn()
    {
        if (isLocalPlayer)
        {
            Vector3 spawnPoint = Vector3.zero;
            if (spawnPoints != null && spawnPoints.Length > 0)
            {
                spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)].transform.position;
            }
            transform.position = spawnPoint;
        }
    }
}

打包测试,实现了修改生成位置的功能。

自此,简单的多人在线射击游戏开发完成,每天学习一点,至少比昨天的自己进步了一点!

参考资源:
  Unity多人网络系统讲解-实践篇
  Unity3D网络组件UNet详解
  Networking API文档翻译

猜你喜欢

转载自blog.csdn.net/qq_35361471/article/details/83957817