535 lines
17 KiB
C#
535 lines
17 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using MegaKoop.Game.Combat;
|
|
using MegaKoop.Game.WeaponSystem;
|
|
using UnityEngine;
|
|
using Random = UnityEngine.Random;
|
|
|
|
namespace MegaKoop.Game.WeaponSystem
|
|
{
|
|
[DisallowMultipleComponent]
|
|
public class WeaponController : MonoBehaviour
|
|
{
|
|
[Header("Ownership")]
|
|
[SerializeField] private Team ownerTeam = Team.Heroes;
|
|
[SerializeField] private Transform weaponSocket;
|
|
|
|
[Header("Targeting")]
|
|
[SerializeField] private float acquisitionRadius = 30f;
|
|
[SerializeField] private float retargetInterval = 0.25f;
|
|
[SerializeField] private LayerMask targetMask = Physics.DefaultRaycastLayers;
|
|
[SerializeField] private LayerMask lineOfSightMask = Physics.DefaultRaycastLayers;
|
|
[SerializeField] private bool requireLineOfSight = true;
|
|
|
|
[Header("Loadout")]
|
|
[SerializeField] private WeaponDefinition[] startingWeapons;
|
|
[SerializeField] private bool autoFire = true;
|
|
|
|
private static readonly RaycastHit[] LineOfSightHitsBuffer = new RaycastHit[8];
|
|
|
|
private readonly List<WeaponRuntime> equippedWeapons = new();
|
|
private readonly HashSet<Collider> ignoredOwnerColliders = new();
|
|
|
|
public event Action<ProjectileSpawnEvent> ProjectileSpawned;
|
|
|
|
private float maxWeaponRange;
|
|
private float retargetTimer;
|
|
private TargetInfo currentTarget;
|
|
private bool hasTarget;
|
|
private Collider[] ownerColliders;
|
|
private Health ownerHealth;
|
|
|
|
private void Awake()
|
|
{
|
|
ownerHealth = GetComponent<Health>();
|
|
if (ownerHealth != null)
|
|
{
|
|
ownerTeam = ownerHealth.Team;
|
|
}
|
|
|
|
ownerColliders = GetComponentsInChildren<Collider>();
|
|
if (ownerColliders != null)
|
|
{
|
|
for (int i = 0; i < ownerColliders.Length; i++)
|
|
{
|
|
Collider ownerCollider = ownerColliders[i];
|
|
if (ownerCollider == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ignoredOwnerColliders.Add(ownerCollider);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
if (weaponSocket == null)
|
|
{
|
|
weaponSocket = transform;
|
|
}
|
|
|
|
if (startingWeapons != null)
|
|
{
|
|
foreach (WeaponDefinition definition in startingWeapons)
|
|
{
|
|
Equip(definition, weaponSocket);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (equippedWeapons.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (autoFire)
|
|
{
|
|
UpdateTargetSelection(Time.deltaTime);
|
|
}
|
|
|
|
TargetInfo target = hasTarget ? currentTarget : default;
|
|
|
|
foreach (WeaponRuntime weapon in equippedWeapons)
|
|
{
|
|
weapon.Tick(Time.deltaTime, target, this);
|
|
}
|
|
}
|
|
|
|
public void Equip(WeaponDefinition definition, Transform mount)
|
|
{
|
|
if (definition == null || mount == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
int weaponIndex = equippedWeapons.Count;
|
|
WeaponRuntime runtime = new WeaponRuntime(definition, mount, weaponIndex);
|
|
equippedWeapons.Add(runtime);
|
|
maxWeaponRange = Mathf.Max(maxWeaponRange, definition.Range);
|
|
}
|
|
|
|
public void Unequip(WeaponDefinition definition)
|
|
{
|
|
if (definition == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (int i = equippedWeapons.Count - 1; i >= 0; i--)
|
|
{
|
|
if (equippedWeapons[i].Definition == definition)
|
|
{
|
|
equippedWeapons[i].Dispose();
|
|
equippedWeapons.RemoveAt(i);
|
|
}
|
|
}
|
|
|
|
RecalculateMaxRange();
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
foreach (WeaponRuntime weapon in equippedWeapons)
|
|
{
|
|
weapon.Dispose();
|
|
}
|
|
|
|
equippedWeapons.Clear();
|
|
hasTarget = false;
|
|
}
|
|
|
|
private void UpdateTargetSelection(float deltaTime)
|
|
{
|
|
retargetTimer -= deltaTime;
|
|
|
|
if (hasTarget)
|
|
{
|
|
if (!currentTarget.IsValid)
|
|
{
|
|
hasTarget = false;
|
|
}
|
|
else
|
|
{
|
|
float distance = Vector3.Distance(transform.position, currentTarget.Transform.position);
|
|
float allowedRange = Mathf.Max(acquisitionRadius, maxWeaponRange);
|
|
if (distance > allowedRange * 1.1f)
|
|
{
|
|
hasTarget = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (retargetTimer > 0f && hasTarget)
|
|
{
|
|
return;
|
|
}
|
|
|
|
retargetTimer = retargetInterval;
|
|
AcquireTarget();
|
|
}
|
|
|
|
private void AcquireTarget()
|
|
{
|
|
float searchRadius = Mathf.Max(acquisitionRadius, maxWeaponRange);
|
|
if (searchRadius <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Collider[] hits = Physics.OverlapSphere(transform.position, searchRadius, targetMask, QueryTriggerInteraction.Collide);
|
|
TargetInfo bestTarget = default;
|
|
float bestScore = float.MaxValue;
|
|
|
|
foreach (Collider hit in hits)
|
|
{
|
|
if (hit == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (ignoredOwnerColliders.Contains(hit))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
IDamageable damageable = hit.GetComponentInParent<IDamageable>();
|
|
if (damageable == null || !damageable.IsAlive)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (damageable.Team == ownerTeam && ownerTeam != Team.Neutral)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (damageable is not Component component)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Transform targetTransform = component.transform;
|
|
float sqrDistance = (targetTransform.position - transform.position).sqrMagnitude;
|
|
if (sqrDistance > maxWeaponRange * maxWeaponRange)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float score = sqrDistance;
|
|
if (score < bestScore)
|
|
{
|
|
bestScore = score;
|
|
bestTarget = new TargetInfo(damageable, hit, targetTransform);
|
|
}
|
|
}
|
|
|
|
hasTarget = bestTarget.IsValid;
|
|
currentTarget = bestTarget;
|
|
}
|
|
|
|
private void RecalculateMaxRange()
|
|
{
|
|
maxWeaponRange = 0f;
|
|
for (int i = 0; i < equippedWeapons.Count; i++)
|
|
{
|
|
WeaponRuntime weapon = equippedWeapons[i];
|
|
weapon.SetIndex(i);
|
|
maxWeaponRange = Mathf.Max(maxWeaponRange, weapon.Definition.Range);
|
|
}
|
|
}
|
|
|
|
private Vector3 GetAimPoint(TargetInfo target)
|
|
{
|
|
if (!target.IsValid)
|
|
{
|
|
return Vector3.zero;
|
|
}
|
|
|
|
if (target.Collider != null)
|
|
{
|
|
return target.Collider.bounds.center;
|
|
}
|
|
|
|
return target.Transform.position;
|
|
}
|
|
|
|
private bool HasLineOfSight(Transform muzzle, Vector3 aimPoint, TargetInfo target)
|
|
{
|
|
if (!requireLineOfSight)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
Vector3 origin = muzzle.position;
|
|
Vector3 toTarget = aimPoint - origin;
|
|
float distance = toTarget.magnitude;
|
|
if (distance <= 0.01f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
Vector3 direction = toTarget.normalized;
|
|
int hitCount = Physics.RaycastNonAlloc(origin, direction, LineOfSightHitsBuffer, distance, lineOfSightMask, QueryTriggerInteraction.Ignore);
|
|
|
|
if (hitCount == 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
float targetDistance = float.MaxValue;
|
|
bool hasTargetHit = false;
|
|
|
|
for (int i = 0; i < hitCount; i++)
|
|
{
|
|
RaycastHit hit = LineOfSightHitsBuffer[i];
|
|
Collider hitCollider = hit.collider;
|
|
if (hitCollider == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (ignoredOwnerColliders.Contains(hitCollider))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (target.Collider != null && hitCollider == target.Collider)
|
|
{
|
|
targetDistance = hit.distance;
|
|
hasTargetHit = true;
|
|
continue;
|
|
}
|
|
|
|
if (hit.distance < targetDistance)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return hasTargetHit;
|
|
}
|
|
|
|
private void SpawnProjectile(WeaponRuntime weapon, Transform muzzle, Vector3 direction)
|
|
{
|
|
Projectile projectilePrefab = weapon.Definition.ProjectilePrefab;
|
|
if (projectilePrefab == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Quaternion rotation = direction.sqrMagnitude > 0f ? Quaternion.LookRotation(direction) : muzzle.rotation;
|
|
Projectile projectile = Instantiate(projectilePrefab, muzzle.position, rotation);
|
|
projectile.Initialize(direction, weapon.Definition.ProjectileSpeed, weapon.Definition.Damage, weapon.Definition.ProjectileLifetime, gameObject, ownerTeam, ownerColliders, weapon.Definition.HitMask);
|
|
|
|
ProjectileSpawned?.Invoke(new ProjectileSpawnEvent(weapon.Index, muzzle.position, direction, weapon.Definition.ProjectileSpeed, weapon.Definition.ProjectileLifetime, weapon.Definition.Damage));
|
|
}
|
|
|
|
public void SpawnNetworkProjectile(int weaponIndex, Vector3 position, Vector3 direction, float speed, float lifetime, float damage)
|
|
{
|
|
if (weaponIndex < 0 || weaponIndex >= equippedWeapons.Count)
|
|
{
|
|
return;
|
|
}
|
|
|
|
WeaponRuntime weapon = equippedWeapons[weaponIndex];
|
|
weapon.SpawnFromNetwork(position, direction, speed, lifetime, damage, ownerTeam, ownerColliders, gameObject);
|
|
}
|
|
|
|
private void FireWeapon(WeaponRuntime weapon, TargetInfo target, Transform muzzle)
|
|
{
|
|
Vector3 aimPoint = GetAimPoint(target);
|
|
Vector3 toTarget = aimPoint - muzzle.position;
|
|
if (toTarget.sqrMagnitude <= 0.0001f)
|
|
{
|
|
toTarget = muzzle.forward;
|
|
}
|
|
|
|
if (!HasLineOfSight(muzzle, aimPoint, target))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Vector3 baseDirection = toTarget.normalized;
|
|
int projectiles = weapon.Definition.ProjectilesPerShot;
|
|
float spread = weapon.Definition.SpreadAngle;
|
|
|
|
for (int i = 0; i < projectiles; i++)
|
|
{
|
|
Vector3 direction = baseDirection;
|
|
if (spread > 0f)
|
|
{
|
|
Vector2 offset = Random.insideUnitCircle * spread;
|
|
Quaternion spreadRotation = Quaternion.AngleAxis(offset.x, muzzle.up) * Quaternion.AngleAxis(offset.y, muzzle.right);
|
|
direction = spreadRotation * baseDirection;
|
|
}
|
|
|
|
SpawnProjectile(weapon, muzzle, direction);
|
|
}
|
|
|
|
weapon.ResetCooldown();
|
|
}
|
|
|
|
private readonly struct TargetInfo
|
|
{
|
|
public readonly IDamageable Damageable;
|
|
public readonly Collider Collider;
|
|
public readonly Transform Transform;
|
|
|
|
public TargetInfo(IDamageable damageable, Collider collider, Transform transform)
|
|
{
|
|
Damageable = damageable;
|
|
Collider = collider;
|
|
Transform = transform;
|
|
}
|
|
|
|
public bool IsValid => Damageable != null && Damageable.IsAlive && Transform != null;
|
|
}
|
|
|
|
private sealed class WeaponRuntime
|
|
{
|
|
private readonly Transform mountPoint;
|
|
private readonly Transform fallbackMuzzle;
|
|
private readonly Transform[] muzzles;
|
|
private int muzzleIndex;
|
|
private float cooldown;
|
|
|
|
public WeaponDefinition Definition { get; }
|
|
public WeaponView ViewInstance { get; }
|
|
public int Index { get; private set; }
|
|
|
|
public WeaponRuntime(WeaponDefinition definition, Transform mount, int index)
|
|
{
|
|
Definition = definition ?? throw new ArgumentNullException(nameof(definition));
|
|
mountPoint = mount;
|
|
Index = index;
|
|
|
|
if (definition.ViewPrefab != null)
|
|
{
|
|
ViewInstance = UnityEngine.Object.Instantiate(definition.ViewPrefab, mountPoint);
|
|
ViewInstance.transform.localPosition = Vector3.zero;
|
|
ViewInstance.transform.localRotation = Quaternion.identity;
|
|
ViewInstance.transform.localScale = Vector3.one;
|
|
muzzles = ViewInstance.Muzzles;
|
|
}
|
|
else
|
|
{
|
|
ViewInstance = null;
|
|
muzzles = Array.Empty<Transform>();
|
|
}
|
|
|
|
fallbackMuzzle = mountPoint;
|
|
muzzleIndex = 0;
|
|
cooldown = 0f;
|
|
}
|
|
|
|
public void Tick(float deltaTime, TargetInfo target, WeaponController controller)
|
|
{
|
|
cooldown = Mathf.Max(0f, cooldown - deltaTime);
|
|
if (cooldown > 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!target.IsValid)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Transform muzzle = GetMuzzle();
|
|
if (muzzle == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Vector3 aimPoint = controller.GetAimPoint(target);
|
|
Vector3 toTarget = aimPoint - muzzle.position;
|
|
float sqrDistance = toTarget.sqrMagnitude;
|
|
float maxRange = Definition.Range;
|
|
if (sqrDistance > maxRange * maxRange)
|
|
{
|
|
return;
|
|
}
|
|
|
|
controller.FireWeapon(this, target, muzzle);
|
|
}
|
|
|
|
public Transform GetMuzzle()
|
|
{
|
|
if (muzzles != null && muzzles.Length > 0)
|
|
{
|
|
for (int i = 0; i < muzzles.Length; i++)
|
|
{
|
|
int current = (muzzleIndex + i) % muzzles.Length;
|
|
Transform muzzle = muzzles[current];
|
|
if (muzzle != null)
|
|
{
|
|
muzzleIndex = (current + 1) % muzzles.Length;
|
|
return muzzle;
|
|
}
|
|
}
|
|
}
|
|
|
|
return fallbackMuzzle;
|
|
}
|
|
|
|
public void ResetCooldown()
|
|
{
|
|
cooldown = Definition.FireInterval;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (ViewInstance != null)
|
|
{
|
|
UnityEngine.Object.Destroy(ViewInstance.gameObject);
|
|
}
|
|
}
|
|
|
|
public void SetIndex(int newIndex)
|
|
{
|
|
Index = newIndex;
|
|
}
|
|
|
|
public void SpawnFromNetwork(Vector3 position, Vector3 direction, float speed, float lifetime, float damage, Team ownerTeam, Collider[] ownerColliders, GameObject owner)
|
|
{
|
|
Projectile projectilePrefab = Definition.ProjectilePrefab;
|
|
if (projectilePrefab == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Quaternion rotation = direction.sqrMagnitude > 0f ? Quaternion.LookRotation(direction) : fallbackMuzzle.rotation;
|
|
Projectile projectile = UnityEngine.Object.Instantiate(projectilePrefab, position, rotation);
|
|
float resolvedSpeed = speed > 0f ? speed : Definition.ProjectileSpeed;
|
|
float resolvedLifetime = lifetime > 0f ? lifetime : Definition.ProjectileLifetime;
|
|
float resolvedDamage = damage > 0f ? damage : Definition.Damage;
|
|
projectile.Initialize(direction, resolvedSpeed, resolvedDamage, resolvedLifetime, owner, ownerTeam, ownerColliders, Definition.HitMask);
|
|
}
|
|
}
|
|
|
|
public readonly struct ProjectileSpawnEvent
|
|
{
|
|
public readonly int WeaponIndex;
|
|
public readonly Vector3 Position;
|
|
public readonly Vector3 Direction;
|
|
public readonly float Speed;
|
|
public readonly float Lifetime;
|
|
public readonly float Damage;
|
|
|
|
public ProjectileSpawnEvent(int weaponIndex, Vector3 position, Vector3 direction, float speed, float lifetime, float damage)
|
|
{
|
|
WeaponIndex = weaponIndex;
|
|
Position = position;
|
|
Direction = direction;
|
|
Speed = speed;
|
|
Lifetime = lifetime;
|
|
Damage = damage;
|
|
}
|
|
}
|
|
}
|
|
}
|