在前面的系列中,我已介绍了如何实现一个基本的FPS游戏,这里将继续进行完善,主要是增加更换武器以及更多动作动画的功能。
之前我是采用了网上一个免费的3D模型来构建角色,这个模型自带了一把AR自动步枪,并且自带了一些动作的动画,例如更换弹药,射击,瞄准等。我准备在这个模型的基础上进行扩展,增加AK47这个武器,以及重新制作并增加更多的动画,例如奔跑,走路等动画。
动画制作
设置关联骨骼
在Blender中导入网上下载的3D模型。导入后需要重新设置手的IK反向运动学骨骼,在姿态模式下,选择hand_IK的骨骼,然后按shift选择hand骨骼,然后shift-I来进行关联,设置IK骨骼约束的链长为3,即根控制骨骼是到upper_arm骨骼。现在点击hand_IK骨骼,按G键调整位置,可以看到整个手臂的骨骼会自动按照hand_IK的变化来运动。
调整模型大小
调整缩放比例,对于这个模型,我是把缩放比例调到0.6,可以通过Blender的测量工具进行测量。调整后的模型高度大致在2米。另外就是调整模型的朝向,因为Blender和Unity采用不同的坐标系,模型的朝向是相反的。
设置模型姿态
为了调整姿态,导入一个AK的模型,和枪械的相关骨骼进行关联。调整模型的骨骼,设置模型的多个相关动画。
稍微介绍一下在Blender中制作动画,需要进入姿态模式,然后移动骨骼,调整到合适的位置,然后按A全选,然后按I插入关键帧即可。
武器切换
要实现武器切换,有两个思路,一个是分别制作玩家和每一种武器对应的模型和动画,然后分别导出为不同的FBX文件。另一个思路是把玩家和武器分开来导出。可以看到第2种思路更合理,因为玩家的模型只需保留一个就可以,然后武器保存为不同的模型文件。但需要切换时就加载相应的武器模型即可。
当然这两种思路在制作模型动画时还是需要一起制作的,不然没法把武器的动作和玩家的动作很好的吻合在一起,我们只需要在导出时,把不需要的物体隐藏即可。
把制作好的玩家和武器的模型导入到Unity,并保存为Prefab。然后就可以进行代码改造了。
Animator定义
每个武器有自己的动画,然后玩家对应不同的武器也有不同的动画,要如何根据武器来进行动画的管理切换呢,我们可以用Unity的Animator override来进行处理。定义两个通用的Animator,分别对应Player和Weapon,在里面定义好状态的转换,例如以下是Player的Animator定义:
在以上的状态切换,我都是通过Trigger来触发的,没有用到Bool。因为我是在PlayerController脚本里面来维护状态。对于需要循环播放的动画,例如Sprint, Walk, Aim这些动画剪辑,我们需要在模型的Animation里面勾选Loop Time。
然后对于每一种武器,我们都定义一个Animator override,继承这个通用的Animator,只是覆盖相应状态的动画即可。对于Player也是需要定义多个Animator override来对应不同的武器,覆盖相应的动画。
之后在每一种武器的Prefab里面,增加一个Animator的组件,里面选择对应的Animator override。
代码重构
在之前的代码里面,武器的属性管理都是放在PlayerController里面的。现在既然要把玩家和武器分离,那么需要重构之前的代码,新增一个Weapon.cs的脚本文件,挂在武器上面。这个脚本除了管理相应的动作动画之外,其他和武器相关的属性也都移到这个脚本管理,不再放在原来的PlayerController脚本管理。代码如下:
cs
public class Weapon : MonoBehaviour
{
[Header("Bullet")]
[SerializeField] GameObject bulletPrefab;
[SerializeField] GameObject casingPrefab;
public int bulletVolume = 30;
public float bulletSpeed = 800f;
[Header("Property")]
[Tooltip("The shoot speed per minute")]
public int shootSpeed = 400;
public Vector3 aimOffset = Vector3.zero;
[Tooltip("Audio clip played when reload.")]
[SerializeField] AudioClip audioClipReload;
[Tooltip("Audio clip played when fire.")]
[SerializeField] AudioClip audioClipFire;
public AudioSource audioSource;
public string WeaponName;
public AnimatorOverrideController PlayerAnimatorController;
public string ClipFinishedName;
private Transform _muzzle;
private Transform _eject;
public MuzzleEffect _muzzleEffect;
private GameObject _bullet;
private GameObject _casing;
private int _currentBulletVolume;
private Animator _animator;
// Start is called before the first frame update
void Awake()
{
_animator = GetComponent<Animator>();
foreach (AnimationClip clip in _animator.runtimeAnimatorController.animationClips) {
if (clip.name.Contains("Reload") || clip.name.Contains("Hide")) {
AnimationEvent animationEndEvent = new AnimationEvent
{
time = clip.length - 0.1f,
functionName = "EndAnimationHandler",
stringParameter = clip.name
};
clip.AddEvent(animationEndEvent);
}
}
_muzzle = transform.Find("pose_controller/weapon/Muzzle");
_eject = transform.Find("pose_controller/weapon/Eject");
_muzzleEffect = GetComponent<MuzzleEffect>();
_currentBulletVolume = bulletVolume;
audioSource.clip = audioClipFire;
}
public void Shoot() {
_bullet = Instantiate(bulletPrefab, _muzzle.position, _muzzle.rotation);
_bullet.GetComponent<Rigidbody>().velocity = _muzzle.forward * bulletSpeed;
_casing = Instantiate(casingPrefab, _eject.position, _eject.rotation);
_muzzleEffect.Effect(_muzzle.position);
audioSource.Play();
_currentBulletVolume--;
}
public int GetCurrentBulletVolume() {
return _currentBulletVolume;
}
public void Reload() {
_currentBulletVolume = bulletVolume;
}
private void EndAnimationHandler(string name) {
ClipFinishedName = name;
}
// Update is called once per frame
void Update()
{
}
}
以上代码里面定义了武器对应的玩家的Animator override。另外就是添加了动画播放结束的事件处理,例如当武器切换时,需要等待旧武器的隐藏动画播放完毕后才能播放新武器的动画,因此需要通过事件来获知动画是否已播放完成。
之后就是修改PlayerController的脚本文件,代码改动如下:
cs
public class PlayerController : MonoBehaviour
{
...
[Header("Weapon")]
[SerializeField] List<GameObject> weaponPrefabs;
[SerializeField] GameObject playerPrefab;
private int _weaponIndex = 0;
private Animator _playerAnimator;
private Animator _weaponAnimator;
private GameObject _player;
private GameObject _weapon;
[Flags]
private enum PlayerStatus {
Idle,
Walk,
Sprint,
Reload,
Shoot,
Aim,
Switch
}
private PlayerStatus _playerStatus;
private void Start()
{
...
InstantiateWeapon();
_playerAnimator.SetTrigger("Idle");
_weaponAnimator.SetTrigger("Idle");
_playerStatus = PlayerStatus.Idle;
}
private void InstantiateWeapon() {
_weapon = Instantiate(weaponPrefabs[_weaponIndex]);
_weapon.transform.SetParent(transform.GetChild(0));
_weapon.transform.localRotation = Quaternion.Euler(-90, 0, 0);
_weaponAnimator = _weapon.GetComponent<Animator>();
_weaponBehavior = _weapon.GetComponent<Weapon>();
_player = Instantiate(playerPrefab);
_player.transform.SetParent(transform.GetChild(0));
_player.transform.localPosition = new Vector3(0.1f, -1.15f, 0f);
_player.transform.localRotation = Quaternion.Euler(0, 0, 0);
_playerAnimator = _player.GetComponent<Animator>();
_playerAnimator.runtimeAnimatorController = _weaponBehavior.PlayerAnimatorController;
ExecuteEvents.Execute<IGameMessage>(_gameManager, null, (x,y)=>x.ArmoMessage(_weaponBehavior.GetCurrentBulletVolume()));
ExecuteEvents.Execute<IGameMessage>(_gameManager, null, (x,y)=>x.TotalArmoMessage(_weaponBehavior.bulletVolume));
}
private void PlayAnimation() {
...
if (_input.switchWeapon) {
StartCoroutine(SwitchWeapon());
}
}
private IEnumerator SwitchWeapon() {
_weaponBehavior.ClipFinishedName = "";
_weaponAnimator.SetTrigger("Hide");
_playerAnimator.SetTrigger("Hide");
_input.switchWeapon = false;
_playerStatus = PlayerStatus.Switch;
yield return new WaitUntil(()=>_weaponBehavior.ClipFinishedName.Contains("Hide"));
Destroy(_weapon);
Destroy(_player);
if (_weaponIndex >= weaponPrefabs.Count -1) {
_weaponIndex = 0;
} else {
_weaponIndex += 1;
}
InstantiateWeapon();
_weaponAnimator.SetTrigger("Take");
_playerAnimator.SetTrigger("Take");
}
以上的代码定义了一个Enum类型的状态值来管理当前的状态,定义了一个Weapon prefab列表来保存所有可以切换的武器。定义了一个根据不同的武器来初始化对应的武器和玩家Gameobject的InstantiateWeapon函数。还有就是当收到切换武器的键盘输入时,调用SwitchWeapon进行切换。切换的时候需要先播放现有武器和玩家的隐藏动画,然后等动画播放完毕后,销毁现有的武器和玩家对象,然后重新调用InstantiateWeapon来初始化,并且播放Take动画。
武器切换的效果如下:
switch_weapon
瞄准模式
现在流行的FPS游戏,提供了机械瞄准的功能,比一般的抵腰射击瞄准姿势能提高准确度。要实现瞄准功能,有两个思路,一个是在机械瞄准的位置设置另一个虚拟摄像机,然后当要切换为瞄准模式的时候进行摄像机切换。另一种是调整玩家和武器的位置到瞄准位置。可以看到第2个思路更符合实际情况。
首先是打开Assets/Input的PlayerInputAsset文件,增加一个名为Aim的Action,类型为Button。在这个Action下面增加一个绑定到鼠标右键。
编辑Scripts目录的Weapon.cs文件,增加一个Vector3 aimOffset的属性,因为对于不同的武器,其瞄准的位置时有差异的。
编辑Scripts目录里面的PlayerInputAsset.cs文件,增加以下代码:
cs
public class PlayerInputAsset : MonoBehaviour
{
...
public bool aim;
public void OnAim(InputValue value) {
aim = true;
}
}
编辑Scripts目录的PlayerController文件,增加以下代码:
cs
public class PlayerController : MonoBehaviour
{
...
private CinemachineVirtualCamera _camera;
private Vector3 _aimOffset = new Vector3(-0.131f, 0.066f, -0.3f);
private Vector3 _weaponVelocity = Vector3.zero;
private Vector3 _playerVelocity = Vector3.zero;
private float _cameraVelocity = 0f;
private Vector3 _weaponPosition;
private Vector3 _weaponAimPosition;
private Vector3 _playerPosition;
private Vector3 _playerAimPosition;
private bool _currentAimStatus = false;
private void InstantiateWeapon()
{
...
_weaponPosition = _weapon.transform.localPosition;
_weaponAimPosition = _weaponPosition + _weaponBehavior.aimOffset;
_playerPosition = _player.transform.localPosition;
_playerAimPosition = _playerPosition + _weaponBehavior.aimOffset;
}
private void Aim() {
if (_weapon.transform.localPosition == _weaponPosition) {
_weapon.transform.Find("pose_controller/crosshair").gameObject.SetActive(false);
_playerAnimator.SetTrigger("Aim");
_weaponAnimator.SetTrigger("Aim");
}
if (_weapon.transform.localPosition == _weaponAimPosition) {
_input.aim = false;
_playerStatus = PlayerStatus.Aim;
} else {
_weapon.transform.localPosition = Vector3.SmoothDamp(_weapon.transform.localPosition, _weaponAimPosition, ref _weaponVelocity, 0.05f);
_player.transform.localPosition = Vector3.SmoothDamp(_player.transform.localPosition, _playerAimPosition, ref _playerVelocity, 0.05f);
_camera.m_Lens.FieldOfView = Mathf.SmoothDamp(_camera.m_Lens.FieldOfView, 35, ref _cameraVelocity, 0.05f);
}
}
private void ExitAim() {
if (_weapon.transform.localPosition == _weaponAimPosition) {
_weapon.transform.Find("pose_controller/crosshair").gameObject.SetActive(true);
_playerAnimator.SetTrigger("Idle");
_weaponAnimator.SetTrigger("Idle");
}
if (_weapon.transform.localPosition == _weaponPosition) {
_input.aim = false;
_playerStatus = PlayerStatus.Idle;
} else {
_weapon.transform.localPosition = Vector3.SmoothDamp(_weapon.transform.localPosition, _weaponPosition, ref _weaponVelocity, 0.05f);
_player.transform.localPosition = Vector3.SmoothDamp(_player.transform.localPosition, _playerPosition, ref _playerVelocity, 0.05f);
_camera.m_Lens.FieldOfView = Mathf.SmoothDamp(_camera.m_Lens.FieldOfView, 40, ref _cameraVelocity, 0.05f);
}
}
private void PlayAnimation() {
...
if (_input.aim) {
if (_playerStatus != PlayerStatus.Aim) {
Aim();
} else {
ExitAim();
}
}
}
}
解释一下代码,我们需要定义一个瞄准时的偏移值,然后保存武器和玩家瞄准时的位置。当玩家点击鼠标右键时,判断当前的状态是否瞄准,如否,则切换到瞄准模式,隐藏十字准星,通过SmoothDamp函数来逐步调节武器和玩家的位置到瞄准模式的位置,同时也逐步调节虚拟摄像机的光圈放大画面。如果当前状态已是瞄准模式,则采取相反的动作,调整玩家和武器的位置到正常位置。
当然我们还需要定义相应的瞄准模式的动画,当处于瞄准模式时,呼吸作用导致的武器上下起伏的幅度应该大幅减小。
另外当处于瞄准模式时,玩家前进或左右转向的速度应该大大降低。
最后完成的效果如下所示:
Aim
画面震动
当玩家进行射击时,由于武器后坐力的作用,我们可以设置画面震动,以达到更好的效果。当然对于玩家受到伤害或者爆炸时,也可以添加画面震动的效果。
在Player的GameObject下添加一个Cinemachine Impulse Source的组件,Impulse Channel设置为Everything,Raw signal选择Handheld_normal_strong,Amplitude Gain设置为0.05, Frequence Gain设置为100。
然后选择我们场景中的Virtual Camera,添加一个Cinemachine Impulse Listener的组件,Channel Mask设置为Everything。
最后在PlayerController里面,Shoot函数中增加一行代码_shootImpulse.GenerateImpulse();即可生成画面震动。