World/SpritePuppet.cs

Component that draws a sprite puppet in the 3D world. Solves a PuppetRig pose from the fighter's current animation or a LookSource delegate, then positions and rotates one SpriteRenderer per bone layer so the flat pictures face the camera, with depth stacking between bones and layers. Caches textures and sprites by art path.

using System;

/// <summary>
/// A gladiator standing in the 3D world: every S&amp;S2 picture on every bone is a flat sprite, lit by the
/// scene's lights and casting a shadow. Draws a fight's <see cref="Gladiator"/>, or any look you give it.
/// <see cref="World3D"/> tells it where its feet are and which way the camera is.
/// </summary>
public sealed class SpritePuppet : Component
{
	public Gladiator Fighter { get; set; }

	/// <summary>Used when there's no fighter (town, shops, character creation, fitting room...).</summary>
	public Func<Look> LookSource { get; set; }
	public string Anim { get; set; } = "idle";
	public Mood FixedMood { get; set; } = Mood.Neutral;

	/// <summary>Mirror left/right (the fighter on the right faces left).</summary>
	public bool Flip { get; set; }

	/// <summary>Where the soles are, which way is screen-right and which way is towards the camera.</summary>
	public Vector3 Feet { get; set; }
	public Vector3 ScreenRight { get; set; } = Vector3.Right;
	public Vector3 ToCamera { get; set; } = Vector3.Forward;
	public float Size { get; set; } = 1f;

	const float BoneDepth = 0.35f;  // world units between bones (towards the camera)
	const float LayerDepth = 0.05f; // between the pictures stacked on one bone

	class BoneSprites
	{
		public PuppetRig.Bone Bone;
		public int Order;
		public readonly List<SpriteRenderer> Sprites = new();
		public readonly List<string> Paths = new();
	}

	readonly Dictionary<string, BoneSprites> bones = new();
	readonly List<PuppetLayer> scratch = new();
	Dictionary<string, (Vector2 Pos, float Angle)> solved;
	RealTimeSince clock = 0;

	static readonly Dictionary<string, Texture> textures = new();
	static readonly Dictionary<string, Sprite> sprites = new();

	public static Texture Tex( string path )
	{
		if ( textures.TryGetValue( path, out var t ) ) return t;
		t = Texture.Load( Catalog.Art + path, false );
		textures[path] = t;
		return t;
	}

	public static Sprite SpriteFor( string path )
	{
		if ( sprites.TryGetValue( path, out var s ) ) return s;
		var t = Tex( path );
		s = t is null ? null : Sprite.FromTexture( t );
		sprites[path] = s;
		return s;
	}

	/// <summary>Turns a flat sprite to face the camera (and round, if the art shows up mirrored).</summary>
	public static Rotation Facing( Vector3 toCamera )
	{
		var r = Rotation.LookAt( toCamera, Vector3.Up );
		return World3D.TurnSprites ? r * Rotation.FromYaw( 180 ) : r;
	}

	protected override void OnStart()
	{
		int order = 0;
		var byName = PuppetRig.Bones.ToDictionary( b => b.Name );
		foreach ( var name in PuppetRig.DrawOrder )
			bones[name] = new BoneSprites { Bone = byName[name], Order = order++ };
	}

	/// <summary>Where a bone's joint is in the world right now (for chains, popups...).</summary>
	public Vector3 BoneWorld( string bone )
	{
		if ( solved is null || !solved.TryGetValue( bone, out var b ) ) return Feet;
		float x = Flip ? -b.Pos.x : b.Pos.x;
		float u = World3D.U * Size;
		return Feet + ScreenRight * (x * u) + Vector3.Up * (-b.Pos.y * u);
	}

	void HideAll()
	{
		foreach ( var b in bones.Values )
			foreach ( var s in b.Sprites ) s.Enabled = false;
	}

	protected override void OnUpdate()
	{
		Look look;
		Mood mood;
		string anim;
		float time;

		if ( Fighter.IsValid() && !float.IsNaN( Fighter.DisplayX ) )
		{
			look = Fighter.Look;
			mood = Fighter.CurrentMood;
			(anim, time) = Fighter.CurrentAnim;
		}
		else if ( !Fighter.IsValid() && LookSource is not null )
		{
			look = LookSource();
			mood = FixedMood;
			anim = Anim;
			time = clock;
		}
		else
		{
			HideAll();
			return;
		}

		solved = PuppetRig.Solve( PuppetRig.Sample( anim, time ) );
		float u = World3D.U * Size;
		var face = Facing( ToCamera );

		foreach ( var bs in bones.Values )
		{
			var (pivot, ang) = solved[bs.Bone.Name];
			PuppetRig.Layers( bs.Bone, look, mood, scratch );
			while ( bs.Sprites.Count < scratch.Count ) AddSprite( bs );

			for ( int i = 0; i < bs.Sprites.Count; i++ )
			{
				var sr = bs.Sprites[i];
				if ( i >= scratch.Count ) { sr.Enabled = false; continue; }

				var layer = scratch[i];
				var tex = Tex( layer.Path );
				if ( tex is null || tex.Width <= 0 ) { sr.Enabled = false; continue; }
				sr.Enabled = true;

				if ( bs.Paths[i] != layer.Path + layer.Tint )
				{
					bs.Paths[i] = layer.Path + layer.Tint;
					sr.Sprite = SpriteFor( layer.Path );
					sr.Color = layer.Tint is null ? Color.White : Color.Parse( layer.Tint ) ?? Color.White;
				}

				// centre of the picture, turned with its bone (clockwise, y down)
				float r = ang * (MathF.PI / 180f);
				float c = MathF.Cos( r ), s = MathF.Sin( r );
				float x = pivot.x + layer.X * c - layer.Y * s;
				float y = pivot.y + layer.X * s + layer.Y * c;
				float turn = ang + layer.Turn;
				bool mirror = layer.Mirror;
				if ( Flip ) { x = -x; turn = -turn; mirror = !mirror; }

				sr.Size = new Vector2( tex.Width * layer.Scale * u, tex.Height * layer.Scale * u );
				sr.FlipHorizontal = mirror;
				sr.WorldPosition = Feet + ScreenRight * (x * u) + Vector3.Up * (-y * u) + ToCamera * (bs.Order * BoneDepth + i * LayerDepth);
				// clockwise on screen = a negative turn about the axis pointing at the camera
				sr.WorldRotation = face * Rotation.FromAxis( Vector3.Forward, World3D.TurnSprites ? turn : -turn );
			}
		}
	}

	void AddSprite( BoneSprites bs )
	{
		var go = new GameObject( GameObject, true, $"{bs.Bone.Name} {bs.Sprites.Count}" );
		go.Flags |= GameObjectFlags.NotSaved;
		var sr = go.AddComponent<SpriteRenderer>();
		sr.Billboard = SpriteRenderer.BillboardMode.None;
		sr.Lighting = true;
		sr.Shadows = true;
		sr.Opaque = true;
		sr.AlphaCutoff = 0.4f;
		bs.Sprites.Add( sr );
		bs.Paths.Add( null );
	}
}