Game/OrbitalBarrier.cs
namespace Monolith;

/// <summary>
/// A wide panel that orbits the shape and swallows anything fired through it.
///
/// The important difference from an interceptor: **you cannot shoot it away**. An interceptor
/// is a target, so the answer is more damage. A barrier has no answer except to move, which is
/// exactly why it exists. Standing still and holding fire stops being viable the moment one of
/// these sweeps across your lane.
///
/// Blocking is tested in cylindrical coordinates about the shape centre rather than with a
/// mesh, which makes it exact and costs a couple of trig calls per projectile step.
/// </summary>
public sealed class OrbitalBarrier : Component
{
	private static readonly List<OrbitalBarrier> all = new();
	public static IReadOnlyList<OrbitalBarrier> All => all;

	private static Model panelModel;

	/// <summary>Centre of the orbit, in world space.</summary>
	public Vector3 Centre { get; set; }

	/// <summary>Distance from the centre the panel sits at.</summary>
	public float OrbitRadius { get; set; } = 700f;

	/// <summary>Current angle around the centre, in degrees.</summary>
	public float Angle { get; set; }

	/// <summary>Degrees per second. Signed, so panels can counter-rotate.</summary>
	public float AngularSpeed { get; set; } = 14f;

	/// <summary>Half-height of the panel above and below its centre height.</summary>
	public float HalfHeight { get; set; } = 260f;

	/// <summary>Height of the panel's centre, relative to the orbit centre.</summary>
	public float HeightOffset { get; set; }

	private ModelRenderer renderer;

	public static OrbitalBarrier Spawn( Scene scene, Vector3 centre, float radius,
		float angle, float speed, float heightOffset, float halfHeight )
	{
		if ( scene == null ) return null;

		var obj = new GameObject( true, "Orbital Barrier" );
		obj.NetworkMode = NetworkMode.Never;

		var barrier = obj.AddComponent<OrbitalBarrier>();
		barrier.Centre = centre;
		barrier.OrbitRadius = radius;
		barrier.Angle = angle;
		barrier.AngularSpeed = speed;
		barrier.HeightOffset = heightOffset;
		barrier.HalfHeight = halfHeight;

		var mr = obj.AddComponent<ModelRenderer>();
		mr.Model = GetPanelModel();
		mr.Tint = new Color( 0.55f, 0.14f, 0.30f );

		barrier.renderer = mr;
		barrier.Reposition();

		return barrier;
	}

	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;

		Angle += AngularSpeed * Time.Delta;
		if ( Angle >= 360f ) Angle -= 360f;
		if ( Angle < 0f ) Angle += 360f;

		Reposition();

		if ( !renderer.IsValid() )
			return;

		// Emissive rather than dark. A dim maroon slab read as a painted wall; bars that glow
		// read as a field, which is what tells you at a glance that it is not part of the shape
		// and not something you can shoot away.
		float flash = MathF.Max( 0f, 1f - timeSinceAbsorb / AbsorbFlashSeconds );
		float hum = 0.85f + 0.15f * MathF.Sin( Time.Now * 2.6f + OrbitRadius );

		// Heat accumulates per shot eaten and bleeds off when you stop. A single flash is easy
		// to miss while firing ten times a second; a panel that visibly heats under sustained
		// fire tells you that you are wasting every one of those shots.
		heat = Math.Clamp( heat + absorbCount * HeatPerHit
			- HeatDecayPerSecond * Time.Delta, 0f, 1f );

		absorbCount = 0;

		// Angry white through pink at full heat, so the message escalates rather than repeating.
		var idle = new Color( 1.5f, 0.28f, 0.5f ) * hum;
		var hot = Color.Lerp( idle, new Color( 1f, 0.55f, 0.75f ) * 5f, heat );

