Voxel/ShieldShell.cs
namespace Monolith;

/// <summary>
/// The visible husk around a shielded shape, and the node that switches it off.
///
/// This exists because the shield previously had **no presence whatsoever**: its only effects
/// were jamming drones (which a new player does not own) and a line of HUD text. It fired on
/// more than a quarter of stages and was reported as never happening, which is what an
/// invisible mechanic looks like from the outside.
///
/// The cage is built from thin bars rather than a solid box so you can still see and shoot the
/// shape through it. It blocks nothing: it is a statement, not an obstacle.
///
/// The NODE hanging above the shape is the interactive half. Shooting it drops the shield for
/// a couple of seconds and then it comes back, so a shielded stage is a repeating skill beat
/// rather than a timer you sit out. **Drone shots pass through it** (see Projectile), because a
/// shield your drones can clear is not jamming anything.
/// </summary>
public sealed class ShieldShell : Component
{
	/// <summary>
	/// There is only ever one shape, so there is only ever one shell. Not named Active:
	/// Component.Active already exists and shadowing it is a trap.
	/// </summary>
	public static ShieldShell Current { get; private set; }

	private static Model cageModel;
	private static Model nodeModel;

	private GameObject shellObject;
	private ModelRenderer renderer;

	private GameObject nodeObject;
	private ModelRenderer nodeRenderer;
	private PointLight nodeLight;

	private bool visible;

	/// <summary>Centre of the node, for hit tests and effects.</summary>
	public Vector3 NodePosition => nodeObject.IsValid() ? nodeObject.WorldPosition : Vector3.Zero;

	/// <summary>True while the node has been struck and the shield is down.</summary>
	private bool IsBroken => MonolithManager.Instance.IsValid()
		&& MonolithManager.Instance.ShieldBroken;

	protected override void OnEnabled() => Current = this;

	protected override void OnDisabled()
	{
		if ( Current == this )
			Current = null;
	}

	public void Show( BBox bounds )
	{
		EnsureObjects();

		float pad = Tuning.ShieldShellPadding;

		shellObject.WorldPosition = bounds.Center;
		shellObject.WorldScale = bounds.Size + new Vector3( pad, pad, pad ) * 2f;
		shellObject.Enabled = true;

		// The node hangs clear of the cage in open sky, so it is always shootable from the
		// ground without having to find an angle through the shape.
		nodeObject.WorldPosition = bounds.Center
			.WithZ( bounds.Maxs.z + pad + Tuning.ShieldNodeHeight );

		nodeObject.WorldScale = Tuning.ShieldNodeRadius;
		nodeObject.Enabled = true;

		visible = true;
	}

	public void Hide()
	{
		visible = false;

		if ( shellObject.IsValid() ) shellObject.Enabled = false;
		if ( nodeObject.IsValid() ) nodeObject.Enabled = false;
	}

