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&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&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;
}
}
}