Sprite2D.cs

A Component that wraps the engine SpriteRenderer and Sprite resource to provide a 2D sprite layer replacement for older SpriteTools code. It creates a hidden child GameObject with a SpriteRenderer, exposes properties like Tint, FlashTint, PlaybackSpeed, SpriteFlags, shadow settings and animation control, and forwards renderer events back to assigned actions.

Native Interop
using Sandbox;
using System;
using System.Linq;
using System.Text.Json.Serialization;

/// <summary>
/// 2D sprite component for this project. It wraps the engine's native <see cref="SpriteRenderer"/>
/// and <see cref="Sprite"/> resource, replicating the behaviour previously provided by SpriteTools'
/// SpriteRendererLayer so the project no longer depends on that library.
///
/// Like the old layer, it spawns a hidden child GameObject holding a native SpriteRenderer configured
/// for a top-down 2D view (Billboard = None plus a rotation offset derived from <see cref="UpDirection"/>)
/// and exposes the same property surface the game code and prefabs already use (Tint, FlashTint,
/// SpriteFlags, etc.). This keeps the exact visual/behavioural result while dropping the dependency.
/// </summary>
[Category( "2D" )]
[Icon( "emoji_emotions" )]
[Title( "2D Sprite" )]
public sealed class Sprite2D : Component, Component.ExecuteInEditor
{
	/// <summary>Which world axis the sprite treats as "up" when orienting itself for the 2D camera.</summary>
	public enum Axis { XPositive, XNegative, YPositive, YNegative, ZPositive, ZNegative }

	/// <summary>How the sprite participates in shadow rendering.</summary>
	public enum ShadowRenderType { On, Off, ShadowsOnly }

	/// <summary>
	/// The sprite resource that this component renders.
	/// </summary>
	[Property]
	public Sprite Sprite
	{
		get => _sprite;
		set { _sprite = value; ApplySprite(); }
	}
	Sprite _sprite;

	/// <summary>
	/// Which axis is treated as "up". Controls the rotation offset applied to the underlying renderer so
	/// the sprite faces the 2D camera correctly.
	/// </summary>
	[Property]
	public Axis UpDirection
	{
		get => _upDirection;
		set
		{
			_upDirection = value;
			switch ( value )
			{
				case Axis.XPositive: _rotationOffset = Rotation.From( 0, 180, 0 ); break;
				case Axis.XNegative: _rotationOffset = Rotation.From( 0, 0, 0 ); break;
				case Axis.YPositive: _rotationOffset = Rotation.From( 0, -90, 0 ); break;
				case Axis.YNegative: _rotationOffset = Rotation.From( 0, 90, 0 ); break;
				case Axis.ZPositive: _rotationOffset = Rotation.From( 90, 0, 0 ); break;
				case Axis.ZNegative: _rotationOffset = Rotation.From( -90, 0, 0 ); break;
			}
			ApplyRotation();
		}
	}
	Axis _upDirection = Axis.YPositive;
	Rotation _rotationOffset = Rotation.From( 0, -90, 0 );

	/// <summary>The colour tint of the sprite (multiplied with the texture). Maps to SpriteRenderer.Color.</summary>
	[Property, Category( "Visuals" )]
	public Color Tint
	{
		get => _tint;
		set { if ( _tint == value ) return; _tint = value; ApplyColor(); }
	}
	Color _tint = Color.White;

	/// <summary>The flash/overlay colour of the sprite. Maps to SpriteRenderer.OverlayColor.</summary>
	[Property, Category( "Visuals" )]
	public Color FlashTint
	{
		get => _flashTint;
		set { if ( _flashTint == value ) return; _flashTint = value; ApplyColor(); }
	}
	Color _flashTint = Color.White.WithAlpha( 0 );

	/// <summary>Playback speed of the animation. 0 pauses, negative plays in reverse.</summary>
	[Property]
	public float PlaybackSpeed
	{
		get => _playbackSpeed;
		set { _playbackSpeed = value; ApplyPlaybackSpeed(); }
	}
	float _playbackSpeed = 1.0f;

	[Property] public bool UsePixelScale { get; set; } = false;

	/// <summary>When enabled, keeps the depth (Z) scale locked to the X scale in play mode.</summary>
	[Property] public bool AutoScale { get; set; } = true;

	[Property, Category( "Visuals" )]
	public ShadowRenderType CastShadows
	{
		get => _castShadows;
		set { _castShadows = value; ApplyShadows(); }
	}
	ShadowRenderType _castShadows = ShadowRenderType.On;

