PlaySim.cs
namespace PixelPusher;

public static class PlaySim
{
	public const float ViewW = 320f;
	public const float ViewH = 180f;
	public const float BodyW = 10f;
	public const float BodyH = 14f;
	const float CoyoteMax = 0.09f;
	const float BufferMax = 0.1f;

	public static bool Active;
	public static float Px;
	public static float Py;
	public static float Vx;
	public static float Vy;
	public static bool Grounded;
	public static bool FaceLeft;
	public static int Coins;
	public static int CoinTotal;
	public static int Deaths;
	public static float TimePlayed;
	public static bool Won;
	public static bool Dead;
	public static float CamX;
	public static float CamY;
	public static float Anim;
	public static int Version;
	public static bool Walk3D;
	public static float Pz;
	public static string SignText = "";

	static StageDoc _stage;
	static TilesetDoc _tiles;
	static float _coyote;
	static float _buffer;
	static float _spawnX;
	static float _spawnY;
	static float _deadT;
	static readonly HashSet<int> _got = new();

	public static bool Collected( int tx, int ty ) => _got.Contains( ty * 1024 + tx );

	public static string TimeText
	{
		get
		{
			var t = TimePlayed;
			var m = (int)(t / 60f);
			var s = t - m * 60f;
			return $"{m}:{s:00.00}";
		}
	}

	public static void Start( StageDoc stage, bool fromPlaytest, int dropX = -1, int dropY = -1, bool walk3d = false )
	{
		_stage = stage ?? Desk.Stage;
		_tiles = Desk.Tileset;
		if ( _stage is null )
			return;
		Active = true;
		Won = false;
		Dead = false;
		Walk3D = walk3d;
		Coins = 0;
		Deaths = 0;
		TimePlayed = 0;
		Vx = 0;
		Vy = 0;
		Pz = WorldStudio.SlabDepth * 0.4f;
		Anim = 0;
		SignText = "";
		_got.Clear();
		CoinTotal = _stage.Entities.Count( e => e.Kind == "coin" );

		var start = _stage.Find( "start" );
		if ( fromPlaytest && dropX >= 0 && dropY >= 0 )
		{
			_spawnX = dropX * _stage.TileSize + 3;
			_spawnY = dropY * _stage.TileSize;
		}
		else if ( start is not null )
		{
			_spawnX = start.Tx * _stage.TileSize + 3;
			_spawnY = start.Ty * _stage.TileSize;
		}
		else
		{
			_spawnX = 16;
			_spawnY = 16;
		}

		Respawn();
		Version++;
	}

