装弹、血条与玩家UI
完结撒花!!!
这次来修复一些项目BUG,并添加装弹和血条机制,完善玩家UI
修复BUG
如果玩家在死亡时是连发模式,死亡期间按下切枪键,那么复活时半自动枪也可以连发。原因是因为当玩家死亡时 WeaponManager
没有被禁用,此时可以切枪;但PlayerShooting
被禁用了,因此无法在切枪时停止射击
解决方法:在角色死亡时调用一下停止射击的函数即可
PlayerShooting
public void StopShooting()
{
CancelInvoke("Shoot");
}
在 Player
的死亡函数里,执行一下 StopShooting()
函数。
装弹
装弹逻辑
在 PlayerWeapon
中,添加变量
- 满弹匣弹药数
- 当前弹匣弹药数
- 装弹时间
- 是否在装弹
在 WeaponManager
中编写
public void Reload(PlayerWeapon playerWeapon)
{
if (playerWeapon.isReloading) return;
playerWeapon.isReloading = true;
StartCoroutine(ReloadCoroutine(playerWeapon)); // 开启多线程换单
}
private IEnumerator ReloadCoroutine(PlayerWeapon playerWeapon)
{
yield return new WaitForSeconds(playerWeapon.reloadTime);
playerWeapon.bullets = playerWeapon.maxBullets;
playerWeapon.isReloading = false;
}
在 PlayerShooting
的射击函数中,当没子弹时自动换弹。绑定R键为换弹键
if (Input.GetKeyDown(KeyCode.R))
{
weaponManager.Reload(currentWeapon);
return;
}
展示弹药信息
将角色弹药信息展示在右下角
在 MenuUI
中新建一个物体 Panel
,放到右下角,锚点选择右下角
在 Panel
里添加一个 Text Mesh Pro
,在里面编写内容,然后调整位置、大小、字体、颜色、透明度等属性,大家可以在右边的属性栏里多看看多试试。之后将 Panel
禁用。
创建脚本 PlayerUI
并和 MenuUI
绑定在一起,编写以下内容
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
public class PlayerUI : MonoBehaviour
{
public static PlayerUI Singleton; // 单例
private Player player = null; // 本地玩家
[SerializeField]
private TextMeshProUGUI bulletsText; // 展示信息
[SerializeField]
private GameObject bulletsObject; // 初始为禁用状态
private WeaponManager weaponManager;
private void Awake()
{
Singleton = this;
}
public void SetPlayer(Player localPlayer)
{
player = localPlayer;
weaponManager = player.GetComponent<WeaponManager>();
bulletsObject.SetActive(true); // 当玩家进入游戏后启用弹药展示
}
// Update is called once per frame
void Update()
{
if (player == null) return;
var currentWeapon = weaponManager.GetCurrentWeapon();
if (currentWeapon.isReloading)
{
bulletsText.text = "Reloading...";
}
else
{
bulletsText.text = "Bullets: " + currentWeapon.bullets + "/" + currentWeapon.maxBullets;
}
}
}
在 PlayerSetup
玩家加入到对局中时调用 SetPlayer
函数。
展示血条
在 MenuUI
中创建 Image
物体,这是我们的血条底。将右边的 Source Image
改为 UISprite
,不透明度调成稍低。
之后复制一个 Image
并放进刚刚的血条底中做子组件,这是当前血条。当前血条的不透明度改为最高。将右边的 Pivot
X改为0,表示锚点放在组件最左侧,这样在修改X方向的缩放时,组件大小只会在X轴上单向移动。我们通过控制组件的X方向缩放大小来调整血条
同装弹,血条在一开始是隐藏的,只有当玩家进入游戏时才展示。在 PlayerUI
中,先将两个血条通过 SerializeField
取出。在 SetPlayer()
中启用血条组件,在 Update()
中改变血条长度
healthBarFill.localScale = new Vector3(player.GetHealth() / 100, 1f, 1f); // 按照血量改变血条长度
展示玩家姓名
y总的课是展示玩家血条和姓名,这里只展示玩家姓名
双击 Prefabs
里的 Player
,在 Player
里创建一个 Canvas
, 将右边的 Render Mode
改为 World Space
,重置并调整一下组件的位置和大小,使其处于角色头顶
之后在 Canvas
里创建一个 Text
组件,这里面写名字。
在 Player
中创建并绑定一个脚本 PlayerInfo
,在里面编写
[SerializeField]
private TextMeshProUGUI playerName; // 用户名引用
[SerializeField]
private Transform infoUI; // UI的位置信息
// Update is called once per frame
void Update()
{
playerName.text = transform.name;
// 将信息条永远面向自己
var camera = Camera.main;
infoUI.transform.LookAt(infoUI.transform.position + camera.transform.rotation * Vector3.back, camera.transform.rotation * Vector3.up);
}
记得将引用取出。将 Player
里的 Camera
属性里的 Tag
改为 MainCamera
Unity3D课最终成品
最终将游戏改为了越肩视角的射击游戏,日后第一人称的动画会单独制作再合并
修改的代码
WeaponManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Netcode;
public class WeaponManager : NetworkBehaviour
{
[SerializeField]
private PlayerWeapon primaryWeapon;
[SerializeField]
private PlayerWeapon secondaryWeapon;
private NetworkVariable<int> currentWeaponIndex = new NetworkVariable<int>(); // 记录玩家当前持的枪,初始值为0
[SerializeField]
private GameObject weaponHolder;
private PlayerWeapon currentWeapon;
private WeaponGraphics currentWeaponGraphics;
private AudioSource currentWeaponAudioSource;
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
EquipWeapon(currentWeaponIndex.Value);
}
public void EquipWeapon(int weaponIndex)
{
if (weaponIndex == 0)
{
currentWeapon = primaryWeapon;
}
else if (weaponIndex == 1)
{
currentWeapon = secondaryWeapon;
}
if (weaponHolder.transform.childCount > 0)
{
Destroy(weaponHolder.transform.GetChild(0).gameObject); // 去掉先前挂载的枪的gameObject
}
// 创建渲染一个武器物体对象
GameObject weaponObject = Instantiate(currentWeapon.graphics, weaponHolder.transform.position, weaponHolder.transform.rotation);
weaponObject.transform.SetParent(weaponHolder.transform); // 将武器对象挂载到WeaponHolder上
currentWeaponGraphics = weaponObject.GetComponent<WeaponGraphics>();
currentWeaponAudioSource = weaponObject.GetComponent<AudioSource>();
if (IsLocalPlayer)
{
currentWeaponAudioSource.spatialBlend = 0f; // 本地玩家开枪时,枪声设为2D
}
}
public PlayerWeapon GetCurrentWeapon()
{
return currentWeapon;
}
public WeaponGraphics GetCurrentWeaponGraphics()
{
return currentWeaponGraphics;
}
public AudioSource GetCurrentWeaponAudioSource()
{
return currentWeaponAudioSource;
}
private void ToggleWeapon(int weaponIndex)
{
EquipWeapon(weaponIndex);
}
[ClientRpc]
private void ToggleWeaponClientRpc(int weaponIndex)
{
ToggleWeapon(weaponIndex);
}
[ServerRpc]
private void ToggleWeaponServerRpc()
{
currentWeaponIndex.Value = (currentWeaponIndex.Value + 1) % 2; // 改变数值来切枪,只有两把枪
if (!IsHost)
{
ToggleWeapon(currentWeaponIndex.Value); // 这里一定要传入数值,因为客户端和服务器端的网络变量数值不一定同步,会出现切枪错误的问题,所以这里直接传修改好的值
}
ToggleWeaponClientRpc(currentWeaponIndex.Value); // 同理
}
public void SetDefaultWeapon()
{
currentWeaponIndex.Value = 0;
}
// Update is called once per frame
void Update()
{
if (IsLocalPlayer)
{
if (Input.GetKeyDown(KeyCode.Q)) // 按下Q切换武器
{
ToggleWeaponServerRpc();
}
}
}
public void Reload(PlayerWeapon playerWeapon)
{
if (playerWeapon.isReloading) return;
playerWeapon.isReloading = true;
StartCoroutine(ReloadCoroutine(playerWeapon)); // 开启多线程换单
}
private IEnumerator ReloadCoroutine(PlayerWeapon playerWeapon)
{
yield return new WaitForSeconds(playerWeapon.reloadTime);
playerWeapon.bullets = playerWeapon.maxBullets;
playerWeapon.isReloading = false;
}
}
PlayerShooting.cs
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
public class PlayerShooting : NetworkBehaviour
{
private const string PLAYER_TAG = "Player";
private WeaponManager weaponManager;
private PlayerWeapon currentWeapon;
private float shootCoolDownTime = 0f; // 距离上次开枪时间过了多久
[SerializeField]
private LayerMask mask;
private Camera cam; // 角色摄像头
private PlayerController playerController;
private int autoShootCount = 0; // 当前一共连开多少枪
enum HitEffectMaterial
{
Metal,
Stone,
}
// Start is called before the first frame update
void Start()
{
cam = GetComponentInChildren<Camera>();
weaponManager = GetComponent<WeaponManager>();
playerController = GetComponent<PlayerController>();
}
// Update is called once per frame
void Update()
{
shootCoolDownTime += Time.deltaTime;
if (!IsLocalPlayer) return;
currentWeapon = weaponManager.GetCurrentWeapon();
if (Input.GetKeyDown(KeyCode.R))
{
weaponManager.Reload(currentWeapon);
return;
}
if (currentWeapon.shootRate <= 0) // 单发
{
if (Input.GetButtonDown("Fire1") && shootCoolDownTime >= currentWeapon.shootCoolDownTime) // 只有当大于冷却时间才能开枪
{
autoShootCount = 0;
Shoot();
shootCoolDownTime = 0f; // 重置冷却时间
}
}
else
{
if (Input.GetButtonDown("Fire1"))
{
autoShootCount = 0;
InvokeRepeating("Shoot", 0f, 1f / currentWeapon.shootRate);
}
else if (Input.GetButtonUp("Fire1") || Input.GetKeyDown(KeyCode.Q))
{
CancelInvoke("Shoot");
}
}
}
private void OnHit(Vector3 pos, Vector3 normal, HitEffectMaterial material) // 击中点的特效,传递的值是位置、法向量和材质
{
GameObject hitEffectPrefab;
if (material == HitEffectMaterial.Metal)
{
hitEffectPrefab = weaponManager.GetCurrentWeaponGraphics().metalHitEffectPrefab;
}
else if (material == HitEffectMaterial.Stone)
{
hitEffectPrefab = weaponManager.GetCurrentWeaponGraphics().stoneHitEffectPrefab;
}
else
{
hitEffectPrefab = new GameObject();
}
// 将特效实例化,传递信息,其中法向量要传递反向的向量,也是人眼看过来的向量方向
GameObject hitEffectObject = Instantiate(hitEffectPrefab, pos, Quaternion.LookRotation(normal));
ParticleSystem particleSystem = hitEffectObject.GetComponent<ParticleSystem>();
particleSystem.Emit(1); // 使特效立即触发
particleSystem.Play();
Destroy(hitEffectObject, 1f); // 在0.25秒后将特效删除
}
[ClientRpc]
private void OnHitClientRpc(Vector3 pos, Vector3 normal, HitEffectMaterial material)
{
OnHit(pos, normal, material);
}
[ServerRpc]
private void OnHitServerRpc(Vector3 pos, Vector3 normal, HitEffectMaterial material)
{
if (!IsHost)
{
OnHit(pos, normal, material);
}
OnHitClientRpc(pos, normal, material);
}
private void OnShoot(float recoilForce) // 每次射击相关的逻辑,包括特效、声音等
{
weaponManager.GetCurrentWeaponGraphics().muzzleFlash.Play(); // 播放枪口火焰特效
weaponManager.GetCurrentWeaponAudioSource().Play(); // 播放枪声
if (IsLocalPlayer) // 施加后坐力,跟移动有关的操作只能作用于本地玩家
{
playerController.AddRecoilForce(recoilForce);
}
}
[ServerRpc]
private void OnShootServerRpc(float recoilForce)
{
if (!IsHost)
{
OnShoot(recoilForce);
}
OnShootClientRpc(recoilForce);
}
[ClientRpc]
private void OnShootClientRpc(float recoilForce)
{
OnShoot(recoilForce);
}
private void Shoot()
{
if (currentWeapon.bullets <= 0 || currentWeapon.isReloading)
{
return;
}
currentWeapon.bullets--;
if (currentWeapon.bullets <= 0)
{
weaponManager.Reload(currentWeapon);
}
autoShootCount++; // 记录连开多少枪
float recoilForce = currentWeapon.recoilForce;
if (autoShootCount <= 3) // 前3枪后坐力降低
{
recoilForce *= 0.2f;
}
OnShootServerRpc(recoilForce);
RaycastHit hit;
if (Physics.Raycast(cam.transform.position, cam.transform.forward, out hit, currentWeapon.range, mask))
{
if (hit.collider.tag == PLAYER_TAG)
{
ShootServerRpc(hit.collider.name, currentWeapon.damage);
OnHitServerRpc(hit.point, hit.normal, HitEffectMaterial.Metal);
}
else
{
OnHitServerRpc(hit.point, hit.normal, HitEffectMaterial.Stone);
}
}
}
[ServerRpc]
private void ShootServerRpc(string name, int damage)
{
Player player = GameManager.Singleton.GetPlayer(name);
player.TakeDamage(damage);
}
public void StopShooting()
{
CancelInvoke("Shoot");
}
}
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;
if (!IsHost) // Host不需要执行这一段,不然会重复执行
{
DieOnServer();
}
DieClientRpc();
}
}
private IEnumerator Respawn() // 重生,所有逻辑一定一定要在 yield之后写!!!
{
yield return new WaitForSeconds(GameManager.Singleton.MatchingSettings.respawnTime); // 停顿3秒,之后执行下面的语句
GetComponentInChildren<Animator>().SetInteger("direction", 0);
GetComponent<Rigidbody>().useGravity = true;
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()
{
GetComponent<PlayerShooting>().StopShooting();
GetComponentInChildren<Animator>().SetInteger("direction", -1);
GetComponent<Rigidbody>().useGravity = false;
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;
}
public bool IsDead()
{
return isDead.Value;
}
}
PlayerWeapon.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class PlayerWeapon
{
public string name = "AUGA3";
public int damage = 10;
public float range = 100f;
public float shootRate = 10f; // 一秒可以打多少发子弹。如果小于等于0,则为单发
public float shootCoolDownTime = 0.5f; // 单发模式冷却时间
public float recoilForce = 2f; // 后坐力
public int maxBullets = 30;
public int bullets = 30;
public float reloadTime = 2f;
[HideInInspector] // 不在编辑器里展示出来
public bool isReloading = false;
public GameObject graphics;
}
PlayerUI.cs
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
public class PlayerUI : MonoBehaviour
{
public static PlayerUI Singleton; // 单例
private Player player = null; // 本地玩家
[SerializeField]
private TextMeshProUGUI bulletsText; // 展示信息
[SerializeField]
private GameObject bulletsObject; // 初始为禁用状态
private WeaponManager weaponManager;
[SerializeField]
private Transform healthBarFill;
[SerializeField]
private GameObject healthBarObject;
private void Awake()
{
Singleton = this;
}
public void SetPlayer(Player localPlayer)
{
player = localPlayer;
weaponManager = player.GetComponent<WeaponManager>();
bulletsObject.SetActive(true); // 当玩家进入游戏后启用弹药展示
healthBarObject.SetActive(true); // 当玩家进入游戏后启用血条展示
}
// Update is called once per frame
void Update()
{
if (player == null) return;
var currentWeapon = weaponManager.GetCurrentWeapon();
if (currentWeapon.isReloading)
{
bulletsText.text = "Reloading...";
}
else
{
bulletsText.text = "Bullets: " + currentWeapon.bullets + "/" + currentWeapon.maxBullets;
}
healthBarFill.localScale = new Vector3(player.GetHealth() / 100, 1f, 1f); // 按照血量改变血条长度
}
}
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)
{
// SetLayerMaskForAllChildren(transform, LayerMask.NameToLayer("Remote Player")); // 如果不是本地玩家,就将图层设置为联网玩家
DisableComponents();
}
else
{
PlayerUI.Singleton.SetPlayer(GetComponent<Player>());
// SetLayerMaskForAllChildren(transform, LayerMask.NameToLayer("Player")); // 本地玩家,将图层设置为本地玩家
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 SetLayerMaskForAllChildren(Transform transform, LayerMask layerMask)
{
// 这里要改掉Player的图层。每个物体的图层和子物体的图层是独立的,因此要修改Player整体的图层,需要对所有子物体进行修改
// 这里使用dfs实现
transform.gameObject.layer = layerMask;
for (int i = 0; i < transform.childCount; i++)
{
SetLayerMaskForAllChildren(transform.GetChild(i), layerMask);
Debug.Log(transform.GetChild(i).name);
}
}
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);
}
}
PlayerInfo.cs
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
public class PlayerInfo : MonoBehaviour
{
[SerializeField]
private TextMeshProUGUI playerName; // 用户名引用
[SerializeField]
private Transform infoUI; // UI的位置信息
// Update is called once per frame
void Update()
{
playerName.text = transform.name;
// 将信息条永远面向自己
var camera = Camera.main;
infoUI.transform.LookAt(infoUI.transform.position + camera.transform.rotation * Vector3.back, camera.transform.rotation * Vector3.up);
}
}
著作权信息
- 源视频作者:yxc
- 文章作者: jaylenwanghitsz
- 链接:https://www.acwing.com/file_system/file/content/whole/index/content/9222420/
- 来源:AcWing
- 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
白嫖Unity3D课,hhh