	protected override void OnDestroy()
	{
		shellObject?.Destroy();
		shellObject = null;

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

	private void EnsureObjects()
	{
		if ( !shellObject.IsValid() )
		{
			shellObject = new GameObject( true, "Shield Shell" );
			shellObject.NetworkMode = NetworkMode.Never;
			shellObject.Parent = GameObject;

			renderer = shellObject.AddComponent<ModelRenderer>();
			renderer.Model = GetCageModel();
		}

		if ( nodeObject.IsValid() )
			return;

		nodeObject = new GameObject( true, "Shield Node" );
		nodeObject.NetworkMode = NetworkMode.Never;

		nodeRenderer = nodeObject.AddComponent<ModelRenderer>();
		nodeRenderer.Model = GetNodeModel();

		nodeLight = nodeObject.AddComponent<PointLight>();
		nodeLight.Radius = 900f;
		nodeLight.Shadows = false;
	}

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

		if ( !visible || !shellObject.IsValid() || !shellObject.Enabled )
			return;

		bool broken = IsBroken;

		// Slow electric shimmer, so it reads as powered rather than as scenery. While broken the
		// cage goes dim and stops pulsing: the state of the shield is legible from the shape
		// itself, without looking at the HUD.
		float pulse = 0.6f + 0.4f * MathF.Sin( Time.Now * 3.4f );

		if ( renderer.IsValid() )
		{
			renderer.Tint = broken
				? new Color( 0.12f, 0.20f, 0.26f )
				: new Color( 0.25f, 0.75f, 1f ) * (1.2f + pulse * 1.6f);
		}

		if ( !nodeObject.IsValid() )
			return;

		// The node spins, so it is picked out as a machine against the sky rather than mistaken
		// for a piece of the shape. It beats hard while live and goes cold while broken, which
		// is also the recharge tell.
		nodeObject.WorldRotation *= Rotation.From( 0f, 70f * Time.Delta, 25f * Time.Delta );

		float beat = 0.5f + 0.5f * MathF.Sin( Time.Now * 6.5f );

		var colour = broken
			? new Color( 0.16f, 0.18f, 0.22f )
			: new Color( 0.35f, 0.85f, 1f ) * (2.4f + beat * 3.4f);

		if ( nodeRenderer.IsValid() )
			nodeRenderer.Tint = colour;

		if ( nodeLight.IsValid() )
			nodeLight.LightColor = broken ? Color.Black : colour;
	}

	// ---------------------------------------------------------------- the weak point

	/// <summary>
	/// Sphere test against the node for one projectile step, same shape as every other hittable.
	/// Returns false when there is no shield up or it is already broken, so a wasted shot passes
	/// through to the rock behind instead of being eaten.
	/// </summary>
	public static bool TryHitNode( Vector3 start, Vector3 direction, float distance, out ShieldShell hit )
	{
		hit = null;

		var shell = Current;
		if ( !shell.IsValid() || !shell.visible || !shell.nodeObject.IsValid() )
			return false;

		var manager = MonolithManager.Instance;
		if ( !manager.IsValid() || !manager.ShieldActive )
			return false;

		float radius = Tuning.ShieldNodeRadius;

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

		if ( along < -radius || along > distance + radius )
			return false;

		float perpSq = toCentre.LengthSquared - along * along;
		if ( perpSq > radius * radius )
			return false;

		hit = shell;
		return true;
	}

	/// <summary>One clean hit takes the whole shield down. No health bar: it is a switch.</summary>
	public void Strike()
	{
		if ( MonolithManager.Instance.IsValid() )
			MonolithManager.Instance.BreakShield();

		var progress = PlayerProgress.Local;

		if ( progress.IsValid() )
		{
			progress.AddResonance();
			progress.Data.ShieldNodesBroken++;
		}
	}

	// ---------------------------------------------------------------- models

	/// <summary>A unit cage: twelve thin bars along the edges of a unit cube.</summary>
	private static Model GetCageModel()
	{
		if ( cageModel != null )
			return cageModel;

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

		const float t = 0.012f;
		int index = 0;

		// Four bars along each axis, at the four edge positions of the cube.
		for ( int axis = 0; axis < 3; axis++ )
		{
			for ( int corner = 0; corner < 4; corner++ )
			{
				float a = (corner & 1) == 0 ? -0.5f : 0.5f;
				float b = (corner & 2) == 0 ? -0.5f : 0.5f;

				Vector3 size = axis switch
				{
					0 => new Vector3( 1f, t, t ),
					1 => new Vector3( t, 1f, t ),
					_ => new Vector3( t, t, 1f ),
				};

				Vector3 centre = axis switch
				{
					0 => new Vector3( 0f, a, b ),
					1 => new Vector3( a, 0f, b ),
					_ => new Vector3( a, b, 0f ),
				};

				AddBox( vb, ref index, centre, size );
			}
		}

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

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

	/// <summary>A unit octahedron. Angular and obviously artificial, unlike the rock.</summary>
	private static Model GetNodeModel()
	{
		if ( nodeModel != null )
			return nodeModel;

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

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

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