射击伤害与重生
关于ServerRpc的函数:该函数放到Server端执行。上节课我们把击中信息放到 ShootServerRpc()
函数中,因此击中信息只显示在Server或者Host端
UI完善
隐藏UI
当我们进入游戏时,界面初始的三个按钮要隐藏。在 NetworkManagerUI
中编写一个辅助函数 DestroyAllButtons()
private void DestroyAllButtons()
{
Destroy(hostBtn.gameObject);
Destroy(serverBtn.gameObject);
Destroy(clientBtn.gameObject);
}
当点击按钮进入游戏后,调用该辅助函数隐藏UI
UI锚点
当我们拖动游戏画面,发现UI按钮可能会消失
这种情况是因为UI按钮的锚点默认为界面中心。只需要将锚点改为界面左上角,UI按钮距离界面上方和右方的距离就不变了,UI按钮便不会消失
射击伤害
Awake()、Start()、OnNetworkSpawn()
Awake()
一定早于Start()
和OnNetworkSpawn()
,但是Start()
和OnNetworkSpawn()
的执行顺序是不确定的。Awake()
不论游戏对象是否Enabled
都会执行;Start()
只有当游戏对象Enabled
时才会执行。OnNetworkSpawn()
当成功加入网络后执行。OnNetworkDespawn()
当客户断开连接时执行
修改 PlayerShooting
现在我们修改 PlayerShooting
。首先要判断击中的物体是否是玩家
- 首先给
Player
添加上标签Player - 判断击中物体是否是
Player
,如果是,调用ShootServerRpc()
函数将Player
的名称和伤害值传递到服务器上。 - 为了能找到击中的
Player
是哪一名玩家,需要在GameManager
中维护所有玩家的信息- 给
Player
添加脚本Player.cs
- 在
GameManager
中添加一个字典存储Player
和name
的映射 GameManager
的实例全局只需要有一份,因此将GameManager
设置为单例模式(singleton)GameManager
的实例在Awake()
函数中初始化- 编写函数
RegisterPlayer()
将新加入的玩家加入字典中并重命名;UnRegisterPlayer()
将玩家从字典中移除
- 给
修改 PlayerSetup
- 将
PlayerSetup
中命名玩家的逻辑删除 - 将
Start()
函数修改为public override void OnNetworkSpawn()
, 因为PlayerSetup
是一个网络对象,初始化在加入到网络时执行。如果初始化放到Start()
中执行,某些属性可能会在角色加入当网络前初始化,从而产生一些bug。 - 在函数
OnNetworkSpawn()
中将创建好的玩家角色加入到GameManager
的字典中,并调用玩家角色的Setup()
- 将
OnDisable()
函数修改为public override void OnNetworkDespawn()
, 里面的逻辑在客户断开网络连接时执行
维护玩家信息
Player
脚本中维护一个玩家角色的所有信息,需要联网,将继承的类改为 NetworkBehaviour
需要维护的信息有
- 最大生命值,所有玩家都一样且不变,不需要同步
- 当前生命值,需要同步
private NetworkVariable<int> currentHealth
: 需要同步的变量使用NetworkVariable
属性,使得变量只能在Server
端修改,Server
端修改后,会将所有Client
端的值同步- 在变动需要同步的变量时,需要判断当前是否是
Server
端,不然函数无效,浪费算力
Setup()
函数进行初始化,SetDefaults()
函数进行默认设置,目前是将当前生命值赋值为最大生命值TakeDamage()
接收伤害,该函数只会在服务器端被调用- 在
GameManager
中编写函数GetPlayer()
通过玩家名称获取玩家 - 在
PlayerShooting
的ShootServerRpc()
中,通过GetPlayer()
函数获取到被击中的玩家并让它受到伤害
- 在
联网执行顺序
假设全局只有一个对局,三名玩家A B C依次加入对局,对局的变动依次为
- A告诉Server要加入对局,Server在自己维护的对局上画出A,并将对局传回给A,A加入对局并显示出跟Server端一样的对局
- B告诉Server要加入对局,Server在自己维护的对局上画出B, 并将对局传回给A, B
- C告诉Server要加入对局,Server在自己维护的对局上画出C,并将对局传回给A B C
A射击B,函数调用顺序为
搞清楚函数执行的顺序,对于项目调试和编程至关重要
调试注意
在调试时不要只调试Host模式,因为Host模式包含Client,如果代码中有些变量忘记在Server中修改,这种bug在Host模式中是看不出的。
添加准星
- 将准星图片下载到
Assets
中 - 在
Player
物体的摄像头里添加一个Canvas
组件 - 在
Canvas
组件中添加一个RawImage
组件 - 将准星图片拖到
RawImage
的Texture
里 - 启动游戏,如果发现准星大小不合适,可以在游戏里调节,游戏结束后要记得将调节合适的数值写到
Player
物体中
死亡与重生
玩家的死亡后仍在对局内,只是不能操作。需要禁用的组件有
PlayerInput
PlayerController
PlayerShooting
- 所有玩家对局里的碰撞检测
重生之后全部恢复
死亡
- 需要一个变量来记录玩家是否死亡,该变量需要联网同步
- 在
TakeDamage()
函数中执行死亡函数- 注意,
TakeDamage()
只在服务器端执行,在角色生命值小于等于0后调用加上ClientRpc
注解的死亡函数DieClientRpc
,此时所有客户端的该函数都会被调用,但是服务器端的不会被调用,因此还需要单独写一个在服务器端调用的死亡函数DieOnServer
- 注意,
- 编写死亡函数
Die()
的逻辑,主要是禁用组件,和之前禁用组件的操作相似。要注意的是,我们在重生时,恢复死亡玩家的控制组件和碰撞,但其他玩家里只恢复碰撞。所以我们恢复的逻辑是恢复原状,需要建立一个bool数组来存储一下组件是否有效的初始状态。碰撞由于没有继承Behaviour
类,因此需要单独记录和恢复- 在
Setup()
中将组件初始状态记录下来,在SetDefaults()
中将组件恢复为初始状态
- 在
重生
设定死亡3秒后重生,代码如下
private IEnumerator Respawn() // 重生
{
yield return new WaitForSeconds(3f); // 停顿3秒,之后执行下面的语句
SetDefaults();
}
死亡之后,TakeDamage()
函数不执行,否则角色会一直死亡
transform.position = new Vector3(0f, 10f, 0f);
: 重生时,角色可以从空中落下,设定一下重生后的位置即可。调整位置的函数在本地玩家处执行,要判断是否是本地玩家,因为一个玩家角色在对局里的位置和移动以本地玩家端的位置为准,即之前讨论过的Client模式
在 Die()
函数中开启一个新线程调用 Respawn()
,角色就可以在3s后复活
代码优化
重生时间目前是一个magic number的形式存在在代码中。建立一个 MatchingSettings.cs
脚本存下重生时间,在 GameManager
中创建实例,供全局调用,可以使代码维护性更好
阶段性成品
修改的代码
NetworkManagerUI.cs
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.UI;
public class NetworkManagerUI : MonoBehaviour
{
// 将三个button与脚本绑定
[SerializeField]
private Button hostBtn;
[SerializeField]
private Button serverBtn;
[SerializeField]
private Button clientBtn;
// Start is called before the first frame update
void Start()
{
// 监听函数,当点击时触发
hostBtn.onClick.AddListener(() =>
{
NetworkManager.Singleton.StartHost();
DestroyAllButtons();
});
serverBtn.onClick.AddListener(() =>
{
NetworkManager.Singleton.StartServer();
DestroyAllButtons();
});
clientBtn.onClick.AddListener(() =>
{
NetworkManager.Singleton.StartClient();
DestroyAllButtons();
});
}
private void DestroyAllButtons()
{
Destroy(hostBtn.gameObject);
Destroy(serverBtn.gameObject);
Destroy(clientBtn.gameObject);
}
}
PlayerSetup.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Netcode;
public class PlayerSetup : NetworkBehaviour
{
[SerializeField]
private Behaviour[] componentsToDisable;
[SerializeField]
private Camera sceneCamera;
// Start is called before the first frame update
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
if (!IsLocalPlayer)
{
DisableComponents();
}
else
{
sceneCamera = Camera.main;
if (sceneCamera != null)
{
sceneCamera.gameObject.SetActive(false);
}
}
string name = "Player " + GetComponent<NetworkObject>().NetworkObjectId.ToString();
Player player = GetComponent<Player>();
player.Setup();
GameManager.Singleton.RegisterPlayer(name, player);
}
private void DisableComponents()
{
for (int i = 0; i < componentsToDisable.Length; i++)
{
componentsToDisable[i].enabled = false;
}
}
public override void OnNetworkDespawn()
{
base.OnNetworkDespawn();
if (sceneCamera != null)
{
sceneCamera.gameObject.SetActive(true);
}
GameManager.Singleton.UnregisterPlayer(transform.name);
}
}
Player.cs
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
public class Player : NetworkBehaviour
{
[SerializeField]
private int maxHealth = 100;
private NetworkVariable<int> currentHealth = new NetworkVariable<int>();
private NetworkVariable<bool> isDead = new NetworkVariable<bool>();
[SerializeField]
private Behaviour[] componentsToDisable; // 要禁用的组件
private bool[] componentsEnabled; // 组件初始状态
private bool colliderEnabled; // 碰撞体初始状态
public void Setup()
{
// 记录组件状态
componentsEnabled = new bool[componentsToDisable.Length];
for (int i = 0; i < componentsToDisable.Length; i++)
{
componentsEnabled[i] = componentsToDisable[i].enabled;
}
Collider col = GetComponent<Collider>();
colliderEnabled = col.enabled;
SetDefaults();
}
private void SetDefaults()
{
// 恢复组件状态
for (int i = 0;i < componentsToDisable.Length; i++)
{
componentsToDisable[i].enabled = componentsEnabled[i];
}
Collider col = GetComponent<Collider>();
col.enabled = colliderEnabled;
if (IsServer) // 这里要修改currentHealth,只有在Server执行才有效
{
currentHealth.Value = maxHealth;
isDead.Value = false;
}
}
public void TakeDamage(int damage) // 受到了伤害,只在服务器端被调用
{
if (isDead.Value) // 已经死亡就不调用死亡的逻辑
{
return;
}
currentHealth.Value -= damage;
if (currentHealth.Value <= 0)
{
currentHealth.Value = 0;
isDead.Value = true;
DieOnServer();
DieClientRpc();
}
}
private IEnumerator Respawn() // 重生
{
yield return new WaitForSeconds(GameManager.Singleton.MatchingSettings.respawnTime); // 停顿3秒,之后执行下面的语句
SetDefaults();
if (IsLocalPlayer) // 位置信息以本地玩家为准
{
transform.position = new Vector3(0f, 10f, 0f);
}
}
private void DieOnServer() // Server端需要单独执行一次Die的逻辑
{
Die();
}
[ClientRpc]
private void DieClientRpc() // 在Server端执行每个Client端上的函数
{
Die();
}
private void Die()
{
for (int i = 0; i < componentsToDisable.Length; i++)
{
componentsToDisable[i].enabled = false;
}
Collider col = GetComponent<Collider>();
col.enabled = false;
StartCoroutine(Respawn());
}
public int GetHealth()
{
return currentHealth.Value;
}
}
GameManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameManager : MonoBehaviour
{
public static GameManager Singleton;
[SerializeField]
public MatchingSettings MatchingSettings;
private Dictionary<string, Player> players = new Dictionary<string, Player>();
private void Awake()
{
Singleton = this;
}
public void RegisterPlayer(string name, Player player)
{
player.transform.name = name;
players.Add(name, player);
}
public void UnregisterPlayer(string name)
{
players.Remove(name);
}
public Player GetPlayer(string name)
{
return players[name];
}
private void OnGUI() // 会被自动调用
{
GUILayout.BeginArea(new Rect(200f, 200f, 200f, 400f)); // 创建一个矩形区域来显示信息
GUILayout.BeginVertical(); // 信息垂直排列
// 显示信息
GUI.color = Color.red;
foreach (string name in players.Keys)
{
Player player = players[name];
GUILayout.Label(name + " - " + player.GetHealth());
}
// 结束区域绘画
GUILayout.EndVertical();
GUILayout.EndArea();
}
}
PlayerShooting.cs
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
public class PlayerShooting : NetworkBehaviour
{
private const string PLAYER_TAG = "Player";
[SerializeField]
private PlayerWeapon weapon;
[SerializeField]
private LayerMask mask;
private Camera cam; // 角色摄像头
// Start is called before the first frame update
void Start()
{
cam = GetComponentInChildren<Camera>();
}
// Update is called once per frame
void Update()
{
if (Input.GetButtonDown("Fire1"))
{
Shoot();
}
}
private void Shoot()
{
RaycastHit hit;
if (Physics.Raycast(cam.transform.position, cam.transform.forward, out hit, weapon.range, mask))
{
if (hit.collider.tag == PLAYER_TAG)
{
ShootServerRpc(hit.collider.name, weapon.damage);
}
}
}
[ServerRpc]
private void ShootServerRpc(string name, int damage)
{
Player player = GameManager.Singleton.GetPlayer(name);
player.TakeDamage(damage);
}
}
MatchingSettings.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 存储整局玩家的所有设置
[Serializable]
public class MatchingSettings
{
public float respawnTime = 3f;
}
著作权信息
- 源视频作者:yxc
- 文章作者: jaylenwanghitsz
- 链接:https://www.acwing.com/file_system/file/content/whole/index/content/8209928/
- 来源:AcWing
- 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
666
:)