Puppet/PuppetRig.cs
using System;

/// <summary>One picture drawn on a bone: centre from the bone's joint (rig px, y down), size, turn, mirrored, tint.</summary>
public readonly record struct PuppetLayer( string Path, string Tint, float X, float Y, float Scale, float Turn, bool Mirror, string FitKey );

/// <summary>
/// The S&amp;S2 paper-doll gladiator: a skeleton of bones, the poses/animations, and which pictures go on
/// which bone for a look. Where every picture sits comes from the fitting table (<see cref="Fit"/>), so
/// the Fitting Room can move anything. Fighters face the camera; the left-hand ("b") limbs are the
/// reference and the right-hand ("f") limbs are their mirror image.
/// </summary>
public static class PuppetRig
{
	/// <summary>Side: 'b' screen-left limb, 'f' screen-right limb (mirrored), ' ' not a limb.</summary>
	public record Bone( string Name, string Art, string Parent, string Joint, bool JointMirrored, char Side );

	public static readonly string[] Channels = { "root", "tail", "torso", "head", "uarm_b", "farm_b", "uarm_f", "farm_f", "weapon", "thigh_b", "shin_b", "foot_b", "thigh_f", "shin_f", "foot_f" };

	// parent first, so one pass solves the skeleton
	public static readonly Bone[] Bones =
	{
		new( "torso", "torso", null, "joint.root", false, ' ' ),
		new( "head", "head", "torso", "joint.neck", false, ' ' ),
		new( "uarm_b", "uarm", "torso", "joint.shoulder", false, 'b' ),
		new( "farm_b", "farm", "uarm_b", "joint.elbow", false, 'b' ),
		new( "shield", "shield", "farm_b", "joint.shield", false, ' ' ),
		new( "thigh_b", "thigh", "torso", "joint.hip", false, 'b' ),
		new( "shin_b", "shin", "thigh_b", "joint.knee", false, 'b' ),
		new( "foot_b", "foot", "shin_b", "joint.ankle", false, 'b' ),
		new( "thigh_f", "thigh", "torso", "joint.hip", true, 'f' ),
		new( "shin_f", "shin", "thigh_f", "joint.knee", true, 'f' ),
		new( "foot_f", "foot", "shin_f", "joint.ankle", true, 'f' ),
		new( "uarm_f", "uarm", "torso", "joint.shoulder", true, 'f' ),
		new( "farm_f", "farm", "uarm_f", "joint.elbow", true, 'f' ),
		new( "weapon", "weapon", "farm_f", "joint.hand", true, ' ' ),
	};

	/// <summary>Back to front.</summary>
	public static readonly string[] DrawOrder = { "uarm_b", "farm_b", "shield", "thigh_b", "shin_b", "foot_b", "thigh_f", "shin_f", "foot_f", "torso", "head", "uarm_f", "weapon", "farm_f" };

	/// <summary>S&amp;S2 limb pictures that are drawn mirrored on the screen-left side.</summary>
	static bool MirrorOnB( string art ) => art is "thigh" or "shin";

	// channel order: x, y, then Channels. Angles in degrees, clockwise.
	// weapon: 0 = blade hanging down from the fist, -90 = pointing right, -180 = straight up
	public static readonly Dictionary<string, float[]> Poses = new()
	{
		//                         x    y   root tail torso head  ub   fb   uf   ff   wpn  tb   sb  ftb  tf   sf  ftf
		["idle"] = new float[] { 0, 0, 0, 0, 0, 0, 0, -4, 0, 4, -150, 2, -2, 0, -2, 2, 0 },
		["breathe"] = new float[] { 0, 2, 0, 0, 0, 1, 2, -7, -2, 7, -153, 2, -2, 0, -2, 2, 0 },
		["walk_a"] = new float[] { 0, -3, 0, 0, 0, 0, -8, -6, -8, 6, -150, 14, -8, 0, -12, 6, 0 },
		["walk_b"] = new float[] { 0, 0, 0, 0, 0, 0, 8, -6, 8, 6, -150, -12, 6, 0, 14, -8, 0 },
		["wind_q"] = new float[] { -4, 0, 0, 0, -4, 0, 6, -4, -20, 40, -150, 2, -2, 0, -2, 2, 0 },
		["jab"] = new float[] { 20, 0, 0, 0, 6, 0, -6, -4, -80, -10, -90, -6, 0, 0, 8, -4, 0 },
		["wind_n"] = new float[] { -8, 0, 0, 0, -8, -4, 10, -6, -130, -30, -170, 2, -2, 0, -2, 2, 0 },
		["swing"] = new float[] { 26, 0, 0, 0, 10, 4, -10, -8, -40, -20, -60, -8, 2, 0, 10, -6, 0 },
		["wind_p"] = new float[] { -14, 4, 0, 0, -12, -6, 40, 10, -170, -10, 175, 6, -6, 0, -6, 6, 0 },
		["slam"] = new float[] { 36, 12, 0, 0, 16, 8, -20, -10, -50, -10, -40, 20, -24, 0, -24, 30, 0 },
		["crouch"] = new float[] { 0, 30, 0, 0, 6, 0, 20, -10, -20, 10, -150, 40, -60, 0, -40, 60, 0 },
		["tuck"] = new float[] { 0, -10, 0, 0, -4, 0, 60, -20, -60, 20, -170, 70, -90, 0, -70, 90, 0 },
		["taunt_a"] = new float[] { 0, 0, 0, 0, -4, 6, 150, 30, -40, -60, -150, 4, -4, 0, -4, 4, 0 },
		["taunt_b"] = new float[] { 0, -4, 0, 0, 4, -6, 165, 10, -50, -70, -150, 4, -4, 0, -4, 4, 0 },
		["rest"] = new float[] { 0, 30, 0, 0, 6, 10, 10, -20, -10, 20, -120, 50, -70, 0, -50, 70, 0 },
		["push"] = new float[] { 24, 0, 0, 0, 10, 0, -60, -20, -70, -20, -150, -6, 2, 0, 8, -4, 0 },
		["hit"] = new float[] { -16, 0, 0, 0, -10, -10, 20, -4, -10, 4, -140, 2, -2, 0, -2, 2, 0 },
		["dead"] = new float[] { 0, 10, -84, 0, 0, 0, 30, -4, -30, 4, -150, 2, -2, 0, -2, 2, 0 },
		["win_a"] = new float[] { 0, 0, 0, 0, 0, 0, 160, 10, -160, -10, -175, 2, -2, 0, -2, 2, 0 },
		["win_b"] = new float[] { 0, -10, 0, 0, 0, 0, 170, 0, -170, 0, -178, 2, -2, 0, -2, 2, 0 },
		["chained_a"] = new float[] { 0, 0, 0, 0, 0, 4, 140, 20, -140, -20, -150, 2, -2, 0, -2, 2, 0 },
		["chained_b"] = new float[] { 0, 3, 0, 0, 2, -4, 136, 24, -136, -24, -150, 2, -2, 0, -2, 2, 0 },
	};

