FPSMicroGame 武器系统
射击
- 散射
散射的计算WeaponController.cs脚本里。在GetShotDirectionWithinSpread函数里,主要是通过Vector3.Slerp,将 枪口的前向向量 和 一个随机的从球心到球面的单位向量,做插值。插值比例就是BulletSpreadAngle / 180f。可以想象一下, 两个三维向量角度之差最多是180°,所以BulletSpreadAngle 是最大散射角。
void HandleShoot()
{
//...
// spawn all bullets with random direction
for (int i = 0; i < bulletsPerShotFinal; i++)
{
Vector3 shotDirection = GetShotDirectionWithinSpread(WeaponMuzzle);
ProjectileBase newProjectile = Instantiate(ProjectilePrefab, WeaponMuzzle.position,
Quaternion.LookRotation(shotDirection));
newProjectile.Shoot(this);
}
//...
}
public Vector3 GetShotDirectionWithinSpread(Transform shootTransform)
{
float spreadAngleRatio = BulletSpreadAngle / 180f;
Vector3 spreadWorldDirection = Vector3.Slerp(shootTransform.forward, UnityEngine.Random.insideUnitSphere,
spreadAngleRatio);
return spreadWorldDirection;
}
-
后坐
- 射击游戏中,为了模拟真实的枪械,后坐力又分为水平后坐力和竖直后坐力。由于这两种后坐力的影响,枪械在连发时,弹道会形成一个T字型,俗称T型弹道。具体原理是:竖直后坐会一直把枪口上抬,抬升到一个最大角度后保持。水平后坐会给弹道增加随机的左右偏移。所以在枪械连发时,可以看到由于竖直后坐的作用,弹道快速上抬,之后高度不变,左右摇摆,得到T型弹道。
- 完善的游戏中,后坐力的手感表现需要,模型的运动表现 + 弹道的表现 + 镜头动画的表现。当前的FPSMicroGame中,只有模型的运动表现。我们先来看看。在PlayerWeaponsManager.cs里。
- 在update函数里叠加了每帧的后坐力,m_AccumulatedRecoil其实代表后坐力累加后,期望枪械所在的位置。
- 在lateUpdate函数里 调用UpdateWeaponRecoil();计算了后坐力的动画,并最终修改了武器的位置WeaponParentSocket.localPosition。这里要注意,后坐力是朝Z轴负方向。
- UpdateWeaponRecoil函数里,先判断期望的后坐力位置,和当前后坐力位置差异。如果差异过大,则往期望后坐力位置移动。否者说明后坐力已经达到,就开始恢复。
void Update() { //... // Handle accumulating recoil if (hasFired) { m_AccumulatedRecoil += Vector3.back * activeWeapon.RecoilForce; m_AccumulatedRecoil = Vector3.ClampMagnitude(m_AccumulatedRecoil, MaxRecoilDistance); } //... } void LateUpdate() { UpdateWeaponAiming(); UpdateWeaponBob(); UpdateWeaponRecoil(); UpdateWeaponSwitching(); // Set final weapon socket position based on all the combined animation influences WeaponParentSocket.localPosition = m_WeaponMainLocalPosition + m_WeaponBobLocalPosition + m_WeaponRecoilLocalPosition; } // Updates the weapon recoil animation void UpdateWeaponRecoil() { // if the accumulated recoil is further away from the current position, make the current position move towards the recoil target if (m_WeaponRecoilLocalPosition.z >= m_AccumulatedRecoil.z * 0.99f) { m_WeaponRecoilLocalPosition = Vector3.Lerp(m_WeaponRecoilLocalPosition, m_AccumulatedRecoil, RecoilSharpness * Time.deltaTime); } // otherwise, move recoil position to make it recover towards its resting pose else { m_WeaponRecoilLocalPosition = Vector3.Lerp(m_WeaponRecoilLocalPosition, Vector3.zero, RecoilRestitutionSharpness * Time.deltaTime); m_AccumulatedRecoil = m_WeaponRecoilLocalPosition; } }
-
开火间隔
-
控制枪械射速的变量,在程序实现上比较简单。在接收到输入时,调用TryShoot。只有子弹余量大于1,并且距离上次触发射击超过DelayBetweenShots时间后,才可以调用HandleShoot();
bool TryShoot() { if (m_CurrentAmmo >= 1f && m_LastTimeShot + DelayBetweenShots < Time.time) { HandleShoot(); m_CurrentAmmo -= 1f; return true; } return false; }
-
-
开镜 Aiming
- 看到这里其实很简单,开镜和关镜,就是移动一下枪的位置,同时修改一下FOV。对于装有倍镜的武器,还要叠加一层开镜UI。(Unity中的Fov指的是视锥体垂直方向两个对边的夹角)。开镜会应用一个更小的FOV,相机只观察更小的一个角度,但这部分的景物,还要填充满屏幕,所以就实现了放大。
- 这里注意一下,游戏内存在两个相机,一个是主相机PlayerCamera,一个是只拍摄player武器模型的WeaponCamera。
// Updates weapon position and camera FoV for the aiming transition void UpdateWeaponAiming() { if (m_WeaponSwitchState == WeaponSwitchState.Up) { WeaponController activeWeapon = GetActiveWeapon(); if (IsAiming && activeWeapon) { m_WeaponMainLocalPosition = Vector3.Lerp(m_WeaponMainLocalPosition, AimingWeaponPosition.localPosition + activeWeapon.AimOffset,AimingAnimationSpeed * Time.deltaTime); SetFov(Mathf.Lerp(m_PlayerCharacterController.PlayerCamera.fieldOfView, activeWeapon.AimZoomRatio * DefaultFov, AimingAnimationSpeed * Time.deltaTime)); } else { m_WeaponMainLocalPosition = Vector3.Lerp(m_WeaponMainLocalPosition, DefaultWeaponPosition.localPosition, AimingAnimationSpeed * Time.deltaTime); SetFov(Mathf.Lerp(m_PlayerCharacterController.PlayerCamera.fieldOfView, DefaultFov, AimingAnimationSpeed * Time.deltaTime)); } } } // Sets the FOV of the main camera and the weapon camera simultaneously public void SetFov(float fov) { m_PlayerCharacterController.PlayerCamera.fieldOfView = fov; WeaponCamera.fieldOfView = fov * WeaponFovMultiplier; }
-
武器运动摇摆 Weapon Bob
-
Weapon Bob 是指角色运动时武器的摇摆动作。demo中摇摆是通过lateupdate中UpdateWeaponBob函数来做的。函数主要做了两件事,通过角色的速度,计算出摇摆因子。通过摇摆因子,乘上正玄函数,得到枪在本帧的位置。注意到函数分别计算了水平方向和竖直方向上的摇摆位置:hBobValue、vBobValue,竖直方向上摇摆的频率是水平的两倍。最后枪械摇摆会画出一个横着的8字型。
// Updates the weapon bob animation based on character speed void UpdateWeaponBob() { if (Time.deltaTime > 0f) { Vector3 playerCharacterVelocity = (m_PlayerCharacterController.transform.position - m_LastCharacterPosition) / Time.deltaTime; // calculate a smoothed weapon bob amount based on how close to our max grounded movement velocity we are float characterMovementFactor = 0f; if (m_PlayerCharacterController.IsGrounded) { characterMovementFactor = Mathf.Clamp01(playerCharacterVelocity.magnitude / (m_PlayerCharacterController.MaxSpeedOnGround * m_PlayerCharacterController.SprintSpeedModifier)); } m_WeaponBobFactor = Mathf.Lerp(m_WeaponBobFactor, characterMovementFactor, BobSharpness * Time.deltaTime); // Calculate vertical and horizontal weapon bob values based on a sine function float bobAmount = IsAiming ? AimingBobAmount : DefaultBobAmount; float frequency = BobFrequency; float hBobValue = Mathf.Sin(Time.time * frequency) * bobAmount * m_WeaponBobFactor; float vBobValue = ((Mathf.Sin(Time.time * frequency * 2f) * 0.5f) + 0.5f) * bobAmount * m_WeaponBobFactor; // Apply weapon bob m_WeaponBobLocalPosition.x = hBobValue; m_WeaponBobLocalPosition.y = Mathf.Abs(vBobValue); m_LastCharacterPosition = m_PlayerCharacterController.transform.position; } }
-
-
武器瞄准摇摆 Weapon Sway
- Weapon Sway 是指武器在瞄准时,武器自己的晃动。这个在demo中没有实现。
-
开火特效、音效
-
开火特效
- 这里可以看到在开火时,枪口有绿色的粒子特效,射出子弹的弹道,枪身过热导致的红色烟雾。我们分别看下这些是如何实现的。
-
1. 枪口开火特效:在射击时,在枪口位置,实例化了一个粒子特效 prefabMuzzleFlashPrefab。并且在2s后销毁。
2. 子弹弹道,也是实例化。
void HandleShoot()
{
//生成子弹弹道 spawn all bullets with random direction
for (int i = 0; i < bulletsPerShotFinal; i++)
{
Vector3 shotDirection = GetShotDirectionWithinSpread(WeaponMuzzle);
ProjectileBase newProjectile = Instantiate(ProjectilePrefab, WeaponMuzzle.position,
Quaternion.LookRotation(shotDirection));
newProjectile.Shoot(this);
}
//枪口特效 muzzle flash
if (MuzzleFlashPrefab != null)
{
GameObject muzzleFlashInstance = Instantiate(MuzzleFlashPrefab, WeaponMuzzle.position,
WeaponMuzzle.rotation, WeaponMuzzle.transform);
// Unparent the muzzleFlashInstance
if (UnparentMuzzleFlash)
{
muzzleFlashInstance.transform.SetParent(null);
}
Destroy(muzzleFlashInstance, 2f);
}
}
3. 过热导致的红色烟雾:这个在另一个脚本OverheatBehavior.cs里面。这里可以看到烟雾的控制是通过调整粒子系统的rateOverTimeMultiplier参数。它会调整粒子曲线的幅度。同时枪械在过热时的颜色也会发生改变。
void Update()
{
// visual smoke shooting out of the gun 枪械在过热时的颜色。
float currentAmmoRatio = m_Weapon.CurrentAmmoRatio;
if (currentAmmoRatio != m_LastAmmoRatio)
{
m_OverheatMaterialPropertyBlock.SetColor("_EmissionColor",
OverheatGradient.Evaluate(1f - currentAmmoRatio));
foreach (var data in m_OverheatingRenderersData)
{
data.Renderer.SetPropertyBlock(m_OverheatMaterialPropertyBlock, data.MaterialIndex);
}
//过热烟雾
m_SteamVfxEmissionModule.rateOverTimeMultiplier = SteamVfxEmissionRateMax * (1f - currentAmmoRatio);
}
- 音效
- 开火音效分为两种,连续开火音效 和 单发开火音效。具体看WeaponController文件,这个根据UseContinuousShootSound变量来区分。一个AudioSource可以通过 PlayOneShot 函数,播放多个声音片段。
//连续射击音效
void UpdateContinuousShootSound()
{
if (UseContinuousShootSound)
{
if (m_WantsToShoot && m_CurrentAmmo >= 1f)
{
if (!m_ContinuousShootAudioSource.isPlaying)
{
m_ShootAudioSource.PlayOneShot(ShootSfx);
m_ShootAudioSource.PlayOneShot(ContinuousShootStartSfx);
m_ContinuousShootAudioSource.Play();
}
}
else if (m_ContinuousShootAudioSource.isPlaying)
{
m_ShootAudioSource.PlayOneShot(ContinuousShootEndSfx);
m_ContinuousShootAudioSource.Stop();
}
}
}
//单发开火音效
void HandleShoot()
{
// play shoot SFX
if (ShootSfx && !UseContinuousShootSound)
{
m_ShootAudioSource.PlayOneShot(ShootSfx);
}
}
-
命中特效、音效
- 在demo中命中没有播放音效,只有有命中特效,但是代码中是有音效部分的。我们观察一下子弹的prefab:Projectile_Blaster。其中impact Vfx里引用了一个prefab,VFX_LazerSparksGreen。再看一下脚本ProjectileStandard的OnHit函数中做了四件事,计算了伤害、命中特效,音效,和子弹的销毁。
void OnHit(Vector3 point, Vector3 normal, Collider collider) { // damage if (AreaOfDamage) { // area damage AreaOfDamage.InflictDamageInArea(Damage, point, HittableLayers, k_TriggerInteraction, m_ProjectileBase.Owner); } else { // point damage Damageable damageable = collider.GetComponent<Damageable>(); if (damageable) { damageable.InflictDamage(Damage, false, m_ProjectileBase.Owner); } } // impact vfx if (ImpactVfx) { GameObject impactVfxInstance = Instantiate(ImpactVfx, point + (normal * ImpactVfxSpawnOffset), Quaternion.LookRotation(normal)); if (ImpactVfxLifetime > 0) { Destroy(impactVfxInstance.gameObject, ImpactVfxLifetime); } } // impact sfx if (ImpactSfxClip) { AudioUtility.CreateSFX(ImpactSfxClip, point, AudioUtility.AudioGroups.Impact, 1f, 3f); } // Self Destruct Destroy(this.gameObject);
-
射程、伤害衰减和穿墙
- 这部分的逻辑demo中都没有,但是可以看一下ProjectileStandard中的Update函数,这里会有子弹的运动逻辑和命中判定逻辑。
- 子弹的运动逻辑里有一部分是做TrajectoryCorrection弹道修正的。游戏中子弹原本是在武器的枪口处生成,这里是将子弹在限定距离TrajectoryCorrectionDistance内,逐步将子弹的弹道修正为从准心射出,更符合FPS的游戏的玩法:从表现上子弹应该是从枪口射出,从玩法上 子弹应该从准心处射出。读者们直接在 Projectile_Blaster.prefab 里把 TrajectoryCorrectionDistance 改成0就能看到,游戏中子弹是直接从准心处射出。
- demo中没有穿墙,而是在 Update 中每帧进行SphereCastAll,进行命中检测