Game/SentinelOrb.cs
namespace Monolith;

/// <summary>
/// The projectile a <see cref="Sentinel"/> fires at you.
///
/// Slow, bright, and destructible. All three are deliberate: it has to be readable at a glance
/// across a dark arena, it has to be dodgeable by someone who noticed it late, and it has to be
/// killable so that spending a shot on it is a real option rather than a wasted one.
///
/// It travels in a STRAIGHT LINE and does not home. Homing would make movement pointless, which
/// is the opposite of why this hazard exists.
/// </summary>
public sealed class SentinelOrb : Component
{
	private static readonly List<SentinelOrb> all = new();
	public static IReadOnlyList<SentinelOrb> All => all;

	private static Model orbModel;

	[Property] public float Radius { get; set; } = Tuning.SentinelOrbRadius;

	private Vector3 direction;
	private GameTimeSince timeSinceSpawn;
	private ModelRenderer renderer;

	public static void Spawn( Scene scene, Vector3 from, Vector3 direction )
	{
		if ( scene == null ) return;

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

		var orb = obj.AddComponent<SentinelOrb>();
		orb.direction = direction.Normal;

		obj.WorldScale = orb.Radius;

		var mr = obj.AddComponent<ModelRenderer>();
		mr.Model = GetOrbModel();
		mr.Tint = new Color( 1f, 0.3f, 0.5f ) * 6f;
		orb.renderer = mr;

		var light = obj.AddComponent<PointLight>();
		light.LightColor = new Color( 1f, 0.25f, 0.45f ) * 8f;
		light.Radius = 420f;
		light.Shadows = false;
	}

	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;

		if ( timeSinceSpawn > Tuning.SentinelOrbLifetime )
		{
			GameObject.Destroy();
			return;
		}

		float step = Tuning.SentinelOrbSpeed * Time.Delta;
		var next = WorldPosition + direction * step;

		// Pulses, so it reads as live rather than as a static prop drifting past.
		if ( renderer.IsValid() )
		{
			float pulse = 0.75f + 0.25f * MathF.Sin( Time.Now * 12f );
			renderer.Tint = new Color( 1f, 0.3f, 0.5f ) * (4f + pulse * 4f);
		}

		// The shape stops it, exactly like a barrier stops yours. Cover has to work in both
		// directions or it is not cover, it is just decoration you happen to hide behind.
		var manager = MonolithManager.Instance;

		if ( manager.IsValid() && manager.World != null
			&& manager.World.TraceRay( WorldPosition, direction, step, out _ ) )
		{
			Burst();
			return;
		}

		var player = Scene.GetAllComponents<PlayerMovement>().FirstOrDefault();

		if ( player.IsValid() && player.WorldPosition.Distance( next ) <= Radius )
		{
			player.Stagger();
			Burst();
			return;
		}

		WorldPosition = next;
	}

	private void Burst()
	{
		BlastEffect.Spawn( Scene, WorldPosition, 120f, false );
		Audio.Play( Audio.GenericHit, WorldPosition, 0.6f, Audio.Vary( 1.1f ) );
		GameObject.Destroy();
	}

	/// <summary>Shot down by the player. One hit: it is a target of opportunity, not a fight.</summary>
	public static bool TryHit( Vector3 start, Vector3 direction, float distance, out SentinelOrb 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 Shatter()
	{
		var progress = PlayerProgress.Local;

		// Feeds the chain. Shooting an incoming orb is a skill shot under time pressure and it
		// should pay like one, or the correct play is always to walk away from it.
		if ( progress.IsValid() )
		{
			progress.AddResonance();
			progress.Data.OrbsShot++;
		}

		Burst();
	}

	/// <summary>A unit octahedron. Angular, so it never reads as one of our round drones.</summary>
	private static Model GetOrbModel()
	{
		if ( orbModel != null ) return orbModel;

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

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

		int index = 0;

		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 );

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