Entities/VisionOccluder.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace BlockParty;

/// <summary>How a vision blocker covers the view.</summary>
public enum VisionBlockMode
{
	/// <summary>Cover everything directly behind the blocker from the player (a hard shadow).</summary>
	Umbra,
	/// <summary>Cover the whole arena EXCEPT a narrow widening cone (less than 90°) between the blocker and
	/// the player (plus the umbra behind it) — so the player sees only the corridor toward the blocker.</summary>
	Spotlight,
}

/// <summary>One vision blocker for a frame: its world rect and how it covers.</summary>
public readonly record struct VisionBlocker( RectF Rect, VisionBlockMode Mode );

/// <summary>
/// 2D line-of-sight cover. Given a set of vision-blocking rectangles and the player's position, it
/// renders an opaque "shadow" over the area behind each blocker (relative to the player), so the
/// player can't see what's on the far side of a blocker. Purely cosmetic — it reads only the render-
/// frame player position and the blocker rects, consumes no <see cref="Rng"/> and runs outside the
/// deterministic fixed step, so it has zero effect on the sim / replays.
///
/// The technique is the 2D analogue of a shadow volume (the reusable idea from the killbox
/// <c>ViewOccluder</c>): for each blocker we extrude its edges that FACE AWAY from the player out to a
/// large distance, forming the umbra, clip it to the arena, and fill it. Only back-facing edges are
/// cast so a single convex blocker's umbra tiles with no self-overlap. Geometry is rebuilt every frame
/// (the player moves) and drawn through a <see cref="SceneCustomObject"/> in the opaque pass with a
/// small unlit vertex-colour shader (depth-write hides the gameplay behind the blocker).
/// </summary>
public sealed class VisionOccluder : Component
{
	/// <summary>Supplies the current blockers each frame by appending them into the provided list. A
	/// delegate (not a cached list) so the caller can supply live, moving blockers (vision-blocking
	/// blocks); fill-a-list rather than returning an IEnumerable so the every-frame rebuild doesn't
	/// allocate an iterator.</summary>
	public Action<List<VisionBlocker>> BlockerSource { get; set; }

	/// <summary>The viewer whose position the shadows are cast from.</summary>
	public Player Player { get; set; }
	public Func<Player> PlayerSource { get; set; }

	/// <summary>Final cover colour, in LINEAR space — the shader outputs it directly (no conversion),
	/// so pass the same linear colour the walls use to match them exactly.</summary>
	public Color Cover { get; set; } = Color.Black;

	// Extrusion distance for the umbra edges. The arena is 240 across, so any fixed distance well
	// beyond its diagonal (~340) guarantees the shadow reaches past the far wall from any point; the
	// geometry is then clipped to the arena rect so it never bleeds into the pillarbox.
	private const float EXTRUDE = 2000f;

	// Phase-2 spotlight cone half-angle (radians)
	private const float SPOTLIGHT_HALF_ANGLE = 0.35f;

	private SceneCustomObject _so;
	private Material _material;
	private readonly List<VisionBlocker> _blockers = new();
	private readonly List<Vertex> _verts = new();
	// Scratch buffers for the per-edge umbra polygon and its arena clip (reused each frame).
	private readonly List<Vector2> _poly = new();
	private readonly List<Vector2> _clip = new();
	private readonly List<Vector2> _clipTmp = new();
	// Scratch buffers for the phase-2 spotlight convex hull.
	private readonly List<Vector2> _hullPts = new();
	private readonly List<Vector2> _hull = new();

	protected override void OnEnabled()
	{
		_material = Material.FromShader( "shaders/vision_cover.shader" );

		_so = new SceneCustomObject( Scene.SceneWorld )
		{
			// Identity transform: the vertices are already in world space (the shader projects them
			// directly), and the object isn't sorted by transform since it renders in the opaque pass.
			Transform = global::Transform.Zero,
			RenderOverride = OnRender,
		};

		// A custom scene object must declare which render pass it belongs to, or it isn't drawn into
		// the scene. Render OPAQUE (depth-write): the opaque pass composites reliably, and the depth it
		// writes at the cover's Z (above the walls) hides the gameplay AND wall spikes behind the
		// blocker. Nearer layers (portal flash, HUD text) still draw over it.
		_so.Flags.IsOpaque = true;
		_so.Flags.IsTranslucent = false;
		_so.Flags.CastShadows = false;
	}

