Code/Sprite/SpriteResource.cs
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;

namespace SpriteTools;

[AssetType( Name = "2D Sprite", Extension = "spr", Category = "SpriteTools" )]
public partial class SpriteResource : GameResource
{
	/// <summary>
	/// A list of animations that are available for this sprite.
	/// </summary>
	public List<SpriteAnimation> Animations { get; set; } = new()
	{
		new SpriteAnimation()
		{
			Name = "animation_01"
		}
	};

	/// <summary>
	/// Returns a specific animation by name (or null if it doesn't exist).
	/// </summary>
	/// <param name="name"></param>
	/// <returns></returns>
	public SpriteAnimation GetAnimation ( string name )
	{
		return Animations.FirstOrDefault( x => x.Name == name );
	}

	/// <summary>
	/// Returns a list of names for every attachment this Sprite has.
	/// </summary>
	public List<string> GetAttachmentNames ()
	{
		var attachmentNames = new List<string>();
		foreach ( var animation in Animations )
		{
			foreach ( var attachment in animation.Attachments )
			{
				if ( !attachmentNames.Contains( attachment.Name ) )
					attachmentNames.Add( attachment.Name );
			}
		}
		return attachmentNames;
	}

	/// <summary>
	/// Try to load a sprite from a file path.
	/// </summary>
	/// <param name="path">The path to the sprite resource</param>
	public static SpriteResource Load ( string path )
	{
		return ResourceLibrary.Get<SpriteResource>( path );
	}

	/// <summary>
	/// Returns the first frame of a sprite resource as a texture.
	/// </summary>
	/// <returns></returns>
	public Texture GetPreviewTexture ()
	{
		var anim = Animations.FirstOrDefault();
		if ( anim is null || anim.Frames.Count == 0 ) return Texture.Transparent;
		if ( anim.Frames.Count == 1 ) return Texture.LoadFromFileSystem( anim.Frames[0].FilePath, FileSystem.Mounted );
		var atlas = TextureAtlas.FromAnimation( anim );
		return atlas.GetTextureFromFrame( 0 );
	}

	/// <summary>
	/// Returns a list of all the texture paths used by this sprite.
	/// </summary>
	/// <returns></returns>
	public List<string> GetAllTexturePaths ()
	{
		var paths = new List<string>();
		foreach ( var animation in Animations )
		{
			foreach ( var frame in animation.Frames )
			{
				if ( paths.Contains( frame.FilePath ) ) continue;
				paths.Add( frame.FilePath );
			}
		}
		return paths;
	}

	protected override Bitmap CreateAssetTypeIcon ( int width, int height )
	{
		return CreateSimpleAssetTypeIcon( "emoji_emotions", width, height, "#67ac5c", "#1a2c17" );
	}

	/// <summary>
	/// The different types of looping for sprite animation.
	/// </summary>
	public enum LoopMode
	{
		/// <summary>
		/// The animation will play from start to finish and then stop.
		/// </summary>
		[Icon( "not_interested" )]
		None,

		/// <summary>
		/// The animation will play from start to finish and then loop back to the start.
		/// </summary>
		[Icon( "loop" ), Title( "Loop" )]
		Forward,

		/// <summary>
		/// The animation will play from start to finish and then backwards from finish to start before looping.
		/// </summary>
		[Icon( "sync_alt" )]
		PingPong
	}

}

public class SpriteAnimation
{
	/// <summary>
	/// The name of the animation. This is used as a key to reference the animation.
	/// </summary>
	[Property, Title( "Animation Name" )]
	private string _nameProp => Name;

	/// <summary>
	/// The speed of the animation. This is the number of frames per second.
	/// </summary>
	[Property, Range( 0f, 999f, true, false ), Step( 0.01f )]
	public float FrameRate { get; set; } = 15.0f;

	/// <summary>
	/// The origin of the sprite. This is used to determine where the sprite is drawn relative to/scaled around.
	/// </summary>
	[Property, Range( 0f, 1f, true, false ), Step( 0.01f )] public Vector2 Origin { get; set; } = new Vector2( 0.5f, 0.5f );

