Game/Anchor.cs
namespace Monolith;

/// <summary>
/// A grapple emplacement that fires a cable at you and holds you down.
///
/// This is the hazard that most directly forces the thing everything else only encourages: you
/// cannot outrun it, you cannot ignore it, and **the only way off is to shoot the far end**,
/// which is across the arena and nowhere near the shape you were mining. It is the one threat
/// that makes you turn your back on your work.
///
/// The design constraint that shaped it: the tether had to be breakable by shooting something
/// AWAY from the player, not something stuck to them. A parasite on your own body is fiddly to
/// aim at and reads as a UI problem; a cable running off into the dark reads as a place to point
/// your gun, and points it in the least convenient direction available.
///
/// Costs time, never progress. While tethered you move at half speed, which is dangerous
/// specifically because a Spotter is easier to escape when you are fast.
/// </summary>
public sealed class Anchor : Component
{
	private static readonly List<Anchor> all = new();
	public static IReadOnlyList<Anchor> All => all;

	private static Model bodyModel;

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

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

	/// <summary>True while the cable is attached and slowing the player.</summary>
	public bool Attached { get; private set; }

	private ModelRenderer renderer;
	private PointLight glow;

	private GameObject cableObject;
	private ModelRenderer cableRenderer;

	private GameTimeSince timeSinceStateChange = 99f;
	private float bobPhase;

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

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

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

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

		var light = obj.AddComponent<PointLight>();
		light.LightColor = new Color( 0.5f, 1f, 0.4f ) * 4f;
		light.Radius = 600f;
		light.Shadows = false;
		anchor.glow = light;

		// The cable is its own object so it can be stretched between two moving points.
		var cable = new GameObject( true, "Anchor Cable" );
		cable.NetworkMode = NetworkMode.Never;

		var cableMr = cable.AddComponent<ModelRenderer>();
		cableMr.Model = Projectile.SharedBoltModel;
		cableMr.Tint = Color.Black;

		anchor.cableObject = cable;
		anchor.cableRenderer = cableMr;