	public static void Tick()
	{
		if ( !Active || _stage is null )
			return;
		if ( GameFlow.Screen is not (GameScreen.Play2D or GameScreen.Play3D) )
			return;

		if ( Won )
			return;

		if ( Dead )
		{
			_deadT -= Time.Delta;
			if ( _deadT <= 0f )
				Respawn();
			return;
		}

		var dt = Math.Min( Time.Delta, 1f / 30f );
		TimePlayed += dt;
		Anim += dt;
		var wasGrounded = Grounded;

		var run = _stage.MaxRun;
		var accel = 2400f;
		var friction = 1800f;
		var grav = _stage.Gravity;
		var jump = _stage.JumpVel;

		var ax = 0f;
		if ( Input.Down( "Left" ) ) ax -= 1f;
		if ( Input.Down( "Right" ) ) ax += 1f;
		if ( ax < 0 ) FaceLeft = true;
		else if ( ax > 0 ) FaceLeft = false;

		if ( ax != 0 )
			Vx = Approach( Vx, ax * run, accel * dt );
		else
			Vx = Approach( Vx, 0, friction * dt );

		Vy += grav * dt;
		if ( Vy > 900f )
			Vy = 900f;

		if ( Grounded )
			_coyote = CoyoteMax;
		else
			_coyote -= dt;

		if ( Input.Pressed( "Jump" ) )
			_buffer = BufferMax;
		else
			_buffer -= dt;

		if ( _buffer > 0f && _coyote > 0f )
		{
			Vy = -jump;
			Grounded = false;
			_coyote = 0;
			_buffer = 0;
			ChipVoices.Jump();
		}

		if ( !Input.Down( "Jump" ) && Vy < -40f )
			Vy *= 0.55f;

		MoveAxis( Vx * dt, 0 );
		Grounded = false;
		MoveAxis( 0, Vy * dt );
		if ( !wasGrounded && Grounded )
			ChipVoices.Land();

		if ( Walk3D )
			MoveDepth( dt );

		TouchEntities();

		if ( Py > _stage.Height * _stage.TileSize + 32 )
			Kill();

		if ( Walk3D )
		{
			var tx = Px + Pz * 0.55f - ViewW * 0.5f;
			var ty = Py - Pz * 0.28f - ViewH * 0.6f;
			CamX = CamX + (tx - CamX) * Math.Min( 1f, 8f * dt );
			CamY = CamY + (ty - CamY) * Math.Min( 1f, 8f * dt );
			WorldStudio.CamX = CamX;
			WorldStudio.CamY = CamY;
		}
		else
		{
			var look = FaceLeft ? -28f : 28f;
			var tx = Px + BodyW * 0.5f - ViewW * 0.5f + look;
			var ty = Py + BodyH * 0.5f - ViewH * 0.65f;
			CamX = CamX + (tx - CamX) * Math.Min( 1f, 8f * dt );
			CamY = CamY + (ty - CamY) * Math.Min( 1f, 8f * dt );
		}
		ClampCam();
		Version++;
	}

	static void MoveDepth( float dt )
	{
		var az = 0f;
		if ( Input.Down( "Forward" ) ) az += 1f;
		if ( Input.Down( "Backward" ) ) az -= 1f;
		if ( az == 0 )
			return;

		var next = Pz + az * _stage.MaxRun * 0.55f * dt;
		Pz = Math.Clamp( next, 2f, WorldStudio.SlabDepth - 6f );
	}

	static void MoveAxis( float dx, float dy )
	{
		Px += dx;
		Py += dy;

		var ts = _stage.TileSize;
		var left = Px;
		var right = Px + BodyW;
		var top = Py;
		var bot = Py + BodyH;
		var x0 = Math.Max( 0, (int)MathF.Floor( left / ts ) );
		var x1 = Math.Min( _stage.Width - 1, (int)MathF.Floor( (right - 0.001f) / ts ) );
		var y0 = Math.Max( 0, (int)MathF.Floor( top / ts ) );
		var y1 = Math.Min( _stage.Height - 1, (int)MathF.Floor( (bot - 0.001f) / ts ) );

		for ( var ty = y0; ty <= y1; ty++ )
		for ( var tx = x0; tx <= x1; tx++ )
		{
			var id = _stage.TileAt( tx, ty );
			if ( id == 0 )
				continue;

			var solid = _tiles is not null && _tiles.IsSolid( id );
			var oneWay = _tiles is not null && _tiles.IsOneWay( id );
			var hazard = _tiles is not null && _tiles.IsHazard( id );
			var spring = _tiles is not null && _tiles.IsSpring( id );

			var tileL = tx * ts;
			var tileR = tileL + ts;
			var tileT = ty * ts;
			var tileB = tileT + ts;

			if ( hazard && Overlaps( left, top, right, bot, tileL + 2, tileT + 2, tileR - 2, tileB - 2 ) )
			{
				Kill();
				return;
			}

			if ( spring && dy > 0 && bot > tileT && top < tileT + 6 && right > tileL + 2 && left < tileR - 2 )
			{
				Py = tileT - BodyH;
				Vy = -_stage.JumpVel * 1.35f;
				Grounded = false;
				continue;
			}

			if ( oneWay )
			{
				if ( dy <= 0 )
					continue;
				if ( bot - dy > tileT + 4 )
					continue;
				if ( right <= tileL + 1 || left >= tileR - 1 )
					continue;
				Py = tileT - BodyH;
				Vy = 0;
				Grounded = true;
				bot = Py + BodyH;
				continue;
			}

			if ( !solid )
				continue;

			if ( !Overlaps( left, top, right, bot, tileL, tileT, tileR, tileB ) )
				continue;

			if ( dx > 0 )
			{
				Px = tileL - BodyW;
				Vx = 0;
				left = Px;
				right = Px + BodyW;
			}
			else if ( dx < 0 )
			{
				Px = tileR;
				Vx = 0;
				left = Px;
				right = Px + BodyW;
			}
			else if ( dy > 0 )
			{
				Py = tileT - BodyH;
				Vy = 0;
				Grounded = true;
				top = Py;
				bot = Py + BodyH;
			}
			else if ( dy < 0 )
			{
				Py = tileB;
				Vy = 0;
				top = Py;
				bot = Py + BodyH;
			}
		}
	}