	/// <summary>
	/// Whether or not the animation should loop. Replaced with LoopMode.
	/// </summary>
	[System.Obsolete( "Use LoopMode instead." )]
	public bool Looping { get; set; } = false;

	/// <summary>
	/// Whether or not the animation should loop and how.
	/// </summary>
	[Property, Title( "Looping" ), JsonIgnore]
	public SpriteResource.LoopMode LoopMode { get; set; } = SpriteResource.LoopMode.Forward;

	[Hide, JsonInclude]
	private int LoopModeVal
	{
		get => (int)LoopMode;
		set => LoopMode = (SpriteResource.LoopMode)value;
	}

	/// <summary>
	/// The name of the animation. This is used as a key to reference the animation.
	/// </summary>
	public string Name { get; set; } = "";

	/// <summary>
	/// The list of frames that make up the animation. These are image paths.
	/// </summary>
	public List<SpriteAnimationFrame> Frames { get; set; } = new();

	/// <summary>
	/// The list of attachment names that are available for this animation.
	/// </summary>
	public List<SpriteAttachment> Attachments { get; set; } = new();

	public int? LoopStart { get; set; } = null;
	public int? LoopEnd { get; set; } = null;

	public SpriteAnimation ()
	{
		Frames = new List<SpriteAnimationFrame>();
		Attachments = new List<SpriteAttachment>();
	}

	public SpriteAnimation ( string name )
	{
		Name = name;
		Frames = new List<SpriteAnimationFrame>();
		Attachments = new List<SpriteAttachment>();
	}

	public int GetLoopStart ()
	{
		if ( LoopStart is null ) return 0;
		return LoopStart.Value;
	}

	public int GetLoopEnd ()
	{
		if ( LoopEnd is null ) return Frames.Count - 1;
		return LoopEnd.Value;
	}

	public Vector2 GetAttachmentPosition ( string attachment, int index )
	{
		var attach = Attachments?.FirstOrDefault( x => x.Name == attachment );
		if ( attach is null ) return Vector2.Zero;
		if ( index < 0 ) return attach.Points.FirstOrDefault();
		if ( index >= attach.Points.Count ) return attach.Points.LastOrDefault();
		return attach.Points[index];
	}
}

public class SpriteAnimationFrame
{
	public string FilePath { get; set; }
	public List<string> Events { get; set; }
	public Rect SpriteSheetRect { get; set; }

	public SpriteAnimationFrame ( string filePath )
	{
		FilePath = filePath;
		Events = new List<string>();
		SpriteSheetRect = new Rect( 0, 0, 0, 0 );
	}

	public SpriteAnimationFrame Copy ()
	{
		var copy = new SpriteAnimationFrame( FilePath );
		copy.Events = new List<string>( Events );
		copy.SpriteSheetRect = SpriteSheetRect;
		return copy;
	}
}

public class SpriteAttachment
{
	/// <summary>
	/// The name of the attachment point. This is used as a key to reference the attachment point.
	/// </summary>
	public string Name { get; set; }

	/// <summary>
	/// The color of the attachment point. This is purely used as a visual aid in the Sprite Editor.
	/// </summary>
	public Color Color { get; set; }

	/// <summary>
	/// A list of points corresponding to the attachment point's position in each frame.
	/// </summary>
	public List<Vector2> Points { get; set; }

	/// <summary>
	/// Whether or not the attachment point is visible in the Sprite Editor.
	/// </summary>
	public bool Visible { get; set; }

	public SpriteAttachment ()
	{
		Name = "new attachment";
		Color = Color.Red;
		Points = new List<Vector2>();
		Visible = true;
	}

	public SpriteAttachment ( string name )
	{
		Name = name;
		Color = Color.Red;
		Points = new List<Vector2>();
		Visible = true;
	}

	public SpriteAttachment Copy ()
	{
		var copy = new SpriteAttachment( Name );
		copy.Color = Color;
		copy.Points = new List<Vector2>( Points );
		copy.Visible = Visible;
		return copy;
	}

}