Code/HumanoidRetargeter/Core/Sprites/SpriteCharacter.cs

The file defines data structures for 2D character skeletons and parts, and implements a static method to parse character definitions from a JSON string.

#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text.Json;

namespace HumanoidRetargeter.Core.Sprites;

using Vector2 = System.Numerics.Vector2; // s&box compat: shadow engine's global-namespace Vector2 (see Code/HumanoidRetargeter/Assembly.cs)

/// <summary>A bulge on one side of a bone: extra radius at a share of its length.</summary>
public readonly record struct SpriteBulge(float At, float Radius);

/// <summary>One bone of a 2D character: where it rests on the canvas and how thick it is.</summary>
public sealed record SpriteBone(
    string Name, int Part, Vector2 Head, Vector2 Tail, float HeadRadius, float TailRadius,
    SpriteBulge[] BulgeLeft, SpriteBulge[] BulgeRight);

/// <summary>One body part: its bones merge into one shape in one colour, on one layer.</summary>
public sealed record SpritePart(int Id, string Name, int Layer, float Blend, byte Red, byte Green, byte Blue);

/// <summary>
/// A 2D character as Bonebase defines it: bones with radii on a pixel canvas, grouped into
/// coloured parts. Read from the <c>.rig.json</c> files <c>dev/sprite-templates/bake.lua</c>
/// dumps from Bonebase's own templates.
/// </summary>
public sealed class SpriteCharacter
{
    private SpriteCharacter(int width, int height, SpriteBone[] bones, SpritePart[] parts)
    {
        Width = width;
        Height = height;
        Bones = bones;
        Parts = parts;
    }

    /// <summary>Canvas width in pixels.</summary>
    public int Width { get; }

    /// <summary>Canvas height in pixels.</summary>
    public int Height { get; }

    /// <summary>The bones, parents first.</summary>
    public IReadOnlyList<SpriteBone> Bones { get; }

    /// <summary>The parts, in stacking order at rest.</summary>
    public IReadOnlyList<SpritePart> Parts { get; }

    /// <summary>Reads a character.</summary>
    /// <exception cref="FormatException">The text is not a character definition.</exception>
    public static SpriteCharacter Parse(string json)
    {
        ArgumentNullException.ThrowIfNull(json);
        try
        {
            using var document = JsonDocument.Parse(json);
            var root = document.RootElement;
            var canvas = root.GetProperty("canvas");
            var bones = new List<SpriteBone>();
            foreach (var bone in root.GetProperty("bones").EnumerateArray())
            {
                bones.Add(new SpriteBone(
                    bone.GetProperty("name").GetString() ?? "",
                    bone.GetProperty("part").GetInt32(),
                    new Vector2(bone.GetProperty("hx").GetSingle(), bone.GetProperty("hy").GetSingle()),
                    new Vector2(bone.GetProperty("tx").GetSingle(), bone.GetProperty("ty").GetSingle()),
                    bone.GetProperty("r0").GetSingle(),
                    bone.GetProperty("r1").GetSingle(),
                    Bulges(bone.GetProperty("bulgeL")),
                    Bulges(bone.GetProperty("bulgeR"))));
            }
            var parts = new List<SpritePart>();
            foreach (var part in root.GetProperty("parts").EnumerateArray())
            {
                var color = part.GetProperty("color");
                parts.Add(new SpritePart(
                    part.GetProperty("id").GetInt32(),
                    part.GetProperty("name").GetString() ?? "",
                    part.GetProperty("z").GetInt32(),
                    part.GetProperty("blend").GetSingle(),
                    color[0].GetByte(), color[1].GetByte(), color[2].GetByte()));
            }
            if (bones.Count == 0 || parts.Count == 0)
                throw new FormatException("the character has no bones or no parts.");
            return new SpriteCharacter(canvas[0].GetInt32(), canvas[1].GetInt32(), bones.ToArray(), parts.ToArray());
        }
        catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException or IndexOutOfRangeException)
        {
            throw new FormatException($"Not a 2D character definition: {e.Message}", e);
        }
    }

    private static SpriteBulge[] Bulges(JsonElement list)
    {
        var bulges = new SpriteBulge[list.GetArrayLength()];
        for (var i = 0; i < bulges.Length; i++)
            bulges[i] = new SpriteBulge(list[i][0].GetSingle(), list[i][1].GetSingle());
        return bulges;
    }
}