Rendering/RetroArcadePostProcess.cs
using System;
using Sandbox.Rendering;

namespace BlockParty;

/// <summary>CRT/arcade treatment for the centered game square. The frame is first downsampled to
/// the arena's native pixel grid, then upscaled back with scanlines, halation, curvature and a
/// vignette — all locked to game pixels, so nothing beats against the sprite art.</summary>
[Title( "Retro Arcade Screen" )]
[Category( "Post Processing" )]
[Icon( "tv" )]
public sealed class RetroArcadePostProcess : BasePostProcess
{
	private static readonly Material DownsampleShader = Material.FromShader( "shaders/retro_downsample.shader" );
	private static readonly Material CrtShader = Material.FromShader( "shaders/retro_arcade.shader" );

	/// <summary>Treat the whole screen (pillarboxes and any UI in them included) instead of just
	/// the centered arena square.</summary>
	[Property] public bool ApplyToEntireScreen { get; set; }

	/// <summary>Rows in the virtual pixel grid the frame is quantized to. Arena.HEIGHT gives the
	/// authentic one-scanline-per-art-pixel look; note that anything not on the 240 grid
	/// (subpixel motion, UI text) gets snapped to it.</summary>
	[Property, Range( 60, 480 )] public int VirtualRows { get; set; } = Arena.HEIGHT;

	[Property, Range( 0f, 1f )] public float ScanlineStrength { get; set; } = 0.175f;
	[Property, Range( 0f, 0.1f )] public float Curvature { get; set; } = 0.013f;
	[Property, Range( 0f, 1f )] public float VignetteStrength { get; set; } = 0.25f;
	[Property, Range( 0f, 1f )] public float HalationStrength { get; set; } = 0.25f;

	/// <summary>Seconds to fade the quantization out when a stage wants crisp text.</summary>
	[Property, Range( 0.01f, 2f )] public float QuantizeFadeOutTime { get; set; } = 0.05f;
	/// <summary>Seconds to snap the quantization back in when gameplay resumes.</summary>
	[Property, Range( 0.01f, 2f )] public float QuantizeFadeInTime { get; set; } = 0.04f;

	/// <summary>Current quantize crossfade (1 = full 240px look, 0 = crisp frame under the same
	/// tube). Follows StageBase.WantsQuantize; curvature and halation ride this fade (they smear
	/// or jag crisp UI), while scanlines/vignette never fade — except via
	/// <see cref="EffectBlend"/>, which takes the whole tube down.</summary>
	public float QuantizeBlend { get; private set; } = 1f;

	/// <summary>Master crossfade for the entire CRT treatment (1 = full effect, 0 = untouched
	/// frame, at which point the passes are skipped). Fades out while the GIF-export overlay is
	/// up so its dense controls stay readable.</summary>
	public float EffectBlend { get; private set; } = 1f;

	private GameManager _manager;

	protected override void OnEnabled()
	{
		_manager = Components.Get<GameManager>( FindMode.InAncestors );
	}

	protected override void OnUpdate()
	{
		float target = (_manager?.Stage?.WantsQuantize ?? true) ? 1f : 0f;
		float time = target > QuantizeBlend ? QuantizeFadeInTime : QuantizeFadeOutTime;
		QuantizeBlend = QuantizeBlend.Approach( target, Time.Delta / MathF.Max( time, 0.01f ) );

		float effectTarget = (_manager?.IsGifPreviewActive ?? false) ? 0f : 1f;
		float effectTime = effectTarget > EffectBlend ? QuantizeFadeInTime : QuantizeFadeOutTime;
		EffectBlend = EffectBlend.Approach( effectTarget, Time.Delta / MathF.Max( effectTime, 0.01f ) );
	}

	/// <summary>Screen-pixel rect the effect covers (the arena square, or the extended full-screen
	/// region). SoftwareCursor uses this to decide where the OS cursor gets replaced.</summary>
	public Rect ScreenRegion
	{
		get
		{
			ComputeRegion( out float regionLeft, out float regionWidth, out _, out _ );
			return new Rect( regionLeft * Screen.Width, 0f, regionWidth * Screen.Width, Screen.Height );
		}
	}

