上期将基础的移动系统搭建完毕后就可以开始搭建更加复杂的系统部分了
前排提示,由于一开始仅思考如何完成操作相关功能,以至于到后面重构稍微有些困难,继续写下去恐成屎山,故在搭完射击和武器UI后不再继续泛化到敌人和敌人状态机
本次主要完成了
自由配置武器参数:武器所需的所有参数都可进行调整
武器的追随准星:
根据输入的武器槽位自动平滑的跟随指定武器的枪线,换弹时也会有相应提示
武器的切换:
不同武器根据数字键进行切换,在UI和准星上也会有所体现
第一第三人称的切换:
可以在第一第三人称间无缝切换
请看VCR!
Unity机甲2
总览
类图结构
武器系统
武器使用状态机进行实现,WeaponState继承自State,内部持有Weapon的引用
Weapon是挂载在玩家身上的主要类,一个武器对应一个Weapon,手动输入索引标识其所属的武器槽位
public class Weapon : MonoBehaviour
{
public enum FireMode
{
Single,
Brust,
Auto
}
private Entity owner;
public int weaponSelectIndex = 0;
public bool selected { get; private set; } = false;
[Header("武器性能")]
[SerializeField] private int maxAmmo = 1; //最大弹匣弹药量
[SerializeField] private int maxPrepareAmmo = 16; //最大后备弹药量
[SerializeField] private float shootingInterval = 0.1f; //射击间隔
[SerializeField] public float reloadTime = 2; //换弹时间
[Header("开火模式")]
[SerializeField] public FireMode fireMode = FireMode.Single; //开火模式
[SerializeField] private int brustNum = 3; //brust一次开火射出的子弹数
[SerializeField] private float brustTime = 1; //两次brust开火之间的间隔
//[Header("武器状态参数")]
private int curAmmo; //当前弹药量
private int curPrepareAmmo; //当前后备弹药量
private float shootingIntervalTimer = 0; //射击间隔计时器
private float brustTimeTimer = 0; //brust射击间隔计时器
private int brustCounter; //brust计数器
[Header("发射物")]
[SerializeField] public Transform fireSocket;
[SerializeField] private GameObject bulletPrefab;
public float bulletVelocity = 100;
public float inertialVelocityMultipler = 10;
public bool constantSpeed = false;
//本来想做个委托外包出去,想了想不如直接集成在类里得了
[Header("特效效果")]
[SerializeField] private GameObject fireFX;
private CinemachineImpulseSource impulseSource;
public float cameraShakeMultipler = 1f;
[Header("音频")]
[SerializeField] private AudioClip fireSound;
[SerializeField] private AudioClip reloadSound;
[SerializeField] private float soundMultipler = 1f;
//玩家的输入对应的委托转发
public UnityAction onFireStart, onFiring, onFireEnd, onReload;
//换弹时的委托,与UI通信使用
public UnityAction onReloadStart, onReloadEnd;
public UnityAction<float> onReloading;
public UnityAction<int, int> onAmmoChanged;
//是否选中
public UnityAction<bool> onSelectChanged;
//自己的状态机
private StateMachine stateMachine = new StateMachine();
public WeaponIdleState idleState;
public WeaponFireState fireState;
public WeaponReloadState reloadState;
private void Awake()
{
owner = GetComponent<Entity>();
impulseSource = fireSocket.GetComponent<CinemachineImpulseSource>();
if (weaponSelectIndex == 1)
selected = true;
//初始化数据
curAmmo = maxAmmo;
curPrepareAmmo = maxPrepareAmmo;
brustTimeTimer = brustTime;
brustCounter = brustNum;
//状态初始化
idleState = new WeaponIdleState(stateMachine, this);
fireState = new WeaponFireState(stateMachine, this);
reloadState = new WeaponReloadState(stateMachine, this);
//自身赋值到Controller方便其他组件引用
PlayerController.Ins.weapons[weaponSelectIndex] = this;
}
private void Start()
{
//玩家操作本Weapon
owner.onFireStart += () => onFireStart?.Invoke();
owner.onFireEnd += () => onFireEnd?.Invoke();
owner.onReload += () => onReload?.Invoke();
owner.onSelect += (num) =>
{
selected = num == weaponSelectIndex;
onSelectChanged?.Invoke(selected);
};
owner.onAllSelect += () =>
{
selected = true;
onSelectChanged?.Invoke(selected);
};
//初始化状态机
stateMachine.Init(idleState);
}
private void Update()
{
stateMachine.Update();
if (shootingIntervalTimer > 0)
shootingIntervalTimer -= Time.deltaTime;
if (brustCounter <= 0 && brustTimeTimer > 0)
{
brustTimeTimer -= Time.deltaTime;
if (brustTimeTimer <= 0)
brustCounter = brustNum;
}
if(owner.firing)
onFiring?.Invoke();
}
public void ModifyAmmo(int amount)
{
curAmmo += amount;
curAmmo = Mathf.Clamp(curAmmo, 0, maxAmmo);
onAmmoChanged?.Invoke(curAmmo, curPrepareAmmo);
}
public void Fire()
{
if (!CanFire())
return;
//发射投射物
Bullet bullet = Instantiate(bulletPrefab, fireSocket.position, fireSocket.rotation).GetComponent<Bullet>();
bullet.Init(bulletVelocity, constantSpeed, new Vector3(owner.velocity.x, 0, owner.velocity.z) * inertialVelocityMultipler, owner.flag);
//数据更新
ModifyAmmo(-1);
shootingIntervalTimer = shootingInterval;
if (fireMode == FireMode.Brust) //如果是Brust模式
{
brustCounter--;
if (brustCounter <= 0)
brustTimeTimer = brustTime;
}
//播放枪口特效
Instantiate(fireFX, fireSocket.position, fireSocket.rotation);
//震动!
impulseSource.m_DefaultVelocity.x = Random.Range(-1f, 1f) * cameraShakeMultipler;
impulseSource.m_DefaultVelocity.y = Random.Range(-1f, 1f) * cameraShakeMultipler;
impulseSource.m_DefaultVelocity.z = Random.Range(-1f, 1f) * cameraShakeMultipler;
impulseSource.GenerateImpulse();
//枪口音效
AudioManager.PlayClipAtPoint(fireSound, fireSocket.position, soundMultipler);
}
public void Reload()
{
int needAmmo = maxAmmo - curAmmo;
curAmmo = Mathf.Min(curPrepareAmmo, maxAmmo);
curPrepareAmmo = Mathf.Max(curPrepareAmmo - needAmmo, 0);
onAmmoChanged?.Invoke(curAmmo, curPrepareAmmo);
AudioManager.PlayClipAtPoint(reloadSound, owner.transform.position, soundMultipler);
}
public bool HaveAmmo() => curAmmo > 0;
public bool HavePrepareAmmo() => curPrepareAmmo > 0;
public bool CanFire()
{
if (!selected)
return false;
//没有子弹
if (!HaveAmmo())
return false;
//没有结束冷却
if (shootingIntervalTimer > 0)
return false;
//如果在Brust模式
//如果Counter小于等于0,说明打完了,否则不管
//如果打完了并且还没过brust冷却,那就不能打
if (fireMode == FireMode.Brust && brustCounter <= 0 && brustTimeTimer > 0)
return false;
return true;
}
public bool CanBrust() => brustCounter > 0;
public float GetReloadTime() => reloadTime;
public int GetCurAmmo() => curAmmo;
public int GetCurPrepareAmmo() => curPrepareAmmo;
public bool CanReload() => curPrepareAmmo > 0 && curAmmo < maxAmmo;
public State GetCurState() => stateMachine.curState;
}
武器的主要逻辑存在于状态机中
但是由于武器的开火分为连发,单发,爆发,因此还需要做一些特殊的处理
以下是WeaponFireState
在使用爆发模式时,即使玩家松开开火键也不能立即停止开火
public class WeaponFireState : WeaponCommonState
{
public bool readyEnd;
public WeaponFireState(StateMachine stateMachine, Weapon weapon) : base(stateMachine, weapon)
{
}
public override void Enter()
{
base.Enter();
weapon.onFireEnd += OnFireEnd;
FireOrReload();
readyEnd = false;
}
public override void Exit()
{
base.Exit();
weapon.onFireEnd -= OnFireEnd;
}
private void OnFireEnd()
{
if (weapon.fireMode != Weapon.FireMode.Brust)
stateMachine.ChangeState(weapon.idleState);
readyEnd = true;
}
public override void Update()
{
base.Update();
//爆发模式
if (weapon.fireMode == Weapon.FireMode.Brust)
{
if (weapon.CanBrust())
FireOrReload();
else if(readyEnd)
stateMachine.ChangeState(weapon.idleState);
}
else if (weapon.fireMode == Weapon.FireMode.Auto)
FireOrReload();
}
private void FireOrReload()
{
if (weapon.HaveAmmo())
{
weapon.Fire();
if (!weapon.HaveAmmo())
stateMachine.ChangeState(weapon.reloadState);
}
else
stateMachine.ChangeState(weapon.reloadState);
}
}
其他部分较为简短不特别描述
子弹
子弹同样使用一个通用的类进行配置
public class Bullet : MonoBehaviour
{
[Header("Physical")]
private Rigidbody rb;
private Vector3 lastPosition;
[Header("Attribute")]
//子弹所属派系,可以设定是否开启友军伤害,-1为中立派系
public int flag = -1;
public float velocity = 100;
public bool constantSpeed = false;
public float lifeTime = 6f;
private float lifeTimer;
public float gravityMultiper = 1f;
[Header("VFX")]
public GameObject explosionPrefab;
public GameObject trailPrefab;
private void Awake()
{
rb = GetComponent<Rigidbody>();
lastPosition = transform.position;
lifeTimer = lifeTime;
}
public void Init(float velocity, bool constantSpeed, Vector3 inertialVelocity /*惯性力*/, int flag = -1)
{
this.velocity = velocity;
this.constantSpeed = constantSpeed;
rb.velocity += transform.forward * velocity * Time.fixedDeltaTime / rb.mass;
rb.AddForce(transform.forward * velocity + inertialVelocity, ForceMode.Impulse);
this.flag = flag;
}
private void Update()
{
lifeTimer -= Time.deltaTime;
if (lifeTimer < 0)
OnCollisionEnter(null);
}
void FixedUpdate()
{
//防止错过刚体,对即将经过的间隔做一个射线检测
if (Physics.Raycast(lastPosition, rb.velocity.normalized, out RaycastHit hitInfo, rb.velocity.magnitude * Time.fixedDeltaTime))
{
transform.position = hitInfo.point;
rb.velocity = Vector3.zero;
return;
}
//持久动力
if (constantSpeed)
rb.AddForce(transform.forward * velocity);
//调整旋转朝向
transform.forward = rb.velocity.normalized;
//应用重力乘数
rb.velocity += new Vector3(0, 9.8f * (1 - gravityMultiper) * Time.fixedDeltaTime, 0);
//记录位置
lastPosition = transform.position;
}
private void OnCollisionEnter(Collision collision)
{
if(collision != null && collision.gameObject.TryGetComponent(out Entity entity))
{
if (entity.flag == flag)
return;
}
if (trailPrefab)
{
trailPrefab.transform.parent = null;
var particleSystems = trailPrefab.GetComponentsInChildren<ParticleSystem>();
foreach (var particle in particleSystems)
{
var main = particle.main;
main.loop = false;
}
}
Instantiate(explosionPrefab, transform.position, Quaternion.identity);
Destroy(gameObject);
}
private void DelayTrail()
{
trailPrefab.SetActive(true);
}
}
其内部包含初始惯性处理,持续动力,防止高速穿过物体的处理以及视觉和销毁时如果有拖尾的处理
第一第三人称转换
这一块比较简单,直接使用Cinemachine自带的混合,代码只需要控制两个虚拟相机的激活即可
public class PlayerCameraController : MonoBehaviour
{
//Third Person Camera
[SerializeField] private CinemachineVirtualCamera thirdPersonCamera;
private Cinemachine3rdPersonFollow thirdCameraBody;
public float freeLookSide = 0;
public float freeLookDistance = 20;
float cameraSide;
float cameraDistance;
//First Peroson Camera
[SerializeField] private CinemachineVirtualCamera firstPersonCamera;
private Cinemachine3rdPersonFollow firstCameraBody;
private Quaternion lastQuaternion;
private void Awake()
{
thirdCameraBody = thirdPersonCamera.GetCinemachineComponent<Cinemachine3rdPersonFollow>();
cameraSide = thirdCameraBody.CameraSide;
cameraDistance = thirdCameraBody.CameraDistance;
firstCameraBody = firstPersonCamera.GetCinemachineComponent<Cinemachine3rdPersonFollow>();
}
// Update is called once per frame
void Update()
{
HandleFreeLook();
HandleSwitchView();
}
private void HandleFreeLook()
{
if (Input.GetKeyDown(KeyCode.C))
{
//控制器冻结
lastQuaternion = PlayerController.GetControllerRotation();
PlayerController.SetPause(true);
//第三人称参数
thirdCameraBody.CameraSide = freeLookSide;
thirdCameraBody.CameraDistance = freeLookDistance;
//隐藏准星(有视觉Bug)
foreach(var hair in UIManager.Ins.crossHairs)
{
hair.gameObject.SetActive(false);
}
}
if (Input.GetKeyUp(KeyCode.C))
{
//控制器恢复
PlayerController.SetControllerRotation(lastQuaternion);
PlayerController.SetPause(false);
//第三人称参数
thirdCameraBody.CameraSide = cameraSide;
thirdCameraBody.CameraDistance = cameraDistance;
//显示UI
foreach (var hair in UIManager.Ins.crossHairs)
{
hair.gameObject.SetActive(PlayerController.Ins.weapons[hair.weaponIndex].selected);
}
}
}
private void HandleSwitchView()
{
if (Input.GetKeyDown(KeyCode.V))
{
firstPersonCamera.gameObject.SetActive(!firstPersonCamera.gameObject.activeSelf);
thirdPersonCamera.gameObject.SetActive(!thirdPersonCamera.gameObject.activeSelf);
}
}
}
UI
比较重要的地方是丝滑的UI跟随以及实时的武器栏
后者只需要在制作时留意委托就可以很方便的调用,前者则需要一些不同空间的变换知识
准星的跟随部分
一开始我在Canvas中选择的渲染模式是覆盖,后来发现在覆盖的模式下不能添加自发光,导致UI较暗,于是调整为了摄像机空间,但调整后导致原本跟踪正确的准星又不再正确,下面是解决办法
public class CrossHairUI : MonoBehaviour
{
//自身
private RectTransform rect, parent;
[SerializeField] private GameObject aimHair, reloadHair;
[SerializeField] private TextMeshPro reloadTxt;
//武器
public int weaponIndex = 0;
private Weapon weapon;
private Transform fireSocket;
public float lerpMultipler = 0.1f;
void Start()
{
rect = GetComponent<RectTransform>();
parent = rect.parent.GetComponent<RectTransform>();
weapon = PlayerController.Ins.weapons[weaponIndex];
fireSocket = weapon.fireSocket;
weapon.onReloadStart += OnReloadStart;
weapon.onReloading += OnReloading;
weapon.onReloadEnd += OnReloadEnd;
weapon.onSelectChanged += (selected) =>
{
gameObject.SetActive(selected);
};
gameObject.SetActive(weapon.selected);
}
void Update()
{
if (Physics.SphereCast(fireSocket.position, .5f, fireSocket.forward, out RaycastHit hitInfo, 1000))
{
if(RectTransformUtility.ScreenPointToLocalPointInRectangle(parent, RectTransformUtility.WorldToScreenPoint(Camera.main, hitInfo.point), Camera.main, out Vector2 localPoint))
{
rect.localPosition = Vector2.Lerp(rect.localPosition, localPoint, lerpMultipler);
}
}
else
{
Vector3 point = fireSocket.position + fireSocket.forward * 3000;
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(parent, RectTransformUtility.WorldToScreenPoint(Camera.main, point), Camera.main, out Vector2 localPoint))
{
rect.localPosition = Vector2.Lerp(rect.localPosition, localPoint, lerpMultipler);
}
}
}
private void OnReloadStart()
{
aimHair.SetActive(false);
reloadHair.SetActive(true);
}
private void OnReloading(float remainTime)
{
reloadTxt.SetText(remainTime.ToString("0.00"));
}
private void OnReloadEnd()
{
aimHair.SetActive(true);
reloadHair.SetActive(false);
}
}
在Update中首先SphereCast来获取击中的点,再将其WorldToScreenPoint变换到屏幕空间,如果是覆盖的渲染模式,此时已经结束了,但由于是摄像机模式,因此需要再多一个变换即ScreenPointToLocalPointInRectangle将其变换到面板上的相对位置。之后使用插值即可实现丝滑的跟踪准