Game/Crawler.cs
namespace Monolith;

/// <summary>
/// A low, fast thing that runs at you across the floor and takes the stage if it reaches you.
///
/// Every other hazard lives in the sky and asks you to look UP. This one uses the half of the
/// arena nothing else was using: the ground you are standing on while you mine.
///
/// **It is the payoff for the Anchor.** A tether halves your speed, which on its own is merely
/// annoying, and something that closes at a fixed rate is merely avoidable. Put them together
/// and the tether becomes a countdown: the correct play stops being "shoot the anchor eventually"
/// and becomes "shoot the anchor NOW, because the thing on the floor is still coming".
///
/// The speed ramp is the whole design. It starts below walking pace, so you can always stroll
/// away from a fresh one and being caught is never the result of it simply being faster than
/// you. Left alone it winds up to just under sprint speed, so ignoring it costs you the freedom
/// to stand still, which is exactly the behaviour the whole hazard set exists to punish.
/// </summary>
public sealed class Crawler : Component
{
	private static readonly List<Crawler> all = new();
	public static IReadOnlyList<Crawler> All => all;

	private static Model bodyModel;

	/// <summary>Shared, so two crawlers cannot both wipe the stage on the same frame.</summary>
	private static GameTimeSince timeSinceAnyGrab = 99f;

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

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

	/// <summary>0 to 1, how wound up the chase is. Drives speed, colour and sound.</summary>
	public float Rage => Math.Clamp( chaseSeconds / Tuning.CrawlerRampSeconds, 0f, 1f );

	private ModelRenderer renderer;
	private PointLight glow;

	private float chaseSeconds;
	private float bobPhase;
	private GameTimeSince timeSinceStep;

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

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

		var crawler = obj.AddComponent<Crawler>();
		crawler.bobPhase = Game.Random.Float( 0f, 10f );
		obj.WorldScale = crawler.Radius;

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

		var light = obj.AddComponent<PointLight>();
		light.LightColor = new Color( 1f, 0.25f, 0.1f ) * 4f;
		light.Radius = 420f;
		light.Shadows = false;
		crawler.glow = light;

