Game/Sentinel.cs
namespace Monolith;

/// <summary>
/// A hovering gun emplacement, and the first thing in the game that shoots back.
///
/// Every other hazard is passive. Barriers sit in the way, leeches sit on the rock, the Spotter
/// watches. All of them can be ignored for a while, and mining is a thing you do *between*
/// dealing with them. A sentinel's shot arrives whether or not you looked at it, so it is the
/// first hazard that sets its own tempo rather than waiting for you to notice.
///
/// The orb is deliberately slow and destructible so the answer is a CHOICE, not a reflex:
///
///   - Move out of its path, which costs you your firing position.
///   - Spend one shot killing the orb, which costs you a shot.
///   - Spend six killing the sentinel, which costs a lot now and nothing later.
///
/// All three are correct in different situations, which is the point.
/// </summary>
public sealed class Sentinel : Component
{
	private static readonly List<Sentinel> all = new();
	public static IReadOnlyList<Sentinel> All => all;

	private static Model bodyModel;

	[Property] public float Radius { get; set; } = 78f;

	public int Health { get; private set; } = Tuning.SentinelHealth;

	private ModelRenderer renderer;
	private PointLight glow;

	private Vector3 wanderTarget;
	private GameTimeSince timeSinceRetarget = 99f;
	private GameTimeSince timeSinceShot;
	private GameTimeSince timeSinceSpawn;
	private float spin;

	public static Sentinel Spawn( Scene scene, Vector3 position )
	{
		if ( scene == null ) return null;

		var obj = new GameObject( true, "Sentinel" );
		obj.NetworkMode = NetworkMode.Never;
		obj.WorldPosition = position;

		var sentinel = obj.AddComponent<Sentinel>();
		obj.WorldScale = sentinel.Radius;

		var mr = obj.AddComponent<ModelRenderer>();
		mr.Model = GetBodyModel();
		sentinel.renderer = mr;

		var light = obj.AddComponent<PointLight>();
		light.LightColor = new Color( 1f, 0.35f, 0.12f ) * 5f;
		light.Radius = 700f;
		light.Shadows = false;
		sentinel.glow = light;

		sentinel.wanderTarget = position;

		// Staggered so a group that arrives together does not fire in a single volley, which
		// would be one big dodge instead of a rhythm you have to keep track of.
		//
		// Capped at HALF the interval, not the whole of it. Rolling near the top meant a
		// sentinel could arrive with its reload already finished and shoot you before you had
		// seen it exist, which reads as being ambushed by the spawn rather than by the enemy.
		sentinel.timeSinceShot = Game.Random.Float( 0f, Tuning.SentinelFireInterval * 0.5f );

		Log.Info( "A Sentinel has arrived." );
		return sentinel;
	}

	protected override void OnEnabled() => all.Add( this );
	protected override void OnDisabled() => all.Remove( this );

	protected override void OnUpdate()
	{
		// Frozen while a blocking screen or the pause menu is up. See GameTime.
		if ( GameTime.Paused )
			return;

		var manager = MonolithManager.Instance;
		if ( !manager.IsValid() || manager.World == null )
			return;

		Hover( manager );
		Face( manager );

		// Charge-up glow, so the shot is telegraphed. A turret that fires without warning is
		// just damage; one that visibly winds up is something you can play around.
		float charge = Math.Clamp( timeSinceShot / Tuning.SentinelFireInterval, 0f, 1f );

		if ( glow.IsValid() )
			glow.LightColor = new Color( 1f, 0.35f, 0.12f ) * (2f + charge * charge * 9f);

		if ( renderer.IsValid() )
			renderer.Tint = new Color( 1f, 0.42f, 0.18f ) * (1.2f + charge * charge * 2.5f);

		// A beat after arriving before it can shoot at all. Even one second is the difference
		// between "a turret turned up and I dealt with it" and "I was hit by something that did
		// not exist a moment ago", and the second of those just feels like being cheated.
		if ( timeSinceSpawn < Tuning.SentinelArmSeconds )
			return;

		if ( timeSinceShot < Tuning.SentinelFireInterval )
			return;

		TryFire();
	}