	public record Anim( bool Loop, (float Time, string Pose)[] Keys )
	{
		public float Length => Keys[^1].Time;
	}

	public static readonly Dictionary<string, Anim> Anims = new()
	{
		["idle"] = new( true, new[] { (0f, "idle"), (0.8f, "breathe"), (1.6f, "idle") } ),
		["walk"] = new( true, new[] { (0f, "walk_a"), (0.25f, "walk_b"), (0.5f, "walk_a") } ),
		["quick"] = new( false, new[] { (0f, "idle"), (0.12f, "wind_q"), (0.24f, "jab"), (0.5f, "idle") } ),
		["normal"] = new( false, new[] { (0f, "idle"), (0.2f, "wind_n"), (0.36f, "swing"), (0.7f, "idle") } ),
		["power"] = new( false, new[] { (0f, "idle"), (0.35f, "wind_p"), (0.5f, "slam"), (0.95f, "idle") } ),
		["cast"] = new( false, new[] { (0f, "idle"), (0.25f, "taunt_a"), (0.5f, "win_b"), (0.85f, "idle") } ),
		["leap"] = new( false, new[] { (0f, "crouch"), (0.12f, "tuck"), (0.6f, "tuck"), (0.72f, "crouch"), (0.9f, "idle") } ),
		["taunt"] = new( false, new[] { (0f, "idle"), (0.2f, "taunt_a"), (0.45f, "taunt_b"), (0.7f, "taunt_a"), (0.95f, "taunt_b"), (1.2f, "idle") } ),
		["rest"] = new( false, new[] { (0f, "idle"), (0.3f, "rest"), (1f, "rest"), (1.3f, "idle") } ),
		["push"] = new( false, new[] { (0f, "idle"), (0.12f, "wind_q"), (0.26f, "push"), (0.6f, "idle") } ),
		["hit"] = new( false, new[] { (0f, "idle"), (0.08f, "hit"), (0.4f, "idle") } ),
		["die"] = new( false, new[] { (0f, "hit"), (0.6f, "dead") } ),
		["win"] = new( true, new[] { (0f, "win_a"), (0.35f, "win_b"), (0.7f, "win_a") } ),
		["chained"] = new( true, new[] { (0f, "chained_a"), (1.4f, "chained_b"), (2.8f, "chained_a") } ),
	};

	public static float[] Sample( string anim, float time )
	{
		if ( !Anims.TryGetValue( anim ?? "idle", out var a ) ) a = Anims["idle"];

		if ( a.Loop ) time %= a.Length;
		time = Math.Clamp( time, 0f, a.Length );

		for ( int i = 0; i < a.Keys.Length - 1; i++ )
		{
			var (t0, p0) = a.Keys[i];
			var (t1, p1) = a.Keys[i + 1];
			if ( time > t1 ) continue;

			float f = t1 > t0 ? (time - t0) / (t1 - t0) : 1f;
			var A = Poses[p0];
			var B = Poses[p1];
			var r = new float[A.Length];
			for ( int c = 0; c < A.Length; c++ ) r[c] = A[c] + (B[c] - A[c]) * f;
			return r;
		}

		var last = Poses[a.Keys[^1].Pose];
		var copy = new float[last.Length];
		for ( int c = 0; c < last.Length; c++ ) copy[c] = last[c];
		return copy;
	}

