Mirror是Unity的一个免费开源游戏网络库,之前在搜索unity多人游戏的解决方案时发现了这个简单又方便的插件,于是就进行了简单的上手尝试。
安装和导入
由于Mirror本身也是一个Unity项目,所以导入Mirror非常简单。首先先在Unity资源商店搜索Mirror, 添加到我的资源中。

随后就可以直接在unity package manager中直接导入了。注意,导入完成后一定要重启整个项目,这一点很容易忘记。

创建Transport和NetworkManager
开始使用Mirror的第一步就是创建Transport和NetworkManager。Transport是一个专门处理底层网络同行的对象,Mirror提供了多种网络协议供我们选择,分别是Telepathy(TCP),KCP(可靠的UDP),SimpleWeb(用于WebGL)。这里我选择了Telepathy。除此之外Mirror还提供了很多其他用于增强功能的传输层,不过现在先掠过这一部分。
新建一个场景,在其中新建一个空对象命名为Network然后再在Network下面新建一个空对象命名为Transport,在Transport对象上附加上Telepathy组件。

紧接着,同样在Network下面新建一个空对象命名为NetworkManager,在Script找一个好位置,右键创建选择Mirror/NetworkManager,就可以创建我们自己的NetworkManager了。

虽然NetworkManager上又很多参数,不过现在我们不太需要知道。目前最关键的三个参数就是,Transport、Player Prefab和Auto Create Player。
Transport:故名思意就是我们之前创建好的Transport对象
Player Prefab:就是玩家所要操作的角色的预制体
Auto Create Player:决定了是否要在连接之后自动创建玩家
由于我们现在还没有玩家预制体,所以Player Prefab暂时先空着。紧接着,给NetworkManager,添加上Network Discovery和Network Discovery HUD组件,前者提供了重要的网络发现功能,同时,后者提供了一个简单的HUD来进行操作,这次的尝试将使用该HUD进行。
创建地图和玩家预制体
在完成了NetworkManager的创建之后就要快速的创建一张测试地图和玩家对象的预制体。因为不是重点就一笔带过了。