	[Property, Category( "Visuals" )]
	public SpriteFlags SpriteFlags
	{
		get => _spriteFlags;
		set { if ( _spriteFlags == value ) return; _spriteFlags = value; ApplySpriteFlags(); }
	}
	SpriteFlags _spriteFlags = SpriteFlags.None;

	/// <summary>The animation currently playing (or the queued one if the renderer isn't created yet).</summary>
	[JsonIgnore]
	public Sprite.Animation CurrentAnimation
	{
		get => _spriteRenderer?.CurrentAnimation ?? Sprite?.GetAnimation( _queuedAnimationName );
		set { PlayAnimation( value.Name ); }
	}

	[Property, Category( "Sprite" ), Title( "Current Animation" ), Editor( "sprite_animation_name" )]
	string StartingAnimationName
	{
		get => CurrentAnimation?.Name ?? (_sprite?.Animations?.FirstOrDefault()?.Name ?? "");
		set
		{
			if ( Sprite == null ) return;
			var animation = Sprite.Animations.Find( a => a.Name.ToLowerInvariant() == value.ToLowerInvariant() );
			if ( animation == null ) { _queuedAnimationName = value; return; }
			PlayAnimation( animation.Name );
		}
	}
	string _queuedAnimationName = "";

	[Property, Category( "Actions" )]
	public Action<string> OnAnimationComplete { get; set; }

	[Property, Category( "Actions" )]
	public Action<string> OnBroadcastMessage { get; set; }

	public BBox Bounds
	{
		get
		{
			var size = new Vector2( 50, 50 );
			BBox bbox = new BBox( new Vector3( -size.x, -size.y, -0.1f ), new Vector3( size.x, size.y, 0.1f ) );
			var origin = (CurrentAnimation?.Origin ?? new Vector2( 0.5f, 0.5f )) - new Vector2( 0.5f, 0.5f );
			bbox = bbox.Translate( new Vector3( origin.y, origin.x, 0 ) * new Vector3( -size.x * 2f, -size.y * 2f, 1f ) );
			return bbox;
		}
	}

	/// <summary>The index of the frame currently being displayed.</summary>
	public int CurrentFrameIndex
	{
		get => _spriteRenderer?.CurrentFrameIndex ?? 0;
		set { if ( _spriteRenderer.IsValid() ) _spriteRenderer.CurrentFrameIndex = value; }
	}

	/// <summary>The native SpriteRenderer doing the actual rendering (hosted on a hidden child object).</summary>
	public SpriteRenderer Renderer => _spriteRenderer;
	SpriteRenderer _spriteRenderer;

	protected override void OnAwake()
	{
		base.OnAwake();
		if ( !_spriteRenderer.IsValid() )
			CreateSpriteRenderer();
	}

	protected override void OnEnabled()
	{
		base.OnEnabled();
		if ( _spriteRenderer.IsValid() )
		{
			_spriteRenderer.Enabled = true;
			_spriteRenderer.AlphaCutoff = 0f;
			_spriteRenderer.Shadows = false;
		}
	}

	protected override void OnDisabled()
	{
		base.OnDisabled();
		if ( _spriteRenderer.IsValid() )
			_spriteRenderer.Enabled = false;
	}

	protected override void OnDestroy()
	{
		base.OnDestroy();
		if ( _spriteRenderer.IsValid() )
		{
			_spriteRenderer.GameObject?.Destroy();
			_spriteRenderer = null;
		}
	}

	protected override void OnPreRender()
	{
		base.OnPreRender();
		if ( !Game.IsPlaying ) return;

		// Keep the sprite's depth (Z) scale in sync with its planar scale so uniform 2D scaling behaves
		// as it did with the old SpriteTools layer.
		if ( LocalScale.z == 1 && LocalScale.y != 1 )
			LocalScale = new Vector3( LocalScale.x, LocalScale.y, LocalScale.y );
		else if ( LocalScale.x == 1 && LocalScale.z == 1 && LocalScale.y != 1 )
			LocalScale = new Vector3( LocalScale.y, LocalScale.y, LocalScale.y );
		else if ( AutoScale )
			LocalScale = new Vector3( LocalScale.x, LocalScale.y, LocalScale.x );
	}