	protected override void OnDisabled()
	{
		_so?.Delete();
		_so = null;
	}

	protected override void OnUpdate()
	{
		RebuildGeometry();
	}

	/// <summary>Refresh the cover from the current player and blockers. Call after configuring a new
	/// occluder so its first render is covered even before its first update.</summary>
	public void RebuildGeometry()
	{
		_verts.Clear();

		var player = PlayerSource?.Invoke() ?? Player;
		if ( !player.IsValid() || BlockerSource is null )
			return;

		_blockers.Clear();
		BlockerSource( _blockers );
		if ( _blockers.Count == 0 )
			return;

		Vector2 light = player.Pos;
		float z = Globals.DepthToZ( Globals.DEPTH_VISION_COVER );
		foreach ( var b in _blockers )
		{
			if ( b.Mode == VisionBlockMode.Spotlight )
				AppendSpotlight( b.Rect, light, z );
			else
				AppendBlockerShadow( b.Rect, light, z );
		}
	}

	/// <summary>Append the umbra of one rectangular blocker: extrude each of its back-facing edges away
	/// from the light, clip the resulting quad to the arena, and fill it. This is the phase-1 cover; the
	/// phase-2 "spotlight" is handled separately by <see cref="AppendSpotlight"/>.</summary>
	private void AppendBlockerShadow( RectF r, Vector2 light, float z )
	{
		// Light inside the blocker: the silhouette is undefined and there's nothing meaningful to
		// occlude, so skip it.
		if ( light.x > r.Left && light.x < r.Right && light.y > r.Bottom && light.y < r.Top )
			return;

		// Corners, counter-clockwise (with Y up). Edge i runs corner[i] -> corner[i+1]; for a CCW
		// winding its outward normal is (dy, -dx).
		Span<Vector2> corners = stackalloc Vector2[4]
		{
			r.BottomLeft,
			r.BottomRight,
			r.TopRight,
			r.TopLeft,
		};

		for ( int i = 0; i < 4; i++ )
		{
			Vector2 a = corners[i];
			Vector2 b = corners[( i + 1 ) % 4];

			Vector2 edge = b - a;
			Vector2 outward = new Vector2( edge.y, -edge.x );
			Vector2 mid = ( a + b ) * 0.5f;

			// Only edges facing away from the light cast the umbra; a convex blocker's back-facing
			// edges tile it with no overlap.
			if ( Vector2.Dot( outward, mid - light ) <= 0f )
				continue;

			_poly.Clear();
			_poly.Add( a );
			_poly.Add( b );
			_poly.Add( Extrude( b, light ) );
			_poly.Add( Extrude( a, light ) );

			ClipToArena( _poly, _clip );
			AppendPolygonFan( _clip, z );
		}
	}

	/// <summary>Push <paramref name="p"/> along the ray from <paramref name="light"/> through it to a
	/// point well past the arena bounds (the shadow's far edge on the corner's silhouette ray).</summary>
	private static Vector2 Extrude( Vector2 p, Vector2 light )
	{
		Vector2 dir = p - light;
		float len = dir.Length;
		if ( len < 0.0001f )
			return p; // degenerate (light exactly on the corner) — harmless zero-area contribution
		return p + dir / len * EXTRUDE;
	}

	/// <summary>Fan-triangulate a convex polygon into the vertex buffer at depth <paramref name="z"/>.</summary>
	private void AppendPolygonFan( List<Vector2> poly, float z )
	{
		if ( poly.Count < 3 )
			return;

		Color32 col = Cover;
		Vertex V( Vector2 p ) => new Vertex( new Vector3( p.x, p.y, z ), col );

		for ( int k = 1; k + 1 < poly.Count; k++ )
		{
			_verts.Add( V( poly[0] ) );
			_verts.Add( V( poly[k] ) );
			_verts.Add( V( poly[k + 1] ) );
		}
	}