	// The grid is always defined by the arena square (height-by-height, VirtualRows cells tall)
	// so the arena stays pixel-perfect. Full-screen mode extends that same grid sideways by a
	// whole number of columns, anchored to the square's edges — the outermost column may hang
	// partially off-screen rather than ever shrinking or shifting the cells.
	private void ComputeRegion( out float regionLeft, out float regionWidth, out int rows, out int cols )
	{
		float screenWidth = MathF.Max( Screen.Width, 1f );
		float screenHeight = MathF.Max( Screen.Height, 1f );

		rows = Math.Clamp( VirtualRows, 16, 1080 );
		float squareWidth = MathF.Min( screenHeight / screenWidth, 1f );
		float cellWidth = squareWidth / rows;

		int sideCols = ApplyToEntireScreen
			? (int)MathF.Ceiling( (1f - squareWidth) * 0.5f / cellWidth )
			: 0;
		cols = rows + 2 * sideCols;
		regionWidth = cols * cellWidth;
		regionLeft = (1f - regionWidth) * 0.5f;
	}

	public override void Render()
	{
		if ( Screen.Width <= 0f || Screen.Height <= 0f ) return;

		// Fully faded out (GIF-export overlay): every parameter below is zero, making the CRT
		// pass a mathematical identity — skip the render work entirely.
		if ( EffectBlend <= 0f ) return;

		ComputeRegion( out float regionLeft, out float regionWidth, out int rows, out int cols );

		var commands = new CommandList( "BlockParty Retro Arcade" );
		var frame = commands.Attributes.GrabFrameTexture( "SourceTexture" );
		// Extra mips feed the halation halo; the pooled texture is cleared first so gaussian
		// mips near the image edge pull in black instead of stale pool contents.
		var lowRes = commands.GetRenderTarget( "RetroLowRes", cols, rows,
			ImageFormat.Default, ImageFormat.None, MultisampleAmount.MultisampleNone, numMips: 4 );
		commands.Clear( lowRes, Color.Black );

		commands.Attributes.Set( "SourceTexture", frame.ColorTexture );
		commands.Attributes.Set( "RegionLeft", regionLeft );
		commands.Attributes.Set( "RegionWidth", regionWidth );
		commands.Attributes.Set( "VirtualSize", new Vector2( cols, rows ) );
		commands.SetRenderTarget( lowRes );
		commands.Blit( DownsampleShader );
		commands.GenerateMipMaps( lowRes, Graphics.DownsampleMethod.GaussianBlur );

		commands.ClearRenderTarget();
		commands.Attributes.Set( "LowResTexture", lowRes.ColorTexture );
		commands.Attributes.Set( "ScanlineStrength", ScanlineStrength * EffectBlend );
		commands.Attributes.Set( "VignetteStrength", VignetteStrength * EffectBlend );
		// Curvature and halation ride the quantize fade; scanlines and the vignette are the
		// permanent tube identity. The halo is built from the quantized low-res frame, so on crisp
		// screens (menus, help overlay) it reads as a blur/haze around text rather than phosphor
		// glow, and the curvature warp lands as duplicated/skipped pixel rows on the point-sampled
		// raw frame — jaggy UI edges, the very thing the quantize fade exists to avoid.
		commands.Attributes.Set( "Curvature", Curvature * QuantizeBlend * EffectBlend );
		commands.Attributes.Set( "HalationStrength", HalationStrength * QuantizeBlend * EffectBlend );
		commands.Attributes.Set( "QuantizeBlend", QuantizeBlend * EffectBlend );
		commands.Blit( CrtShader );

		commands.ReleaseRenderTarget( lowRes );
		InsertCommandList( commands, Stage.AfterUI, 9000, "BlockParty Retro Arcade" );
	}
}