	private void TryFire()
	{
		var player = Scene.GetAllComponents<PlayerMovement>().FirstOrDefault();
		if ( !player.IsValid() )
			return;

		var manager = MonolithManager.Instance;

		var toPlayer = player.WorldPosition - WorldPosition;
		float distance = toPlayer.Length;

		if ( distance < 1f || distance > Tuning.SentinelRange )
			return;

		// Must actually be pointed at you. The charge glow plus the visible turn is the whole
		// warning, and firing without facing would throw both away.
		if ( !IsFacing( player.WorldPosition ) )
			return;

		// Line of sight, so the shape is cover against sentinels too. Consistency matters more
		// than the individual rule: if the monolith blocks one threat it must block them all,
		// or players cannot reason about cover at all.
		if ( manager.World.TraceRay( WorldPosition, toPlayer.Normal, distance - 24f, out _ ) )
			return;

		timeSinceShot = 0f;

		// CALLS IT IN. A sentinel with eyes on you tells every Spotter in range where to look.
		//
		// This is what turns two hazards into a system. A sentinel is cheap, close-range and
		// survivable; a Spotter is the thing that actually costs you a stage but has to find you
		// first. Alerting only steers the search, it never grants a lock, so leaving a sentinel
		// alive does not kill you - it makes the Spotter that was going to miss you find you
		// instead. Killing the cheap thing is now how you stay hidden from the expensive one.
		Spotter.AlertAll( player.WorldPosition );

		// Leads its target, but DELIBERATELY UNDER-LEADS. It aims at where you would be if you
		// kept going, so holding a straight line gets you hit and changing direction beats it.
		// A perfect lead would teach the opposite lesson and make movement pointless.
		float flight = distance / Tuning.SentinelOrbSpeed;
		var predicted = player.WorldPosition
			+ player.Velocity * flight * Tuning.SentinelLeadFactor;

		SentinelOrb.Spawn( Scene, WorldPosition, (predicted - WorldPosition).Normal );
		Audio.Play( Audio.MetalHit, WorldPosition, 0.5f, 0.6f );
	}

	/// <summary>
	/// Turns the barrel toward you at a capped rate, and idles by drifting when you are out of
	/// range.
	///
	/// This is the telegraph. Previously it just spun on the spot and fired at anything in line
	/// of sight, so a shot could arrive from a machine that had never appeared to be interested
	/// in you. Watching a turret swing round to face you is a warning you can act on, and
	/// because the turn is slow, walking around it genuinely spoils the shot.
	/// </summary>
	private void Face( MonolithManager manager )
	{
		var player = Scene.GetAllComponents<PlayerMovement>().FirstOrDefault();

		// DOES NOT TRACK DURING THE ARM WINDOW.
		//
		// The arm delay stopped it FIRING but not TURNING, so it swung onto you the instant it
		// appeared and then fired the moment the timer expired. From the player's side that is
		// indistinguishable from no delay at all: what you read as "it locked on immediately" is
		// the barrel movement, not the shot.
		//
		// Drifting until armed means the delay is something you can SEE. It also stacks: it has
		// to spend the arm window idle and then still turn at SentinelTurnRate to find you.
		bool armed = timeSinceSpawn >= Tuning.SentinelArmSeconds;

		float wanted;

		if ( armed && player.IsValid()
			&& player.WorldPosition.Distance( WorldPosition ) <= Tuning.SentinelRange )
		{
			wanted = (player.WorldPosition - WorldPosition).WithZ( 0f ).Normal.EulerAngles.yaw;
		}
		else
		{
			// Not armed, or nobody in reach: drift, so it still reads as alive rather than
			// switched off.
			wanted = spin + 40f * Time.Delta;
		}

		// Shortest way round, capped. Turning the long way past 180 degrees would look like the
		// turret had panicked.
		float step = Tuning.SentinelTurnRate * Time.Delta;

		spin += Math.Clamp( ShortestTurn( wanted, spin ), -step, step );
		WorldRotation = Rotation.FromYaw( spin );
	}

	/// <summary>True when the barrel is pointed close enough at a target to fire.</summary>
	private bool IsFacing( Vector3 target )
	{
		float wanted = (target - WorldPosition).WithZ( 0f ).Normal.EulerAngles.yaw;
		return MathF.Abs( ShortestTurn( wanted, spin ) ) <= Tuning.SentinelViewCone;
	}

	/// <summary>
	/// Signed degrees to turn FROM <paramref name="from"/> TO <paramref name="to"/>, in -180..180.
	///
	/// Written out rather than using MathX.DeltaDegrees because the docs do not say which way
	/// round its arguments subtract, and a sign error here would make the turret rotate away
	/// from its target forever. Cheap to spell out, impossible to get subtly wrong.
	/// </summary>
	private static float ShortestTurn( float to, float from )
		=> (to - from + 540f) % 360f - 180f;

	private void Hover( MonolithManager manager )
	{
		var bounds = manager.World.WorldBounds;

		if ( timeSinceRetarget > 4.5f )
		{
			timeSinceRetarget = 0;

			// Keeps its distance from the shape so it is never buried inside geometry, and so
			// turning to shoot it always means turning away from what you were mining.
			float spread = MathF.Max( 700f, bounds.Size.Length * 0.7f );
			float angle = Game.Random.Float( 0f, MathF.PI * 2f );

			wanderTarget = bounds.Center + new Vector3(
				MathF.Cos( angle ) * spread,
				MathF.Sin( angle ) * spread,
				0f );

			wanderTarget.z = bounds.Center.z + Tuning.SentinelHoverHeight;
		}

		var toTarget = wanderTarget - WorldPosition;

		if ( toTarget.Length > 10f )
			WorldPosition += toTarget.Normal * Tuning.SentinelSpeed * Time.Delta;
	}