接下来的关键是玩家身上的脚本了。在Mirror中,所有游戏对象要继承的并不是MonoBahaviour,而是NetworkBehaviour。在玩家身上创建一个PlayerInputController和Character,并且都基础NetworkBehaviour,这里使用PlayerInputController来绑定玩家的InputAction,使用Character来控制的玩家的预制体。
using System;
using System.Collections;
using System.Collections.Generic;
using Character.InputActionSetting;
using Mirror;
using Mirror.Examples.Benchmark;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Controller
{
public class PlayerInputController : NetworkBehaviour
{
public Character.Character character;
public float Direction => m_direction;
public float UpForce { get=>m_upForce; set=>m_upForce=value; }
private float m_direction = 0f;
private float m_upForce = 0f;
public BasePlayerInput PlayerInput;
public override void OnStartLocalPlayer()
{
base.OnStartLocalPlayer();
PlayerInput = new BasePlayerInput();
PlayerInput.Player.Enable();
PlayerInput.Player.Movement.performed += OnMovement;
PlayerInput.Player.Movement.canceled += OnMovement;
PlayerInput.Player.Jump.performed += OnJumpStart;
PlayerInput.Player.Jump.canceled += OnJumpEnd;
}
public override void OnStopLocalPlayer()
{
base.OnStopLocalPlayer();
PlayerInput.Player.Disable();
PlayerInput.Player.Movement.performed -= OnMovement;
PlayerInput.Player.Movement.canceled -= OnMovement;
PlayerInput.Player.Jump.performed -= OnJumpStart;
PlayerInput.Player.Jump.canceled -= OnJumpEnd;
}
private void OnMovement(InputAction.CallbackContext context)
{
float value = context.ReadValue<float>();
m_direction = value;
}
private void OnJumpStart(InputAction.CallbackContext context)
{
if (context.performed && character.IsGround)
{
m_upForce = 1f;
}
}
private void OnJumpEnd(InputAction.CallbackContext context)
{
m_upForce = 0f;
}
}
}playerInputController.cs
using System;
using Controller;
using Mirror;
using UnityEngine;
namespace Character
{
public class Character : NetworkBehaviour
{
public PlayerInputController playerInputController;
public Animator animator;
public NetworkAnimator networkAnimator;
public SpriteRenderer spriteRenderer;
[Tooltip("正常状态的速度")]public float speed = 5;
[Tooltip("重力")]public float gravity = 10;
[Tooltip("跳跃力")]public float upForceScale = 10;
[Tooltip("水平速度衰减率")]public float decelerationRate = 0.2f;
[Tooltip("地面检测点")]public Transform groundCheck;
[Tooltip("地面的layer")]public LayerMask groundLayer;
[Tooltip("检测点与地面的距离为多少时判断在地面上")]public float distanceToGround;
public bool IsGround=>m_isGround;
public bool IsFalling=>m_isFalling;
private float m_vertivcalSpeed;
private Rigidbody2D m_rigidbody;
private bool m_isGround;
private bool m_isFalling;
// 仅在服务器上创建玩家对象时运行
public override void OnStartServer()
{
}
// 在所有客户端上创建玩家对象时运行
public override void OnStartClient()
{
if (!isLocalPlayer)
{
playerInputController.enabled = false;
return;
}
}
void Start()
{
m_rigidbody = GetComponent<Rigidbody2D>();
}
private void Update()
{
// 判断是否在地面
m_isGround = Physics2D.Raycast(groundCheck.position, Vector2.down, distanceToGround, groundLayer);
if (!m_isGround && playerInputController.UpForce > 0)
{
playerInputController.UpForce = 0f;
}
}
private void FixedUpdate()
{
if (!isLocalPlayer) return;
// 判断是否在下落
m_isFalling = !m_isGround && m_rigidbody.velocity.y <= 0;
// 水平移动
if (Mathf.Abs(playerInputController.Direction) < 0.1f && IsGround)
{
m_rigidbody.velocity = new Vector2(
m_rigidbody.velocity.x * decelerationRate,
m_rigidbody.velocity.y
);
}else if (Mathf.Abs(playerInputController.Direction) < 0.1f || (playerInputController.Direction * m_rigidbody.velocity.x < 0 && !IsGround))
{
m_rigidbody.velocity = new Vector2(
m_rigidbody.velocity.x,
m_rigidbody.velocity.y
);
}
else
{
m_rigidbody.velocity = new Vector2(
playerInputController.Direction * speed,
m_rigidbody.velocity.y
);
}
// 垂直力
if (playerInputController.UpForce > 0)
{
m_rigidbody.AddForce(Vector2.up * upForceScale, ForceMode2D.Impulse);
}
m_rigidbody.AddForce(Vector2.down * gravity);
RaiseAnimator();
}
}
}Character.cs
可以看到在Character中,实现了一些基本的移动跳跃逻辑。接下来,就要实现移动同步了。
NetworkTransform
在多人游戏中,玩家之间的同步有多种实现方式,而Mirror给我们提供了一种非常简单的方式,那就是Network Transform。在玩家预制体上添加Network Transform组件,除此以外我们并不需要什么其他额外的操作。拥有Network Transform组件的对象,会在其transform在所有者客户端上改变时,自动将信息同步到服务端,服务端再自动广播到其他的客户端。然后将场景添加到构建设置中打包,启动两个客户端并且连接,就可以看到两个玩家的同步了。

Network Animator
除了位置信息之外,动画状态机的同步也是非常有必要的,而做到这一点的就是Network Animator。再一般情况下,它会将服务器的动画信息向所有客户端同步。