	public static float Get( float[] pose, string channel )
	{
		int i = Array.IndexOf( Channels, channel );
		return i < 0 ? 0f : pose[i + 2];
	}

	static Vector2 Turn( Vector2 v, float degrees )
	{
		float r = degrees * (MathF.PI / 180f);
		float c = MathF.Cos( r ), s = MathF.Sin( r );
		return new Vector2( v.x * c - v.y * s, v.x * s + v.y * c );
	}

	/// <summary>
	/// Every bone's joint position (rig px, y down, soles at 0,0) and angle (degrees, clockwise) for a pose.
	/// </summary>
	public static Dictionary<string, (Vector2 Pos, float Angle)> Solve( float[] pose )
	{
		var world = new Dictionary<string, (Vector2 Pos, float Angle)>();
		var root = (new Vector2( pose[0], pose[1] ), Get( pose, "root" ));

		foreach ( var b in Bones )
		{
			var (ppos, pang) = b.Parent is null ? root : world[b.Parent];
			var j = Fit.Get( b.Joint );
			var off = new Vector2( b.JointMirrored ? -j.X : j.X, j.Y );
			var pos = ppos + Turn( off, pang );
			float ang = pang + Get( pose, b.Name );
			if ( b.Art == "foot" ) ang = root.Item2; // feet stay flat on the ground
			world[b.Name] = (pos, ang);
		}

		return world;
	}

	/// <summary>Turns a picture's fit (made for the screen-left side) into how it's drawn on this bone.</summary>
	static PuppetLayer Place( Bone b, string path, string tint, string key )
	{
		var f = Fit.Get( key );
		bool mirror = f.Flip;
		float x = f.X, turn = f.Turn;
		if ( b.Side == 'b' ) mirror ^= MirrorOnB( b.Art );
		else if ( b.Side == 'f' )
		{
			mirror ^= !MirrorOnB( b.Art );
			x = -x;
			turn = -turn;
		}
		return new PuppetLayer( path, tint, x, f.Y, f.Scale, turn, mirror, key );
	}

	static void Gear( Bone b, GearSlot slot, string key, Look look, List<PuppetLayer> into )
	{
		var item = Catalog.GearItem( slot, look.Get( slot ) );
		if ( item is not null ) into.Add( Place( b, $"ss/item/{item.Src}.png", null, $"{key}.{item.Index + 1}" ) );
	}

	/// <summary>
	/// The pictures stacked on one bone for this look, back first. Paths are under resources/sad/.
	/// </summary>
	public static void Layers( Bone b, Look look, Mood mood, List<PuppetLayer> into )
	{
		into.Clear();
		string skin = look.SkinHex;
		string race = look.RaceName.ToLower();

		switch ( b.Art )
		{
			case "torso":
				into.Add( Place( b, "ss/body/torso.png", skin, "body.torso" ) );
				into.Add( Place( b, "ss/body/loin.png", look.ClothHex, "body.torso" ) );
				Gear( b, GearSlot.Chest, "chest", look, into );
				break;

			case "head":
				into.Add( Place( b, "ss/body/head.png", skin, "body.head" ) );
				if ( race is "elf" or "orc" ) into.Add( Place( b, $"ss/race/{race}.png", skin, $"race.{race}" ) );
				if ( look.Beard > 0 ) into.Add( Place( b, $"ss/beard/{look.Beard}.png", look.HairHex, $"beard.{look.Beard}" ) );
				into.Add( Place( b, $"ss/face/{mood.ToString().ToLower()}.png", null, "face" ) );
				if ( look.Hair > 0 && look.Helm < 0 ) into.Add( Place( b, $"ss/hair/{look.Hair}.png", look.HairHex, $"hair.{look.Hair}" ) );
				if ( race is "devil" or "undead" ) into.Add( Place( b, $"ss/race/{race}.png", null, $"race.{race}" ) );
				Gear( b, GearSlot.Helm, "helm", look, into );
				break;

			case "weapon":
				if ( look.Weapon > 0 && look.Weapon < Catalog.Weapons.Length )
					into.Add( Place( b, $"ss/item/{Catalog.Weapons[look.Weapon].Src}.png", null, $"weapon.{look.Weapon}" ) );
				break;

			case "shield":
				Gear( b, GearSlot.Shield, "shield", look, into );
				break;

			case "foot":
				into.Add( Place( b, "ss/body/foot.png", null, "body.foot" ) );
				Gear( b, GearSlot.Boots, "boot", look, into );
				break;

			default:
				into.Add( Place( b, $"ss/body/{b.Art}.png", skin, $"body.{b.Art}" ) );
				if ( b.Art == "uarm" ) Gear( b, GearSlot.Shoulders, "shoulder", look, into );
				if ( b.Art == "farm" ) Gear( b, GearSlot.Gauntlets, "gauntlet", look, into );
				if ( b.Art == "thigh" ) Gear( b, GearSlot.Thighs, "thigh", look, into );
				if ( b.Art == "shin" ) Gear( b, GearSlot.Greaves, "greave", look, into );
				break;
		}
	}
}