	/// <summary>Sutherland–Hodgman clip of a convex polygon to the arena rect [0,W]x[0,H]. Keeps the
	/// cover strictly inside the arena so it can render above the walls (covering wall spikes) without
	/// bleeding into the pillarbox. Result is written to <paramref name="result"/>.</summary>
	private void ClipToArena( List<Vector2> input, List<Vector2> result )
	{
		ClipHalfPlane( input, result, 0 );    // left:   x >= 0
		ClipHalfPlane( result, _clipTmp, 1 ); // right:  x <= W
		ClipHalfPlane( _clipTmp, result, 2 ); // bottom: y >= 0
		ClipHalfPlane( result, _clipTmp, 3 ); // top:    y <= H
		result.Clear();
		result.AddRange( _clipTmp );
	}

	private static bool Inside( Vector2 p, int edge ) => edge switch
	{
		0 => p.x >= 0f,
		1 => p.x <= Arena.WIDTH,
		2 => p.y >= 0f,
		_ => p.y <= Arena.HEIGHT,
	};

	private static Vector2 Intersect( Vector2 a, Vector2 b, int edge )
	{
		float t = edge switch
		{
			0 => ( 0f - a.x ) / ( b.x - a.x ),
			1 => ( Arena.WIDTH - a.x ) / ( b.x - a.x ),
			2 => ( 0f - a.y ) / ( b.y - a.y ),
			_ => ( Arena.HEIGHT - a.y ) / ( b.y - a.y ),
		};
		return a + ( b - a ) * t;
	}

	private static void ClipHalfPlane( List<Vector2> src, List<Vector2> dst, int edge )
	{
		dst.Clear();
		int n = src.Count;
		if ( n == 0 )
			return;

		Vector2 prev = src[n - 1];
		bool prevIn = Inside( prev, edge );
		for ( int i = 0; i < n; i++ )
		{
			Vector2 cur = src[i];
			bool curIn = Inside( cur, edge );

			if ( curIn )
			{
				if ( !prevIn )
					dst.Add( Intersect( prev, cur, edge ) );
				dst.Add( cur );
			}
			else if ( prevIn )
			{
				dst.Add( Intersect( prev, cur, edge ) );
			}

			prev = cur;
			prevIn = curIn;
		}
	}

	/// <summary>Phase-2 "spotlight": the visible region is the block PLUS a cone opening from it toward
	/// (and past) the player; everything else in the arena is covered. Built as the convex hull of the
	/// block's four corners and two far points along the cone edges, whose arena complement is filled.
	/// This REPLACES the phase-1 umbra (the complement already covers behind the block).</summary>
	private void AppendSpotlight( RectF r, Vector2 light, float z )
	{
		// Player inside the blocker: nothing sensible to draw.
		if ( light.x > r.Left && light.x < r.Right && light.y > r.Bottom && light.y < r.Top )
			return;

		Vector2 center = new Vector2( ( r.Left + r.Right ) * 0.5f, ( r.Bottom + r.Top ) * 0.5f );
		Vector2 toPlayer = light - center;
		if ( toPlayer.Length < 0.0001f )
			return;
		Vector2 axis = toPlayer.Normal; // block -> player

		// Visible hull = the block's four corners (keeps the whole block visible) + two far points along
		// the cone edges, so the visible region widens from the block toward the player and reaches past
		// it to the arena edge.
		_hullPts.Clear();
		_hullPts.Add( r.BottomLeft );
		_hullPts.Add( r.BottomRight );
		_hullPts.Add( r.TopRight );
		_hullPts.Add( r.TopLeft );
		_hullPts.Add( center + Rotate( axis, SPOTLIGHT_HALF_ANGLE ) * EXTRUDE );
		_hullPts.Add( center + Rotate( axis, -SPOTLIGHT_HALF_ANGLE ) * EXTRUDE );

		ConvexHull( _hullPts, _hull );
		if ( _hull.Count < 3 )
			return;

		// Cover = arena minus the visible hull = union of each CCW hull edge's OUTER half-plane.
		int n = _hull.Count;
		for ( int i = 0; i < n; i++ )
		{
			Vector2 a = _hull[i];
			Vector2 b = _hull[( i + 1 ) % n];
			Vector2 outN = new Vector2( b.y - a.y, -( b.x - a.x ) ); // outward normal for a CCW polygon
			BuildArenaPoly( _poly );
			ClipPolyHalf( _poly, _clip, a, outN );
			AppendPolygonFan( _clip, z );
		}
	}