		Log.Info( "An Anchor has arrived." );
		return anchor;
	}

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

	protected override void OnDisabled()
	{
		all.Remove( this );

		cableObject?.Destroy();
		cableObject = null;
	}

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

		bobPhase += Time.Delta * 1.3f;
		WorldPosition = WorldPosition.WithZ(
			manager.World.WorldBounds.Mins.z + Tuning.AnchorHoverHeight
			+ MathF.Sin( bobPhase ) * 14f );

		if ( Attached )
			UpdateAttached( manager, player );
		else
			UpdateSeeking( manager, player );

		DrawCable( player );
	}

	private void UpdateSeeking( MonolithManager manager, PlayerMovement player )
	{
		if ( glow.IsValid() )
			glow.LightColor = new Color( 0.5f, 1f, 0.4f ) * 3f;

		if ( renderer.IsValid() )
			renderer.Tint = new Color( 0.45f, 0.85f, 0.4f ) * 1.6f;

		if ( !player.IsValid() || timeSinceStateChange < Tuning.AnchorCooldownSeconds )
			return;

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

		if ( distance > Tuning.AnchorRange )
			return;

		// Needs a clear line to fire, so the shape shelters you from it. Consistent with the
		// Spotter and the Sentinel: everything in this game respects cover.
		if ( manager.World.TraceRay( WorldPosition, toPlayer.Normal, distance - 24f, out _ ) )
			return;

		Attached = true;
		timeSinceStateChange = 0f;

		Audio.Play( Audio.Alert, WorldPosition, 0.6f, 0.5f );
	}

	private void UpdateAttached( MonolithManager manager, PlayerMovement player )
	{
		// Releases on its own eventually, so a player who genuinely cannot find it is not stuck
		// forever. Being unable to solve a problem is frustrating; being slowed for nine seconds
		// is merely expensive.
		if ( !player.IsValid() || timeSinceStateChange > Tuning.AnchorHoldSeconds )
		{
			Release();
			return;
		}

		// Line of sight is NOT required to keep holding. Once it has you, ducking behind the
		// shape does not help: that is what makes this different from every other hazard and
		// why the answer has to be aggression rather than cover.
		player.SpeedMultiplier = Tuning.AnchorSlowFactor;

		float pulse = 0.6f + 0.4f * MathF.Sin( Time.Now * 7f );

		if ( glow.IsValid() )
			glow.LightColor = new Color( 0.6f, 1f, 0.35f ) * (5f + pulse * 5f);

		if ( renderer.IsValid() )
			renderer.Tint = new Color( 0.7f, 1f, 0.4f ) * (2.2f + pulse * 2f);
	}

	private void Release()
	{
		Attached = false;
		timeSinceStateChange = 0f;
	}

	/// <summary>
	/// Lets go and goes on cooldown. Called when a new stage condenses.
	///
	/// Anchors carry over between stages like the other hazards, and I reset the Spotter's lock
	/// and the Sentinel's reload but never this. So a stage could open with the cable already on
	/// you, halving your speed, which is the "line attached at spawn" report: it is not a Spotter
	/// beam at all, it is a tether nobody released.
	///
	/// **THIRD time the same mistake.** Carrying a hazard across stages is right; carrying its
	/// STATE across is wrong, and each hazard has its own state to clear.
	/// </summary>
	public void ReleaseForNewStage()
	{
		Attached = false;

		// Restarted from zero, so the full AnchorCooldownSeconds must elapse before it can grab
		// again. Leaving the timer where it was would let it re-tether almost immediately and
		// reproduce the same complaint one second later.
		timeSinceStateChange = 0f;
	}

	/// <summary>Releases every anchor. Called when a new stage condenses.</summary>
	public static void ReleaseAll()
	{
		for ( int i = 0; i < all.Count; i++ )
		{
			if ( all[i].IsValid() )
				all[i].ReleaseForNewStage();
		}
	}

	/// <summary>
	/// Stretches the cable between the anchor and the player. Drawn only while attached: a
	/// visible line is the tell that tells you where to shoot, and it should not be showing
	/// when there is nothing to do about it.
	/// </summary>
	private void DrawCable( PlayerMovement player )
	{
		if ( !cableObject.IsValid() )
			return;

		if ( !Attached || !player.IsValid() )
		{
			cableObject.WorldScale = Vector3.Zero;

			if ( cableRenderer.IsValid() )
				cableRenderer.Tint = Color.Black;

			return;
		}

		var toPlayer = player.WorldPosition - WorldPosition;
		float length = MathF.Max( 40f, toPlayer.Length );

		cableObject.WorldPosition = WorldPosition + toPlayer * 0.5f;
		cableObject.WorldRotation = Rotation.LookAt( toPlayer.Normal );
		cableObject.WorldScale = new Vector3( length, 3.5f, 3.5f );

		float pulse = 0.7f + 0.3f * MathF.Sin( Time.Now * 9f );

		if ( cableRenderer.IsValid() )
			cableRenderer.Tint = new Color( 0.6f, 1f, 0.35f ) * (2.5f + pulse * 2f);
	}

	public static bool TryHit( Vector3 start, Vector3 direction, float distance, out Anchor 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.4f ) );
			return;
		}

		var progress = PlayerProgress.Local;

		if ( progress.IsValid() )
		{
			progress.AwardDust( Tuning.AnchorDustReward );

			// Worth more than the dust: cutting a tether under pressure is the play this hazard
			// exists to create, so it pays the chain generously.
			progress.AddResonance( 3 );
			progress.Data.AnchorsCut++;
		}

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

		Log.Info( "Anchor cut." );
		GameObject.Destroy();
	}

	/// <summary>A squat spike: reads as a stake driven into the air.</summary>
	private static Model GetBodyModel()
	{
		if ( bodyModel != null ) return bodyModel;

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

		int index = 0;

		Vector3[] tips = { Vector3.Up * 1.1f, Vector3.Down * 0.6f };
		Vector3[] ring =
		{
			new( 0.6f, 0f, 0f ), new( 0.42f, 0.42f, 0f ), new( 0f, 0.6f, 0f ),
			new( -0.42f, 0.42f, 0f ), new( -0.6f, 0f, 0f ), new( -0.42f, -0.42f, 0f ),
			new( 0f, -0.6f, 0f ), new( 0.42f, -0.42f, 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;
	}
}