	protected override void DrawGizmos()
	{
		base.DrawGizmos();
		if ( Sprite is null ) return;

		Gizmo.Transform = Gizmo.Transform.WithRotation( WorldRotation * _rotationOffset );
		var bbox = Bounds;
		Gizmo.Hitbox.BBox( bbox );

		if ( Gizmo.IsHovered || Gizmo.IsSelected )
		{
			bbox.Mins.z = 0;
			bbox.Maxs.z = 0.0f;
			Gizmo.Draw.Color = Gizmo.IsSelected ? Color.White : Color.Orange;
			Gizmo.Draw.LineBBox( bbox );
		}
	}

	void ApplySprite()
	{
		if ( !_spriteRenderer.IsValid() ) return;
		_spriteRenderer.Sprite = _sprite;
		if ( !string.IsNullOrEmpty( _queuedAnimationName ) )
		{
			_spriteRenderer.StartingAnimationName = _queuedAnimationName;
			_queuedAnimationName = null;
		}
	}

	void ApplyColor()
	{
		if ( !_spriteRenderer.IsValid() ) return;
		_spriteRenderer.Color = _tint;
		_spriteRenderer.OverlayColor = _flashTint;
	}

	void ApplySpriteFlags()
	{
		if ( !_spriteRenderer.IsValid() ) return;
		_spriteRenderer.FlipHorizontal = _spriteFlags.HasFlag( SpriteFlags.HorizontalFlip );
		_spriteRenderer.FlipVertical = _spriteFlags.HasFlag( SpriteFlags.VerticalFlip );
	}

	void ApplyRotation()
	{
		if ( !_spriteRenderer.IsValid() ) return;
		_spriteRenderer.LocalRotation = _rotationOffset * new Angles( -90, 0, 0 );
	}

	void ApplyShadows()
	{
		if ( !_spriteRenderer.IsValid() ) return;
		_spriteRenderer.Shadows = _castShadows != ShadowRenderType.Off;
	}

	void ApplyPlaybackSpeed()
	{
		if ( !_spriteRenderer.IsValid() ) return;
		_spriteRenderer.PlaybackSpeed = _playbackSpeed;
	}

	void CreateSpriteRenderer()
	{
		var childObject = new GameObject();
		childObject.Parent = GameObject;
		childObject.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;

		_spriteRenderer = childObject.AddComponent<SpriteRenderer>();
		_spriteRenderer.Billboard = SpriteRenderer.BillboardMode.None;
		_spriteRenderer.TextureFilter = Sandbox.Rendering.FilterMode.Point;
		_spriteRenderer.Enabled = false;
		_spriteRenderer.Size = 100;
		_spriteRenderer.IsSorted = true;
		_spriteRenderer.Shadows = false;

		// Forward through lambdas so reassigning the actions at runtime is respected (the delegate isn't
		// captured by value at creation time).
		_spriteRenderer.OnAnimationEnd += ( name ) => OnAnimationComplete?.Invoke( name );
		_spriteRenderer.OnBroadcastMessage += ( msg ) => OnBroadcastMessage?.Invoke( msg );

		ApplySprite();
		ApplyColor();
		ApplySpriteFlags();
		ApplyRotation();
		ApplyShadows();
		ApplyPlaybackSpeed();
	}

	/// <summary>
	/// Plays an animation from the current Sprite by name.
	/// </summary>
	/// <param name="animationName">The name of the animation.</param>
	/// <param name="force">Unused; retained for source compatibility with the old SpriteTools layer API.</param>
	public void PlayAnimation( string animationName, bool force = false )
	{
		if ( _spriteRenderer?.CurrentAnimation?.Name == animationName )
			return;

		if ( _spriteRenderer.IsValid() )
			_spriteRenderer.PlayAnimation( animationName );
		else
			_queuedAnimationName = animationName;
	}
}

/// <summary>
/// Flags controlling how a <see cref="Sprite2D"/> is drawn. Ported from SpriteTools so existing game
/// code (and serialized prefab values) keep working after removing that library.
/// </summary>
[Flags]
public enum SpriteFlags
{
	None = 0,

	[Icon( "align_horizontal_center" )]
	[Title( "Horizontal Flip" )]
	[Description( "Flip the sprite horizontally" )]
	HorizontalFlip = 1 << 2,

	[Icon( "align_vertical_center" )]
	[Title( "Vertical Flip" )]
	[Description( "Flip the sprite vertically" )]
	VerticalFlip = 1 << 3,

	[Icon( "flip_to_back" )]
	[Title( "Draw Backface" )]
	[Description( "Draw the backface of the sprite" )]
	DrawBackface = 1 << 4
}