using System; using UnityEngine; using static AffectingForcesManager; using ShipHandling; using Managers; using Unity.Mathematics; using FORGE3D; using PrimeTween; using log4net; using System.Reflection; using System.Collections.Generic; using static ShipSound; public class Ship : MonoBehaviour, IHUDOwner, IDamageable { private static ILog Log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); public event Action BoostUpdated; public event Action LifeUpdated; public int InstanceID { get; private set; } public ShipProperties Props; public ShipState State; public ShipInput Input; // Private variables public CameraOperator CameraOperator; public ParticleSystem BoostEffect; public ParticleSystem GravityEffect; public ParticleSystem JetFlameEffect; public ParticleSystem SmokeTrailEffect; public DamageNumberParticles DamageParticleEffect; public MeshRenderer BodyMeshRenderer; private F3DFXController _fireController; private AffectingForcesManager _forceManager; public Rigidbody _body; // Saves the current input value for thrust private bool _canBoost = true; private HitDetection[] _tackleDetectors; private bool _isCriticalTackle = false; private bool _isTackled = false; private float _tackledTime = 0f; private float _lastTackleTime = 0f; private readonly float _minHitDelay = 0.06f; private float _lastHitTime = 0f; // Upcoming zone change private Zone newZone = Zone.NimbleZone; private WeaponEffect equippedWeapon = WeaponEffect.None; private Dictionary sounds = new(); void Awake() { if (_forceManager == null) { _forceManager = GameObject.FindGameObjectWithTag("ForceManager"). GetComponent(); } _body = GetComponent(); _fireController = GetComponent(); } void Start() { InstanceID = gameObject.GetInstanceID(); State.BoostCapacity = Props.MaxBoostCapacity; State.RemainingHealth = Props.MaximumHealth; // Get manageable audio instances for the ships sounds foreach (ShipSoundToName stn in Props.Audio.shipSounds) { ManageableAudio ma = AudioManager.G.GetLocalSound(stn.soundName, 1, gameObject.transform); sounds[stn.sound] = ma; } // Register the ship with the camera CameraOperator.AddCharacter(gameObject); // Connect the tackling/tackled logic to the ships detection components _tackleDetectors = GetComponentsInChildren(); foreach (HitDetection td in _tackleDetectors) { td.TackledResponse += TackledResponse; td.TacklingResponse += TacklingResponse; td.HitResponse += HitResponse; } LifeUpdated?.Invoke(1); BoostUpdated?.Invoke(1); } void OnDestroy() { foreach (HitDetection td in _tackleDetectors) { td.TackledResponse -= TackledResponse; td.TacklingResponse -= TacklingResponse; td.HitResponse -= HitResponse; } BoostUpdated = null; LifeUpdated = null; } // Update is called once per frame void FixedUpdate() { newZone = _forceManager.GetZoneOfInstance(InstanceID); // TODO: This could be more elegant maybe? if (MatchManager.G.matchState != MatchState.Match || State.IsFrozen) { _body.constraints = RigidbodyConstraints.FreezeAll; UpdateSounds(); UpdateFireWeapon(equippedWeapon); State.Zone = newZone; return; } _body.constraints = RigidbodyConstraints.None; UpdateSounds(); if (State.Zone != newZone) { State.Zone = newZone; } UpdateMovement(); BoostStateUpdate(Time.deltaTime); UpdateTackleResponse(_isCriticalTackle); UpdateFireWeapon(equippedWeapon); } void UpdateFireWeapon(WeaponEffect weapon) { // Stop firing if (State.IsFiring && Input.shootInput < 1 || State.IsFiring && MatchManager.G.matchState != MatchState.Match) { State.IsFiring = false; _fireController.Stop(); } if (weapon == WeaponEffect.None) { return; } if (_fireController.SelectedWeaponEffect != weapon) { _fireController.SelectedWeaponEffect = weapon; } if (!State.IsFiring && Input.shootInput == 1) { State.IsFiring = true; _fireController.Fire(); } } /// /// Movement logic and simulation of the ship. /// void UpdateMovement() { // Player rotation is always possible and same speed float current_angle = transform.localEulerAngles.z; Vector2 radial = -Input.radialInput; float goal_angle = Vector2.SignedAngle(Vector2.up, radial) + 180; float inputThrust = 0; if (radial.magnitude > 0.05) { float angle_difference = ((goal_angle - current_angle + 180) % 360) - 180; angle_difference = angle_difference < -180 ? angle_difference + 360 : angle_difference; float sign = math.sign(angle_difference); float rotation = Mathf.Min(math.abs(angle_difference), Props.SteerVelocity * radial.magnitude * Time.deltaTime); transform.Rotate(0, 0, sign * rotation); } else { transform.Rotate(0, 0, Input.steerInput * -Props.SteerVelocity * Time.deltaTime); } inputThrust = Input.thrustInput > inputThrust ? Input.thrustInput : inputThrust; // Get and apply the current Gravity Transform gravitySource = _forceManager.GetGravitySourceForInstance(InstanceID); State.CurrentGravity = _forceManager.GetGravityForInstance(InstanceID)(gravitySource, transform) * Props.GravitStrength; _body.AddForce(State.CurrentGravity, ForceMode.Acceleration); float stunFactor = _isCriticalTackle ? Props.StunLooseControlFactor : 1f; float thrust = IsBoosting() ? 1f : inputThrust; Vector3 acceleration = Props.ThrustAcceleration * thrust * Time.deltaTime * transform.up * stunFactor; Vector3 currentVelocity = _body.velocity; Vector3 boostedAcceleration = BoostAcceleration(acceleration, State.CurrentGravity); if (!_isCriticalTackle) { // Add drag if (State.Zone == Zone.NimbleZone) { Vector3 dragDecceleration = DragDecceleration(currentVelocity, State.Zone); _body.AddForce(dragDecceleration, ForceMode.Acceleration); if (!_isTackled) { // Add anti drift acceleration Vector3 driftDampeningAcceleration = DriftDampeningAcceleration(currentVelocity, State.Zone); _body.AddForce(driftDampeningAcceleration, ForceMode.Acceleration); } } if (currentVelocity.magnitude <= Props.NormalMaxVelocity || IsBoosting() || State.Zone != Zone.NimbleZone) { _body.AddForce(boostedAcceleration, ForceMode.Acceleration); } if (currentVelocity.magnitude >= Props.AbsolutMaxVelocity && State.Zone == Zone.NimbleZone) { _body.velocity = _body.velocity.normalized * Props.AbsolutMaxVelocity; } } // Default torque drag _body.AddRelativeTorque(_body.angularVelocity * -Props.TorqueDrag, ForceMode.Acceleration); Debug.DrawRay(transform.position, transform.up * (currentVelocity.magnitude + 3) * 0.5f, Color.black); // Fix the ship to the virtual 2D plane of the game transform.localEulerAngles = new Vector3(0, 0, transform.localEulerAngles.z); _body.transform.localPosition = new Vector3(_body.transform.localPosition.x, _body.transform.localPosition.y, 0); } /// /// Calculates a vector to mitigate the ship drifting when it's changing direction. /// /// Current velocity of the ship /// Zone which the ship is in /// Vector3 DriftDampeningAcceleration(Vector3 currentVelocity, Zone zone) { Vector3 antiDriftVelocity; float antiDriftFactor; // Cancel out inertia/drifting Vector3 up = transform.up; Vector3 driftVelocity = currentVelocity - Vector3.Project(currentVelocity, up); if (driftVelocity.magnitude < 0.1) { return Vector3.zero; } antiDriftVelocity = Vector3.Reflect(-driftVelocity, up) - driftVelocity; antiDriftFactor = Mathf.InverseLerp(Props.AbsolutMaxVelocity, Props.NormalMaxVelocity, currentVelocity.magnitude); antiDriftFactor = Mathf.Max(antiDriftFactor, Props.MinAntiDriftFactor); Debug.DrawRay(transform.position, currentVelocity.normalized * currentVelocity.magnitude * 2, Color.cyan); Debug.DrawRay(transform.position, driftVelocity.normalized * 5, Color.red); Debug.DrawRay(transform.position, antiDriftVelocity.normalized * 5, Color.green); return antiDriftVelocity * Props.AntiDriftAmount * antiDriftFactor; } /// /// Calculates drag on the ship depending on it's velocity and inhabited zone. /// /// Velocity of the ship /// Zone which the ship is in /// Vector3 DragDecceleration(Vector3 currentVelocity, Zone zone) { Vector3 drag = new Vector3(); float minDragFactor = Mathf.InverseLerp(Props.AbsolutMaxVelocity, Props.NormalMaxVelocity, currentVelocity.magnitude); float normalDragFactor = Mathf.InverseLerp(Props.NormalMaxVelocity, 0, currentVelocity.magnitude); if (!IsBoosting() && zone == Zone.NimbleZone) { drag -= currentVelocity.normalized * Props.NormalDrag; } if (currentVelocity.magnitude >= Props.NormalMaxVelocity && zone == Zone.NimbleZone) { drag -= currentVelocity.normalized * Props.MaximumDrag; } return drag; } /// /// Is the boost input pressed and boosting possible? /// /// Boosting state bool IsBoosting() { return Input.boostInput > 0 && _canBoost; } /// /// Applies boost to an acceleration vector. /// This includes increasing acceleration and mitigating /// the gravity. /// /// Current acceleration vector /// Gravity vector which is in force /// Vector3 BoostAcceleration(Vector3 acceleration, Vector3 currentGravity) { if (IsBoosting()) { acceleration *= Props.BoostMagnitude; acceleration -= currentGravity * Props.BoostAntiGravityFactor; } return acceleration; } /// /// Logic which depletes boost capacity when boost conditions are met. /// /// Time delta of the current frame void BoostStateUpdate(float deltaTime) { BoostUpdated?.Invoke(State.BoostCapacity / Props.MaxBoostCapacity); if (IsBoosting()) { State.BoostCapacity -= deltaTime; } if (_canBoost && State.Zone == Zone.OutsideZone) { State.BoostCapacity -= deltaTime * Props.OutsideBoostRate; } if (State.BoostCapacity <= 0) { _canBoost = false; } if ((Input.boostInput <= 0 || !_canBoost) && State.Zone == Zone.NimbleZone && State.BoostCapacity <= Props.MaxBoostCapacity) { State.BoostCapacity += deltaTime; } // When your boost capacity is still critical, you can't start boosting immediately again. // TODO: This is not tested well enough with players. if (_canBoost == false && State.BoostCapacity >= Props.MinBoostCapacity) { _canBoost = true; } } /// /// Logic which sets the tackled member variables and /// updates them over time. /// State logic depends on these variables and is responsible /// for certain tackle behavior. /// /// Use true to process a tackle hit void UpdateTackleResponse(bool gotTackled = false) { if (gotTackled && !_isTackled) { _isTackled = true; _tackledTime = _isCriticalTackle ? Props.TackledCriticalStunTime : Props.TackledBodyStunTime; return; } _tackledTime -= Time.deltaTime; if (_tackledTime <= 0) { _isTackled = false; _isCriticalTackle = false; _tackledTime = 0; } } /// /// Response logic if the ship is tackling an opponend. /// void TacklingResponse() { if (IgnoreTackles()) return; Log.Debug($"{Props.ShipName} is tackling."); } bool IgnoreTackles() { if (Time.time < _lastTackleTime + Props.TackledGraceTime) { return true; } _lastTackleTime = Time.time; return false; } /// /// Called by the collision regions of the ship being tackled by an opponent. /// Adds resulting forces to the ship and intiates the tackle response. /// /// Kind of the tackle. Depends on collision region. /// Object which has collided with the collision region. void TackledResponse(TackleKind tackleKind, Collider collider) { if (IgnoreTackles()) return; float damage = 0; float tacklePowerFactor = Props.CriticalTacklePowerFactor; if (tackleKind == TackleKind.IncomingCritical) { _isCriticalTackle = true; damage = 450; Log.Debug($"{Props.ShipName} has been tackled critically."); } else if (tackleKind == TackleKind.IncomingNormal) { _isCriticalTackle = false; damage = 100; tacklePowerFactor = Props.NormalTacklePowerFactor; Log.Debug($"{Props.ShipName} has been tackled."); } Vector3 colliderVelocity = collider.attachedRigidbody.velocity - _body.velocity; Vector3 force = colliderVelocity * tacklePowerFactor; Vector3 resultForce = force / Math.Max(force.magnitude / 4000, 1); resultForce = resultForce / Math.Max(0.001f, Math.Min(resultForce.magnitude / 500, 1)); Log.Debug(resultForce.magnitude); _body.AddForce(resultForce, ForceMode.Acceleration); InflictDamage(damage); DamageParticleEffect.SpawnDamageNumber((int)damage, colliderVelocity / 2); UpdateTackleResponse(true); } public void HitResponse(HitKind hitKind, ProjectileDamage damage) { if (Time.time < _lastHitTime + _minHitDelay) { return; } _lastHitTime = Time.time; InflictDamage(damage.DamageValue); Log.Info("particle spawned"); DamageParticleEffect.SpawnDamageNumber((int)damage.DamageValue, damage.ImpactDirection); _body.AddForce(damage.ImpactDirection * damage.ImpactMagnitude, ForceMode.Impulse); if ((this as IDamageable).IsKilled()) { MatchManager.G.UpdateMatchCondition(new MatchConditionUpdate { Condition = WinCondition.Lives, Ship = this, Count = -1 }); } } void UpdateSounds() { if (MatchManager.G.matchState != MatchState.Match || State.IsFrozen) { if (newZone != State.Zone && newZone == Zone.NimbleZone) { AudioManager.G.BroadcastAudioEffect(AudioEffects.LowPass, transform, false); } sounds[Thruster].StopAudio(); GravityEffect.Clear(); GravityEffect.Stop(); return; } float velocityFactor = math.smoothstep(0, Props.AbsolutMaxVelocity, _body.velocity.magnitude); if (math.abs(Input.thrustInput) > 0 || IsBoosting()) { sounds[Thruster].PlayAudio(true); sounds[Thruster].ChangePitch(velocityFactor); if (!JetFlameEffect.isPlaying) JetFlameEffect.Play(); } else { sounds[Thruster].FadeOutAudio(0.3f); JetFlameEffect.Stop(); } if (IsBoosting()) { if (!BoostEffect.isPlaying) BoostEffect.Play(); if (!SmokeTrailEffect.isPlaying) SmokeTrailEffect.Play(); if (JetFlameEffect.isPlaying) JetFlameEffect.transform.localScale = new Vector3(1.3f, 2, 1); sounds[Booster].PlayAudio(false, 0.1f, true); } else { sounds[Booster].ResetOneShot(); SmokeTrailEffect.Stop(); JetFlameEffect.transform.localScale = new Vector3(1.3f, 1, 1); } if (_isTackled && !_isCriticalTackle) { sounds[Tackling].PlayAudio(false, 0, true); CameraOperator.ShakeCam(0.2f); } if (_isCriticalTackle) { sounds[TacklingCritical].PlayAudio(false, 0, true); CameraOperator.ShakeCam(0.4f); } if (!_isTackled) { sounds[TacklingCritical].ResetOneShot(); sounds[Tackling].ResetOneShot(); } if (newZone != State.Zone && State.Zone != Zone.UninitializedZone && newZone != Zone.UninitializedZone) { if (newZone != Zone.NimbleZone) { sounds[LeaveZone].ChangePitch(velocityFactor); sounds[LeaveZone].PlayAudio(false); AudioManager.G.BroadcastAudioEffect(AudioEffects.LowPass, transform, true); } else { sounds[EnterZone].ChangePitch(velocityFactor); sounds[EnterZone].PlayAudio(false); AudioManager.G.BroadcastAudioEffect(AudioEffects.LowPass, transform, false); } } if (GravityEffect == null) { return; } if (!GravityEffect.isPlaying && State.CurrentGravity != Vector3.zero) { GravityEffect.Play(); } else if (State.CurrentGravity == Vector3.zero) { GravityEffect.Stop(); } if (GravityEffect.isPlaying) { float gravityAngle = Vector3.SignedAngle(transform.parent.up, State.CurrentGravity, transform.forward); GravityEffect.gameObject.transform.localEulerAngles = new Vector3(0, 0, gravityAngle - transform.localEulerAngles.z); } } public void EquipWeapon(WeaponEffect weapon) { if (equippedWeapon != WeaponEffect.None) { _fireController.Stop(); } equippedWeapon = weapon; } public float CurrentHealth() { return State.RemainingHealth; } public float MaximumHealth() { return Props.MaximumHealth; } public void SetHealth(float totalValue) { State.RemainingHealth = totalValue; LifeUpdated?.Invoke(CurrentHealth() / MaximumHealth()); } public void InflictDamage(float damageValue) { SetHealth(CurrentHealth() - damageValue); } public void ReplenishHealth(float healValue) { SetHealth(CurrentHealth() + healValue); } }