		Log.Info( "A Crawler has arrived." );
		return crawler;
	}

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

	/// <summary>
	/// Resets the chase for a new stage. Position is kept, so a crawler that had you cornered is
	/// still nearby, but its wind-up is gone and you get the first move.
	/// </summary>
	public void CalmForNewStage() => chaseSeconds = 0f;

	public static void CalmAll()
	{
		for ( int i = 0; i < all.Count; i++ )
		{
			if ( all[i].IsValid() )
				all[i].CalmForNewStage();
		}
	}

	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;

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

		if ( !player.IsValid() )
			return;

		float floor = manager.World.WorldBounds.Mins.z;

		// GROUND BOUND. It never leaves the floor, which is what makes height a real answer:
		// standing on the shape is safe from this and exposed to everything else.
		var target = player.WorldPosition.WithZ( floor );
		var toPlayer = target - WorldPosition.WithZ( floor );
		float distance = toPlayer.Length;

		chaseSeconds += Time.Delta;

		float speed = MathX.Lerp( Tuning.CrawlerBaseSpeed, Tuning.CrawlerMaxSpeed, Rage );

		if ( distance > 4f )
			WorldPosition += toPlayer.Normal * speed * Time.Delta;

		// Scuttles, so the wind-up is legible before the speed is.
		bobPhase += Time.Delta * (6f + Rage * 16f);

		WorldPosition = WorldPosition.WithZ(
			floor + Radius * 0.55f + MathF.Abs( MathF.Sin( bobPhase ) ) * (6f + Rage * 12f) );

		if ( toPlayer.Length > 1f )
			WorldRotation = Rotation.LookAt( toPlayer.Normal );

		UpdateLook();
		UpdateVoice( distance );

		TryGrab( manager, player, distance );
	}

	private void UpdateLook()
	{
		// Cold ember to white hot. The colour IS the speed readout: a crawler you have been
		// ignoring looks different from one that just arrived, without a HUD element.
		var colour = Color.Lerp(
			new Color( 0.85f, 0.22f, 0.08f ),
			new Color( 1f, 0.85f, 0.55f ),
			Rage );

		if ( renderer.IsValid() )
			renderer.Tint = colour * (1.4f + Rage * 3.2f);

		if ( glow.IsValid() )
		{
			glow.LightColor = colour * (3f + Rage * 9f);
			glow.Radius = 380f + Rage * 380f;
		}
	}

	/// <summary>
	/// Footfalls that quicken with the chase. This is the warning that works when it is behind
	/// you, which is where a ground enemy spends most of its time.
	/// </summary>
	private void UpdateVoice( float distance )
	{
		float interval = MathX.Lerp( 0.42f, 0.13f, Rage );

		if ( timeSinceStep < interval )
			return;

		timeSinceStep = 0f;

		// Louder as it closes, so distance is audible rather than something you have to turn
		// around to check.
		float near = Math.Clamp( 1f - distance / 2200f, 0f, 1f );

		Audio.Play( Audio.Land, WorldPosition, 0.18f + near * 0.5f,
			Audio.Vary( 0.7f + Rage * 0.5f ) );
	}

	private void TryGrab( MonolithManager manager, PlayerMovement player, float distance )
	{
		if ( distance > Tuning.CrawlerGrabRadius + Radius )
			return;

		// Shared cooldown: a pack arriving together must not wipe the stage several times over,
		// which is the same mistake the Spotters made when every one of them called the reset.
		if ( timeSinceAnyGrab < Tuning.CrawlerGrabCooldown )
			return;

		timeSinceAnyGrab = 0f;

		Audio.Play( Audio.Explosion, WorldPosition, 1f, 0.5f );

		manager.ResetStageProgress( "A Crawler reached you" );

		// The whole pack loses its wind-up, so the rebuilt stage does not open with three fully
		// enraged crawlers standing on top of you.
		CalmAll();
	}

	public static bool TryHit( Vector3 start, Vector3 direction, float distance, out Crawler 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 )
		{
			// Knocked back and set back. Hitting it is worth something immediately, rather than
			// only on the shot that finally kills it.
			chaseSeconds = MathF.Max( 0f, chaseSeconds - Tuning.CrawlerRampSeconds * 0.25f );

			Audio.Play( Audio.GenericHit, WorldPosition, 0.5f, Audio.Vary( 1.2f ) );
			return;
		}

		var progress = PlayerProgress.Local;

		if ( progress.IsValid() )
		{
			progress.AwardDust( Tuning.CrawlerDustReward );
			progress.AddResonance( 2 );
			progress.Data.CrawlersKilled++;
		}

		BlastEffect.Spawn( Scene, WorldPosition, 300f, true );
		Debris.Burst( Scene, WorldPosition, 180f, true );
		Audio.Play( Audio.Explosion, WorldPosition, 0.8f, Audio.Vary( 1.2f ) );

		Log.Info( "Crawler killed." );
		GameObject.Destroy();
	}

	/// <summary>
	/// A flattened wedge: wide, low and pointed forward, so it reads as something running at you
	/// rather than as another floating diamond.
	/// </summary>
	private static Model GetBodyModel()
	{
		if ( bodyModel != null ) return bodyModel;

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

		int index = 0;

		// Nose forward, tail wide, squat in Z.
		Vector3[] tips = { new( 1.3f, 0f, 0f ), new( -0.9f, 0f, 0.1f ) };
		Vector3[] ring =
		{
			new( 0f, 0.85f, -0.25f ), new( 0f, 0.5f, 0.45f ),
			new( 0f, -0.5f, 0.45f ), new( 0f, -0.85f, -0.25f ),
		};

		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.x > 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;
	}
}