	static void TouchEntities()
	{
		var ts = _stage.TileSize;
		var left = Px;
		var right = Px + BodyW;
		var top = Py;
		var bot = Py + BodyH;

		var reading = "";
		foreach ( var e in _stage.Entities )
		{
			var el = e.Tx * ts + 2;
			var et = e.Ty * ts + 2;
			var er = el + ts - 4;
			var eb = et + ts - 4;
			if ( !Overlaps( left, top, right, bot, el, et, er, eb ) )
				continue;

			if ( e.Kind == "coin" )
			{
				var key = e.Ty * 1024 + e.Tx;
				if ( _got.Add( key ) )
				{
					Coins++;
					ChipVoices.Coin();
					GameFlow.Bump();
				}
			}
			else if ( e.Kind == "spring" && Vy >= 0 )
			{
				Vy = -_stage.JumpVel * 1.35f;
				Grounded = false;
				ChipVoices.Spring();
			}
			else if ( e.Kind == "check" )
			{
				_spawnX = e.Tx * ts + 3;
				_spawnY = e.Ty * ts;
			}
			else if ( e.Kind == "sign" )
			{
				reading = string.IsNullOrWhiteSpace( e.Data ) ? "..." : e.Data;
			}
			else if ( e.Kind == "exit" )
			{
				if ( _stage.Goal == "grab" && Coins < CoinTotal )
				{
					reading = "GRAB EVERY COIN FIRST";
					continue;
				}

				Won = true;
				Active = true;
				ChipVoices.Clear();
				GameFlow.OnStageCleared();
				return;
			}
		}

		if ( reading != SignText )
		{
			SignText = reading;
			GameFlow.Bump();
		}
	}

	static void Kill()
	{
		if ( Dead || Won )
			return;
		Dead = true;
		Deaths++;
		_deadT = 0.55f;
		Vx = 0;
		Vy = 0;
		ChipVoices.Hurt();
		GameFlow.Bump();
	}

	static void Respawn()
	{
		Dead = false;
		Px = _spawnX;
		Py = _spawnY;
		Vx = 0;
		Vy = 0;
		Grounded = false;
		Pz = WorldStudio.SlabDepth * 0.4f;
		CamX = Px - ViewW * 0.5f;
		CamY = Py - ViewH * 0.65f;
		ClampCam();
	}

	static void ClampCam()
	{
		var worldW = _stage.Width * _stage.TileSize;
		var worldH = _stage.Height * _stage.TileSize;
		CamX = Math.Clamp( CamX, 0, Math.Max( 0, worldW - ViewW ) );
		CamY = Math.Clamp( CamY, 0, Math.Max( 0, worldH - ViewH ) );
	}

	static float Approach( float v, float t, float max )
	{
		if ( v < t )
			return Math.Min( v + max, t );
		return Math.Max( v - max, t );
	}

	static bool Overlaps( float al, float at, float ar, float ab, float bl, float bt, float br, float bb )
		=> al < br && ar > bl && at < bb && ab > bt;
}