	/// <summary>
	/// Re-arms for a new stage: pushes the reload back and restarts the spawn grace.
	///
	/// Sentinels carry over between stages so their reload is not reset by a fast player, and
	/// that is exactly what made them fire the instant a new stage condensed: a survivor arrives
	/// with `timeSinceSpawn` long expired and its reload already full. The arm delay only ever
	/// covered a FRESH spawn, which is not the case that needed covering.
	///
	/// A new stage should always open with a moment to look around, whoever is still in the sky.
	/// </summary>
	public void RearmForNewStage()
	{
		timeSinceSpawn = 0f;
		timeSinceShot = Game.Random.Float( 0f, Tuning.SentinelFireInterval * 0.4f );
	}

	/// <summary>Re-arms every sentinel. Called when a new stage condenses.</summary>
	public static void RearmAll()
	{
		for ( int i = 0; i < all.Count; i++ )
		{
			if ( all[i].IsValid() )
				all[i].RearmForNewStage();
		}
	}

	/// <summary>Sphere test for one projectile step, same shape as every other hittable.</summary>
	public static bool TryHit( Vector3 start, Vector3 direction, float distance, out Sentinel hit )
	{
		hit = null;
		float best = float.MaxValue;

		for ( int i = 0; i < all.Count; i++ )
		{
			var candidate = all[i];
			if ( !candidate.IsValid() ) continue;

			var toCentre = candidate.WorldPosition - start;
			float along = Vector3.Dot( toCentre, direction );

			if ( along < -candidate.Radius || along > distance + candidate.Radius )
				continue;

			float perpSq = toCentre.LengthSquared - along * along;
			if ( perpSq > candidate.Radius * candidate.Radius )
				continue;

			if ( along < best )
			{
				best = along;
				hit = candidate;
			}
		}

		return hit != null;
	}

	public void TakeHit( int damage )
	{
		Health -= damage;

		if ( Health > 0 )
		{
			Audio.Play( Audio.MetalHit, WorldPosition, 0.5f, Audio.Vary( 1.3f ) );
			return;
		}

		var progress = PlayerProgress.Local;

		if ( progress.IsValid() )
		{
			progress.AwardDust( Tuning.SentinelDustReward );
			progress.AddResonance( 2 );
			progress.Data.SentinelsDowned++;
		}

		BlastEffect.Spawn( Scene, WorldPosition, 380f, true );
		Debris.Burst( Scene, WorldPosition, 200f, true );
		Audio.Play( Audio.Explosion, WorldPosition, 0.85f, Audio.Vary( 1.1f ) );

		Log.Info( "Sentinel downed." );
		GameObject.Destroy();
	}

	/// <summary>A blunt turret: a wide drum with a barrel stub, unmistakably a gun.</summary>
	private static Model GetBodyModel()
	{
		if ( bodyModel != null ) return bodyModel;

		var vb = new VertexBuffer();
		vb.Init( true );

		int index = 0;

		Vector3[] tips = { Vector3.Up * 0.5f, Vector3.Down * 0.5f };
		Vector3[] ring =
		{
			new( 1f, 0f, 0f ), new( 0.7f, 0.7f, 0f ), new( 0f, 1f, 0f ), new( -0.7f, 0.7f, 0f ),
			new( -1f, 0f, 0f ), new( -0.7f, -0.7f, 0f ), new( 0f, -1f, 0f ), new( 0.7f, -0.7f, 0f ),
		};

		foreach ( var tip in tips )
		{
			for ( int i = 0; i < ring.Length; i++ )
			{
				var a = ring[i];
				var b = ring[(i + 1) % ring.Length];
				var (p1, p2) = tip.z > 0 ? (a, b) : (b, a);

				var normal = Vector3.Cross( p2 - tip, p1 - tip ).Normal;
				var tangent = (p1 - tip).Normal;

				vb.Add( new Vertex( tip, normal, tangent, new Vector4( 0.5f, 0, 0, 0 ) ) );
				vb.Add( new Vertex( p1, normal, tangent, new Vector4( 0, 1, 0, 0 ) ) );
				vb.Add( new Vertex( p2, normal, tangent, new Vector4( 1, 1, 0, 0 ) ) );

				vb.AddRawIndex( index + 0 );
				vb.AddRawIndex( index + 1 );
				vb.AddRawIndex( index + 2 );
				index += 3;
			}
		}

		var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
		mesh.CreateBuffers( vb );

		bodyModel = new ModelBuilder().AddMesh( mesh ).Create();
		return bodyModel;
	}
}