	private void BuildArenaPoly( List<Vector2> dst )
	{
		dst.Clear();
		dst.Add( new Vector2( 0f, 0f ) );
		dst.Add( new Vector2( Arena.WIDTH, 0f ) );
		dst.Add( new Vector2( Arena.WIDTH, Arena.HEIGHT ) );
		dst.Add( new Vector2( 0f, Arena.HEIGHT ) );
	}

	/// <summary>Clip a convex polygon to the half-plane { p : dot(p - linePoint, normal) >= 0 }.</summary>
	private static void ClipPolyHalf( List<Vector2> src, List<Vector2> dst, Vector2 linePoint, Vector2 normal )
	{
		dst.Clear();
		int n = src.Count;
		if ( n == 0 )
			return;

		Vector2 prev = src[n - 1];
		float prevD = Vector2.Dot( prev - linePoint, normal );
		for ( int i = 0; i < n; i++ )
		{
			Vector2 cur = src[i];
			float curD = Vector2.Dot( cur - linePoint, normal );
			bool curIn = curD >= 0f;
			bool prevIn = prevD >= 0f;

			if ( curIn )
			{
				if ( !prevIn )
					dst.Add( prev + ( cur - prev ) * ( prevD / ( prevD - curD ) ) );
				dst.Add( cur );
			}
			else if ( prevIn )
			{
				dst.Add( prev + ( cur - prev ) * ( prevD / ( prevD - curD ) ) );
			}

			prev = cur;
			prevD = curD;
		}
	}

	// Named static method, not a lambda in a static field (those break hotload).
	private static int HullSort( Vector2 p, Vector2 q ) => p.x != q.x ? p.x.CompareTo( q.x ) : p.y.CompareTo( q.y );

	/// <summary>Convex hull (Andrew's monotone chain) of <paramref name="pts"/> into
	/// <paramref name="hull"/>, ordered counter-clockwise. Mutates (sorts) <paramref name="pts"/>.</summary>
	private static void ConvexHull( List<Vector2> pts, List<Vector2> hull )
	{
		pts.Sort( HullSort );
		hull.Clear();
		int n = pts.Count;

		for ( int i = 0; i < n; i++ )
		{
			while ( hull.Count >= 2 && HullCross( hull[hull.Count - 2], hull[hull.Count - 1], pts[i] ) <= 0f )
				hull.RemoveAt( hull.Count - 1 );
			hull.Add( pts[i] );
		}

		int lower = hull.Count + 1;
		for ( int i = n - 2; i >= 0; i-- )
		{
			while ( hull.Count >= lower && HullCross( hull[hull.Count - 2], hull[hull.Count - 1], pts[i] ) <= 0f )
				hull.RemoveAt( hull.Count - 1 );
			hull.Add( pts[i] );
		}

		hull.RemoveAt( hull.Count - 1 ); // last point repeats the first
	}

	private static float HullCross( Vector2 o, Vector2 a, Vector2 b )
		=> ( a.x - o.x ) * ( b.y - o.y ) - ( a.y - o.y ) * ( b.x - o.x );

	private static Vector2 Rotate( Vector2 v, float radians )
	{
		float c = MathF.Cos( radians ), s = MathF.Sin( radians );
		return new Vector2( v.x * c - v.y * s, v.x * s + v.y * c );
	}

	private void OnRender( SceneObject o )
	{
		if ( _verts.Count == 0 || _material is null )
			return;

		Graphics.Draw( _verts, _verts.Count, _material, primitiveType: Graphics.PrimitiveType.Triangles );
	}
}