由于我们这里的动画是由客户端进行触发,因此将Client Authority设置为true。需要注意的是,Network Animator本身并不能同步SetTrigger的数值,所以关于Trigger的触发,我们同样要使用Network Animator而不是Animator进行。
private void RaiseAnimator()
{
transform.rotation = new Quaternion(transform.rotation.x,m_rigidbody.velocity.x < 0 ? 180:0,transform.rotation.z,transform.rotation.w);
ChangeFlipX(m_rigidbody.velocity.x < 0);
if (!IsGround && !IsFalling)
{
animator.SetBool("Idle", false);
animator.SetTrigger("Jump");
networkAnimator.SetTrigger("Jump");
animator.SetBool("IsJump",true);
}else if (IsFalling)
{
animator.SetTrigger("Fall");
networkAnimator.SetTrigger("Fall");
}else if (IsGround)
{
animator.SetBool("IsJump",false);
animator.SetBool("Idle",true);
}
}使用SyncVar和Command处理伤害逻辑
接下来就要实现玩家的攻击和伤害的同步,而攻击和伤害的同步就要依靠SyncVar和Command。
SyncVar和Command是Mirror定义的两个基本的特性,被标识为SyncVar的Network Behaviour字段将会自动从客户端同步同步服务端变化的变量, 除此之外,通过设置hook变量,客户端可以监听变量的变化从而在需要时进行逻辑处理。而被Command特性标识的方法会成为客户端向服务器的指令,在客户端调用该方法时,将会自动发送网络通知,在服务端执行该函数。
首先要处理的就是子弹的创建逻辑,而要创建子弹就要先有子弹的预制体。

简单的创建了子弹的预制体并且绑上脚本之后就可以处理它的创建逻辑了。因为子弹是要处理玩家间伤害的逻辑,所以为了权威性,一定要放在服务端进行计算,那么实例化的部分自然就要在Command中进行。再创建一个PlayerAttackController.cs脚本专门用来处理玩家攻击的逻辑。
public class PlayerAttackController : NetworkBehaviour
{
public PlayerInputController playerInputController;
public Character.Character character;
public GameObject bulletPrefab;
public Transform bulletSpawn;
public override void OnStartLocalPlayer()
{
base.OnStartClient();
playerInputController.PlayerInput.Player.Attack.started += OnFireClick;
}
private void OnFireClick(InputAction.CallbackContext context)
{
Fire();
}
[Command]
private void Fire()
{
GameObject go = Instantiate(bulletPrefab);
go.transform.position = bulletSpawn.position;
go.GetComponent<BulletObject>().SetDirection(character.IsFixpX);
NetworkServer.Spawn(go);
}
}可以看到在PlayerAttackController中,我绑定了Fire的Command到开火的Action上,并在其中处理了子弹创建的逻辑。需要注意的时,仅仅只是Instantiate预制体是不够的,要让子弹同样在客户端出现,必须加上NetworkServer.Spawn。再这之前,为了确保Network Manager可以识别创建的物体,还需要将该预制体添加到Network Manager的注册列表中。

紧接着就是处理子弹的飞行和碰撞逻辑了。同样的,为了防止作弊,子弹的碰撞逻辑也一样要在服务端中处理。
// PlayerAttackController.cs
[ServerCallback]
private void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Bullet"))
{
BulletObject bullet = other.GetComponent<BulletObject>();
character.currentHealth -= bullet.Damage;
Destroy(bullet.gameObject);
}
}
// BulletObject.cs
public class BulletObject : NetworkBehaviour
{
public readonly int Damage = 2;
public int speed;
[SyncVar]
private int m_direction;
public void SetDirection(bool flipX)
{
m_direction = flipX ? -1 : 1;
}
private void Update()
{
transform.position = new Vector2(transform.position.x + m_direction * speed * Time.deltaTime, transform.position.y);
}
}其中ServerCallback注解保证了OnTriggerEnter2D只会在服务端执行,所以所有的伤害判定逻辑最后都会在服务端处理。至此,这一个小联机demo就基本成型了。