Rendering/SwapperStaffIndicator.cs

A rendering component for the Swapper character that finds the staff pixel in sprite frames, builds a tinted quad matching the character sprite bounds, and renders it as an overlay for hover/transition effects. It maps animations and frames to marker pixel coordinates and exposes a method to get the marker world position.

Native Interop
using System.Collections.Generic;
using Sandbox;

namespace BlockParty;

/// <summary>Recolors Swapper's unique blue staff pixel and locates it for hover transition effects.</summary>
public sealed class SwapperStaffIndicator : Component
{
	const float DEPTH_OFFSET = SpriteLayer.LAYER_Z_STEP;

	readonly RenderAttributes _attributes = new();
	readonly List<Vertex> _vertices = new( 6 );
	SceneCustomObject _sceneObject;
	Material _material;
	Player _player;
	string _animation = "idle";
	Color _tint = Color.White;

	public Color Tint
	{
		get => _tint;
		set => _tint = value;
	}

	public void Setup( Player player )
	{
		_player = player;
	}

	public void SetAnimation( string animation )
	{
		_animation = animation;
	}

	protected override void OnEnabled()
	{
		_material = Material.FromShader( "shaders/swapper_staff_indicator.shader" );
		_sceneObject = new SceneCustomObject( Scene.SceneWorld )
		{
			Transform = global::Transform.Zero,
			RenderLayer = SceneRenderLayer.OverlayWithDepth,
			RenderOverride = OnRender,
		};
		_sceneObject.Flags.IsOpaque = false;
		_sceneObject.Flags.IsTranslucent = true;
		_sceneObject.Flags.CastShadows = false;
	}

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

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

	void RebuildGeometry()
	{
		_vertices.Clear();
		SpriteRenderer body = _player?.BodySpriteRenderer;
		if ( body is null || !body.Enabled || !body.GameObject.Active ) return;

		Texture texture = body.Texture;
		if ( texture is null || texture == Texture.Transparent ) return;
		texture.MarkUsed( ushort.MaxValue );
		_attributes.Set( "MainTexture", texture );

		GetFrameBounds( body, texture, out float left, out float right, out float bottom, out float top );
		float leftU = body.FlipHorizontal ? 1f : 0f;
		float rightU = body.FlipHorizontal ? 0f : 1f;
		float bottomV = body.FlipVertical ? 0f : 1f;
		float topV = body.FlipVertical ? 1f : 0f;
		float z = body.GameObject.WorldPosition.z + DEPTH_OFFSET;

		Vertex V( float x, float y, float u, float v )
			=> new( new Vector3( x, y, z ), new Vector4( u, v, 0f, 0f ), _tint );

		_vertices.Add( V( left, bottom, leftU, bottomV ) );
		_vertices.Add( V( right, bottom, rightU, bottomV ) );
		_vertices.Add( V( right, top, rightU, topV ) );
		_vertices.Add( V( left, bottom, leftU, bottomV ) );
		_vertices.Add( V( right, top, rightU, topV ) );
		_vertices.Add( V( left, top, leftU, topV ) );
	}

	public bool TryGetMarkerWorldPosition( out Vector2 position )
	{
		position = default;
		SpriteRenderer body = _player?.BodySpriteRenderer;
		Texture texture = body?.Texture;
		if ( body is null || texture is null || texture == Texture.Transparent
			|| !TryGetMarkerPixel( body.CurrentFrameIndex, out Vector2 pixel ) )
			return false;

		GetFrameBounds( body, texture, out float left, out float right, out float bottom, out float top );
		float pixelWidth = (right - left) / Math.Max( 1, texture.Width );
		float pixelHeight = (top - bottom) / Math.Max( 1, texture.Height );
		float column = body.FlipHorizontal ? texture.Width - 1f - pixel.x : pixel.x;
		float row = body.FlipVertical ? texture.Height - 1f - pixel.y : pixel.y;
		position = new Vector2(
			left + (column + 0.5f) * pixelWidth,
			top - (row + 0.5f) * pixelHeight );
		return true;
	}

	bool TryGetMarkerPixel( int frame, out Vector2 pixel )
	{
		Vector2? result = _animation switch
		{
			"idle" or "air_up" or "air_down" or "air_fall_fast" or "wall_up" or "wall_down" => new Vector2( 10, 5 ),
			"crouch" => new Vector2( 11, 8 ),
			"air_up_active" => new Vector2( 9, 6 ),
			"air_down_active" => new Vector2( 11, 5 ),
			"walk" => frame switch
			{
				0 or 1 => new Vector2( 6, 4 ),
				2 or 5 => new Vector2( 10, 5 ),
				3 or 4 => new Vector2( 11, 4 ),
				_ => null,
			},
			"squish_h" => frame switch
			{
				0 => new Vector2( 10, 5 ),
				1 => new Vector2( 7, 5 ),
				2 => new Vector2( 6, 5 ),
				_ => null,
			},
			"squish_v" => frame switch
			{
				0 => new Vector2( 10, 5 ),
				1 => new Vector2( 10, 6 ),
				2 => new Vector2( 10, 7 ),
				3 => new Vector2( 10, 8 ),
				4 => new Vector2( 9, 8 ),
				_ => null,
			},
			"explode" => frame switch
			{
				0 => new Vector2( 10, 5 ),
				1 => new Vector2( 11, 5 ),
				2 => new Vector2( 12, 5 ),
				_ => null,
			},
			_ => null,
		};

		pixel = result ?? default;
		return result.HasValue;
	}

	static void GetFrameBounds( SpriteRenderer body, Texture texture, out float left, out float right,
		out float bottom, out float top )
	{
		Vector3 center = body.GameObject.WorldPosition;
		Vector3 scale = body.GameObject.WorldScale;
		float aspect = texture.Width / (float)Math.Max( 1, texture.Height );
		float width = MathF.Abs( body.Size.x * scale.x * MathF.Min( aspect, 1f ) );
		float height = MathF.Abs( body.Size.y * scale.y / MathF.Max( aspect, 1f ) );
		left = center.x - width * 0.5f;
		right = center.x + width * 0.5f;
		bottom = center.y - height * 0.5f;
		top = center.y + height * 0.5f;
	}

	void OnRender( SceneObject sceneObject )
	{
		SpriteRenderer body = _player?.BodySpriteRenderer;
		if ( _vertices.Count == 0 || _material is null || body is null || !body.Enabled ) return;
		Graphics.Draw( _vertices, _vertices.Count, _material, _attributes, Graphics.PrimitiveType.Triangles );
	}
}