		renderer.Tint = Color.Lerp( hot, new Color( 1f, 0.85f, 0.95f ) * 11f, flash );
	}

	private void Reposition()
	{
		float radians = Angle * MathF.PI / 180f;

		var position = Centre + new Vector3(
			MathF.Cos( radians ) * OrbitRadius,
			MathF.Sin( radians ) * OrbitRadius,
			HeightOffset );

		WorldPosition = position;

		// Face the centre so the panel presents its width across your line of fire.
		WorldRotation = Rotation.LookAt( (Centre.WithZ( position.z ) - position).Normal );

		// Arc length at this radius gives the panel its apparent width.
		float arcWidth = Tuning.BarrierArcDegrees * MathF.PI / 180f * OrbitRadius;
		WorldScale = new Vector3( 24f, arcWidth, HalfHeight * 2f );
	}

	/// <summary>
	/// True if the segment blocks this projectile step. Tested in cylindrical coordinates:
	/// a point is inside when its radius, height and bearing all fall within the panel.
	///
	/// Reports WHICH barrier and WHERE, because a lattice you can see through has to prove it
	/// is still solid. Without the absorb landing somewhere visible, a barrier that eats your
	/// shots while you can see the rock behind it just looks like the gun has stopped working.
	/// </summary>
	public static bool Blocks( Vector3 start, Vector3 direction, float distance,
		out OrbitalBarrier hit, out Vector3 point )
	{
		hit = null;
		point = start;

		if ( all.Count == 0 )
			return false;

		// A barrier shields the MONOLITH. It does not shield the hazards.
		//
		// This was an unwinnable interaction. Barriers orbit at ~0.55 of the shape's diagonal
		// while Sentinels sit at 0.7 and Anchors at 0.9, so the hazards are ALWAYS outside the
		// barrier ring: every shot aimed at the things that force you to look away from the
		// shape was eaten by the ring on the way out. You could not cut a tether or kill a
		// sentinel at all, which is exactly what "these do nothing" looks like.
		//
		// Only shots travelling INWARD are blocked now. Firing out at a hazard passes; firing in
		// at the rock is what the panel is there to stop. It also reads correctly: the thing is
		// a shield around the monolith, so it faces the monolith's attacker.
		if ( !IsHeadingInward( all[0].Centre, start, direction ) )
			return false;

		// Sample along the step.
		//
		// Three samples used to be "plenty", and it was not. A bolt travels 4200 units per
		// second, so at a good framerate one step is 30 to 70 units while the blocking slab is
		// 80 units thick radially. Approach it at a shallow angle and the sampled points could
		// straddle it entirely, which is why shots appeared to pass through from some directions
		// and not others. Sample density has to be set by the THINNEST dimension of the volume,
		// not by the step length.
		int samples = Math.Clamp( (int)(distance / 12f) + 2, 4, 24 );

		for ( int s = 0; s <= samples; s++ )
		{
			var sample = start + direction * (distance * s / (float)samples);

			for ( int i = 0; i < all.Count; i++ )
			{
				if ( !all[i].Contains( sample ) )
					continue;

				hit = all[i];
				point = sample;
				return true;
			}
		}

		return false;
	}

	/// <summary>
	/// A shot just died on this panel. Flares the whole lattice for a moment so the absorb is
	/// attributed to the barrier and not to thin air.
	/// </summary>
	public void Absorb()
	{
		timeSinceAbsorb = 0f;
		absorbCount++;
	}

	private GameTimeSince timeSinceAbsorb = 99f;

	/// <summary>
	/// Shots eaten. Drives a rising glow, so a barrier you are firing into gets visibly hotter
	/// the longer you waste ammunition on it. One flash is easy to miss when you are firing ten
	/// times a second; a panel that brightens under sustained fire is not.
	/// </summary>
	private int absorbCount;

	private GameTimeSince timeSinceAbsorbDecay;

	/// <summary>Length of the single-hit flare. Short: it is a punctuation mark, not a state.</summary>
	private const float AbsorbFlashSeconds = 0.3f;

	/// <summary>Hits it takes to reach full heat, and how fast that heat bleeds off.</summary>
	private const float HeatPerHit = 0.17f;
	private const float HeatDecayPerSecond = 0.8f;

	private float heat;

	private bool Contains( Vector3 point )
	{
		var local = point - Centre;

		float height = local.z - HeightOffset;
		if ( MathF.Abs( height ) > HalfHeight )
			return false;

		float radius = new Vector2( local.x, local.y ).Length;
		if ( MathF.Abs( radius - OrbitRadius ) > 40f )
			return false;

		float bearing = MathF.Atan2( local.y, local.x ) * 180f / MathF.PI;
		if ( bearing < 0f ) bearing += 360f;

		float delta = MathF.Abs( DeltaAngle( bearing, Angle ) );
		return delta <= Tuning.BarrierArcDegrees * 0.5f;
	}

	/// <summary>
	/// True if the shot is closing on the shape centre in the horizontal plane.
	///
	/// Measured in XY only. Height is irrelevant to "is this aimed at the monolith", and
	/// including it would mean a shot at a Spotter directly overhead counted as inward.
	/// </summary>
	private static bool IsHeadingInward( Vector3 centre, Vector3 start, Vector3 direction )
	{
		var outward = (start - centre).WithZ( 0f );

		// Standing dead centre: nothing sensible to compare against, so let it through rather
		// than blocking arbitrarily.
		if ( outward.Length < 1f )
			return false;

		return Vector3.Dot( direction.WithZ( 0f ), outward.Normal ) < 0f;
	}

	/// <summary>Shortest signed distance between two bearings, in degrees.</summary>
	private static float DeltaAngle( float a, float b )
	{
		float d = (a - b + 540f) % 360f - 180f;
		return d;
	}

	/// <summary>
	/// A unit LATTICE, scaled per barrier into a wide panel of bars.
	///
	/// It used to be a solid slab, which meant a barrier drifting between you and the shape
	/// blanked out the thing you were trying to mine. The blocking test is unchanged and still
	/// covers the whole rectangle: only what you can see through it changed. That is the right
	/// trade, because the barrier's job is to move your feet, not to hide the level.
	///
	/// Local axes at draw time are (thickness, width, height), so the bars are laid out in the
	/// Y/Z plane and left full depth in X.
	/// </summary>
	private static Model GetPanelModel()
	{
		if ( panelModel != null )
			return panelModel;

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

		int index = 0;

		// JUST THE FRAME. The lattice version read as a window pane, which made a barrier look
		// like scenery you were behind rather than an obstacle in your way. An empty rectangle
		// says "the opening is blocked" without pretending to be glass.
		//
		// `depth` stays small: the bars used to span the panel's full 24 unit thickness, so the
		// moment you looked at one off-axis they occluded each other and it filled in solid.
		// A frame is only see-through when it is FLAT.
		const float bar = 0.04f;
		const float depth = 0.28f;

		// Top and bottom rails.
		AddBox( vb, ref index, new Vector3( 0f, 0f, -0.5f ), new Vector3( depth, 1f, bar ) );
		AddBox( vb, ref index, new Vector3( 0f, 0f, 0.5f ), new Vector3( depth, 1f, bar ) );

		// Left and right posts.
		AddBox( vb, ref index, new Vector3( 0f, -0.5f, 0f ), new Vector3( depth, bar, 1f ) );
		AddBox( vb, ref index, new Vector3( 0f, 0.5f, 0f ), new Vector3( depth, bar, 1f ) );

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

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

	private static void AddBox( VertexBuffer vb, ref int index, Vector3 centre, Vector3 size )
	{
		Vector3[] normals =
		{
			Vector3.Forward, Vector3.Backward, Vector3.Left,
			Vector3.Right, Vector3.Up, Vector3.Down,
		};

		foreach ( var n in normals )
		{
			var reference = MathF.Abs( n.z ) > 0.9f ? Vector3.Forward : Vector3.Up;
			var u = Vector3.Cross( n, reference ).Normal;
			var v = Vector3.Cross( n, u ).Normal;

			var face = centre + n * 0.5f * Project( size, n );

			var du = u * 0.5f * Project( size, u );
			var dv = v * 0.5f * Project( size, v );

			var p0 = face - du - dv;
			var p1 = face + du - dv;
			var p2 = face + du + dv;
			var p3 = face - du + dv;

			vb.Add( new Vertex( p0, n, u, new Vector4( 0, 0, 0, 0 ) ) );
			vb.Add( new Vertex( p1, n, u, new Vector4( 1, 0, 0, 0 ) ) );
			vb.Add( new Vertex( p2, n, u, new Vector4( 1, 1, 0, 0 ) ) );
			vb.Add( new Vertex( p3, n, u, new Vector4( 0, 1, 0, 0 ) ) );

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

			index += 4;
		}
	}

	/// <summary>Magnitude of a box extent along an axis direction.</summary>
	private static float Project( Vector3 size, Vector3 axis )
		=> MathF.Abs( axis.x ) * size.x + MathF.Abs( axis.y ) * size.y + MathF.Abs( axis.z ) * size.z;
}