地图、射击音效与后坐力
先打一个补丁。上节课在击中物体的特效上,删除时间太快,看不到弹坑效果,因此可以把删除时间改为1秒。但是延长删除时间后发现动画会播放两次,这是因为击中物体特效的时间太长了,可以从0.25秒改为0.15秒。
本次要导入地图成品,给枪械添加射击音效和后坐力。
导入地图资源
y总提供的地图资源现在需要付费了,可以在unity资源商店中搜索fps map,找免费或付费的资源使用。这里找到一个轻量的免费地图资源。
导入地图资源之后,点击地图资源里的Scene,从Scene中将地图整体拖进 Prefabs
中,然后将我们原来场景中的 Environment
组件删除,将地图拖进场景中,调整一下地图的位置和起始摄像机的位置
添加射击音效
导入音效包
将两个音效包导入到unity中。推荐在第一个包中找突击步枪音效,第二个包中找手枪音效,创建一个 Audios
包存放选好的音效。
给武器添加音效
点击枪械物体,添加组件 Audio Source
,然后将选好的音效拖到对应位置。在音效设置上,取消 Play On Awake
,将下方的 Spatial Blend
设为1,这可以开启空间音效。
添加音效逻辑
枪声音效和枪口火焰特效的逻辑设计是类似的,都是开枪时触发
在 WeaponManager
中,添加一个变量来存储当前武器的音效,当装备武器时获得当前音效,并编写一个get函数返回当前武器音效。
在 PlayerShooting
的 OnShoot()
里面播放音效
weaponManager.GetCurrentWeaponAudioSource().Play(); // 播放枪声
由于枪在角色的右边,因此枪声会偏右。为了调整枪声,可以判定,如果是自己开枪,则听到的枪声是2D的,敌人开枪枪声才是3D。在 WeaponManager
中,当获取到枪声组件时执行
if (IsLocalPlayer)
{
currentWeaponAudioSource.spatialBlend = 0f; // 本地玩家开枪时,枪声设为2D
}
如果想测试3D音效,可以先进行 Build
,然后从打包好的游戏中开启Host,之后将代码里播放音效的代码注释掉,在unity里启动游戏并以Client身份进入游戏,此时Client开枪,电脑里的声音是Host听Client的声音,可以测试3D音效。
枪械后坐力
单发枪械间隔设置
单发的枪械,比如手枪等,开完第一枪之后不会马上开第二枪,会有一个停顿时间。
为了实现这一效果,先在 PlayerWeapon
中添加一个变量存储单发枪械的冷却时间。
在 PlayerShooting
中,添加一个变量来记录单发武器距离上一次开枪过了多久。单发武器只有在按下鼠标左键并且超过冷却时间才能开枪,每开一次枪重置时间记录。
在 Update()
函数中,通过 Time.deltaTime
当前这一帧距离上一帧的时间,叠加到时间记录变量上。
枪械平衡
建议大家在开发FPS游戏的时候注意枪械平衡的设计:>
后坐力逻辑
这里实现枪械的后坐力是开枪时准星和枪口会向上并随机向左右运动,实际就是角色摄像头运动。
在 PlayerWeapon
中添加一个变量存储后坐力数值,设定手枪没有后坐力。
在 PlayerController
中控制角色运动。
- 建立一个变量表示累加后坐力,并编写一个函数对后坐力进行累加。
- 后坐力同时会受到环境和人物的阻力,整体逐渐减小。在
PerformRotation()
中,在每次调用的末尾将后坐力乘以一个小于1的系数。当后坐力小于0.1时设为0 - 添加围绕x轴旋转的后坐力,即枪口向上抬。直接将后坐力数值取反加到总旋转角度上即可,注意方向。
- 添加围绕y轴旋转的后坐力,即枪口随机左右摆动。
rb.transform.Rotate(yRotation + rb.transform.up * Random.Range(-2f * recoilForce, 2f * recoilForce));
,Random.Range
表示在一个范围内随机生成数。
在 PlayerShooting
的 OnShoot()
函数中添加后坐力。要注意,这里控制玩家移动的模式是客户端模式,即客户端本地玩家对角色的操作决定所有主机上角色的移动,因此当涉及角色移动的操作时,一定只能对本地玩家的角色进行操作,不能影响到其他玩家。
if (IsLocalPlayer) // 施加后坐力,跟移动有关的操作只能作用于本地玩家
{
playerController.AddRecoilForce(recoilForce);
}
优化体验
开枪的后坐力为逐渐上升时,玩家体验会比较良好,这里尝试减少前三枪的后坐力。
在 PlayerShooting
中建立一个变量记录当前一共连开了多少枪。
在 Shoot()
函数中,记录当前一共连开了多少枪,如果开枪数小于3,就减少后坐力系数。在每次按下鼠标左键并触发开枪时,重置开枪数变量。
阶段性成品
修改的代码
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 (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()
{
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);
}
}
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();
}
}
}
}
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 GameObject graphics;
}
PlayerController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerController : MonoBehaviour
{
[SerializeField]
private Rigidbody rb; // 加上注解后在unity里将要绑定的rigidbody拖进变量中
[SerializeField]
private Camera cam; // 要操作视角,需要摄像头
private Vector3 velocity = Vector3.zero; // 速度:每秒移动的距离,初始为0(一定要清楚速度对应的时间单位是帧还是秒)
private Vector3 yRotation = Vector3.zero; // 旋转角色
private Vector3 xRotation = Vector3.zero; // 旋转视角
private Vector3 thrusterForce = Vector3.zero; // 向上的推力
private float recoilForce = 0f; // 后坐力
private float cameraRotationTotal = 0f; // 累计转了多少度
[SerializeField]
private float cameraRotationLimit = 85f;
public void Move(Vector3 _velocity)
{
velocity = _velocity; // 将用户输入的速度向量传过来
}
public void Rotate(Vector3 _yRotation, Vector3 _xRotation) // 将用户输入的旋转向量传过来
{
yRotation = _yRotation;
xRotation = _xRotation;
}
public void Thrust(Vector3 _thrusterForce)
{
thrusterForce = _thrusterForce;
}
public void AddRecoilForce(float newRecoilForce) // 累加后坐力
{
recoilForce += newRecoilForce;
}
private void PerformMovement()
{
if (velocity != Vector3.zero) // 这里是一个优化,只有当角色有速度时才进行移动操作
{
rb.MovePosition(rb.position + velocity * Time.fixedDeltaTime);
}
if (thrusterForce != Vector3.zero)
{
rb.AddForce(thrusterForce); // 作用Time.fixedDeltaTime秒:0.02s
}
}
private void PerformRotation()
{
if (recoilForce < 0.1)
{
recoilForce = 0f; // 当后坐力小于0.1时置为0
}
if (yRotation != Vector3.zero || recoilForce > 0)
{
rb.transform.Rotate(yRotation + rb.transform.up * Random.Range(-2f * recoilForce, 2f * recoilForce)); // 旋转刚体,后坐力使枪口随机左右摆动
}
if (xRotation != Vector3.zero || recoilForce > 0)
{
cameraRotationTotal += xRotation.x - recoilForce; // 旋转时添加了后坐力
cameraRotationTotal = Mathf.Clamp(cameraRotationTotal, -cameraRotationLimit, cameraRotationLimit);
cam.transform.localEulerAngles = new Vector3(cameraRotationTotal, 0f, 0f); // 不旋转刚体,只旋转摄像头
}
recoilForce *= 0.5f; // 后坐力递减
}
private void FixedUpdate() // 每秒中以固定时间间隔执行固定次数
{
PerformMovement();
PerformRotation();
}
}
著作权信息
- 源视频作者:yxc
- 文章作者: jaylenwanghitsz
- 链接:https://www.acwing.com/file_system/file/content/whole/index/content/8578479/
- 来源:AcWing
- 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
hitsz太瞩目了
😉