MatchDrawing.cs

UI drawing panels for a soccer match. PitchCanvas draws the striped pitch and markings, GoalCanvas draws both goals, FootballerCanvas draws one player from procedural pose parts, and GoalReplayCanvas renders a captured goal replay with its own camera.

Rough Code
using Sandbox;
using Sandbox.UI;
using Socer;
using System;
using System.Collections.Generic;
using System.Linq;

// Same broadcast projection and procedural poses; no per-line/per-limb layout nodes.
public class PitchCanvas : Panel
{
    public float Pixels { get; set; }
    public MatchController Game { get; set; }
    static readonly PitchPerspective.Line[] Markings=PitchPerspective.Markings().ToArray();
    internal static Vector2 Project(Point point,float pixels,float height=0)
    {
        var p=PitchPerspective.Project(point);
        return new(p.X*pixels,p.Y*pixels*HumanFigure.GroundScale-height*pixels*.77f*PitchPerspective.Scale(point));
    }
    public override void OnDraw(Painter painter)
    {
        using var drawingScope=painter.Scope();
        painter.Scale(ScaleToScreen);
        painter.Stroke=Stroke.None;
        for(int i=0;i<10;i++)
        {
            painter.Fill=Color.Parse(i%2==0?"#24634d":"#286c53")??Color.Green;
            painter.Quad(Project(new(i*10.5f,0),Pixels),Project(new((i+1)*10.5f,0),Pixels),
                Project(new((i+1)*10.5f,68),Pixels),Project(new(i*10.5f,68),Pixels));
        }
        painter.Fill=Fill.None;painter.Stroke=Stroke.Solid(Color.Parse("#c2d8bb")??Color.White,1.5f);
        foreach(var line in Markings)painter.Line(Project(line.A,Pixels),Project(line.B,Pixels));
        painter.Stroke=Stroke.None;painter.Fill=Color.Parse("#d6e5c9")??Color.White;
        painter.Circle(Project(new(11,34),Pixels),2);painter.Circle(Project(new(94,34),Pixels),2);painter.Circle(Project(new(52.5f,34),Pixels),2);
    }
}
public class GoalCanvas : Panel
{
    public float Pixels { get; set; }
    static readonly PitchPerspective.Line[] Edges=PitchPerspective.Goal(0).Concat(PitchPerspective.Goal(105)).ToArray();
    public override void OnDraw(Painter painter)
    {
        using var drawingScope=painter.Scope();
        painter.Scale(ScaleToScreen);
        painter.Fill=Fill.None;
        foreach(var edge in Edges)
        {
            painter.Stroke=Stroke.Solid(Color.Parse(edge.Net?"#dae5df65":"#f1f3df")??Color.White,edge.Net?1:2.5f);
            painter.Line(PitchCanvas.Project(edge.A,Pixels,edge.ZA),PitchCanvas.Project(edge.B,Pixels,edge.ZB));
        }
    }
}
public class FootballerCanvas : Panel
{
    public Footballer Player { get; set; }
    public MatchSimulation Match { get; set; }
    public float Pixels { get; set; }
    public bool Referee { get; set; }
    readonly List<HumanFigure.Part> parts=new(24);
    readonly List<ReplayFigure.Part> worldParts=new(24);
    static readonly Dictionary<string,Color> Colors=new();
    static Color Ink(string value)
    {
        if(!Colors.TryGetValue(value,out var color))Colors[value]=color=Color.Parse(value)??Color.White;
        return color;
    }
    public override void OnDraw(Painter painter)
    {
        using var drawingScope=painter.Scope();
        painter.Scale(ScaleToScreen);
        if(Player==null || Match==null)return;
        painter.Stroke=Stroke.None;
        foreach(var part in HumanFigure.Pose(Player,Match,Referee,parts,worldParts))
        {
            var delta=part.B-part.A;float width=Math.Max(1.2f,part.Width*Pixels);
            float length=Math.Max(width,delta.Length*Pixels);
            using var scope=painter.Scope();
            painter.Translate(12.5f+part.A.X*Pixels,12.5f+part.A.Y*Pixels);
            painter.Rotate(MathF.Atan2(delta.Y,delta.X)*180/MathF.PI);
            painter.Fill=Ink(part.Color);
            painter.Rect(new Rect(0,-width/2,length,width),width*.45f);
            if(part.Color=="#17282b")
            {
                painter.Fill=Color.White.WithAlpha(.25f);
                painter.Rect(new Rect(length*.48f,-width*.26f,length*.35f,width*.12f),width*.06f);
            }
            // A soft rim and narrow reflection give the same silhouette a rounded
            // surface without extra panels, textures, or per-player materials.
            if(width>=2f && length>width*1.15f)
            {
                painter.Fill=Color.Black.WithAlpha(.14f);
                painter.Rect(new Rect(width*.16f,width*.18f,length-width*.32f,width*.19f),width*.09f);
                painter.Fill=Color.White.WithAlpha(.17f);
                painter.Rect(new Rect(width*.18f,-width*.33f,length-width*.36f,width*.16f),width*.08f);
            }
        }
    }
}

// The replay has its own perspective and immutable captured poses. It never
// replaces the live match camera or asks the simulation to run backwards.
public class GoalReplayCanvas : Panel
{
    public GoalReplay Replay { get; set; }
    public float WidthPx { get; set; }
    public float HeightPx { get; set; }
    static readonly PitchPerspective.Line[] Markings=PitchPerspective.Markings().ToArray();
    static readonly Dictionary<string,Color> Colors=new();
    static Vector2 V(Point p)=>new(p.X,p.Y);
    static Color Ink(string code)
    {
        if(!Colors.TryGetValue(code,out var value))Colors[code]=value=Color.Parse(code)??Color.White;
        return value;
    }
    public override void OnDraw(Painter painter)
    {
        using var drawingScope=painter.Scope();
        painter.Scale(ScaleToScreen);
        float w=Math.Max(1,WidthPx),h=Math.Max(1,HeightPx);
        painter.Stroke=Stroke.None;painter.Fill=Ink("#1e4f40");painter.Rect(new Rect(0,0,w,h));
        if(Replay==null || !Replay.Sample(out var a,out var b,out float t))return;
        var scorer=GoalReplay.Mix(a.Actors[Replay.ScorerId].Position,b.Actors[Replay.ScorerId].Position,t);
        float afterGoal=Math.Clamp((Replay.PlaybackTime-Replay.GoalTime-.15f)/1.45f,0,1);
        afterGoal=afterGoal*afterGoal*(3-2*afterGoal);
        var camera=new GoalReplayCamera(scorer,Replay.AttackDirection,w,h,afterGoal);
        int direction=Replay.AttackDirection;
        // Turf bands are drawn in world space, so they converge toward the goal.
        for(int i=0;i<10;i++)
        {
            float x0=i*10.5f,x1=(i+1)*10.5f;
            var p0=new Point(x0,0);var p1=new Point(x1,0);
            var p2=new Point(x1,68);var p3=new Point(x0,68);
            if(Math.Min(Math.Min(camera.Depth(p0),camera.Depth(p1)),Math.Min(camera.Depth(p2),camera.Depth(p3)))<.8f)continue;
            painter.Fill=Ink(i%2==0?"#286850":"#2b7155");
            painter.Quad(V(camera.Project(p0)),V(camera.Project(p1)),V(camera.Project(p2)),V(camera.Project(p3)));
        }
        painter.Fill=Fill.None;painter.Stroke=Stroke.Solid(Ink("#bdd7ba"),1.5f);
        foreach(var edge in Markings)
        {
            if(camera.Depth(edge.A)<.8f || camera.Depth(edge.B)<.8f)continue;
            painter.Line(V(camera.Project(edge.A)),V(camera.Project(edge.B)));
        }
        float goalX=direction>0?105:0;
        foreach(var edge in PitchPerspective.Goal(goalX))
        {
            painter.Stroke=Stroke.Solid(Ink(edge.Net?"#b8d5c591":"#f5f5e1"),edge.Net?1f:3f);
            painter.Line(V(camera.Project(edge.A,edge.ZA)),V(camera.Project(edge.B,edge.ZB)));
        }
        painter.Stroke=Stroke.None;
        foreach(int i in Enumerable.Range(0,a.Actors.Length).OrderByDescending(i=>camera.Depth(a.Actors[i].Position)))
        {
            var first=a.Actors[i];var second=b.Actors[i];
            if(!first.Visible || camera.Depth(first.Position)<.8f)continue;
            Point position=GoalReplay.Mix(first.Position,second.Position,t);
            float scale=camera.Scale(position);var basePoint=camera.Project(position);
            if(basePoint.X< -150 || basePoint.X>w+150 || basePoint.Y< -150 || basePoint.Y>h+200)continue;
            painter.Fill=Color.Black.WithAlpha(.24f);
            painter.Rect(new Rect(basePoint.X-scale*.38f,basePoint.Y-scale*.07f,scale*.76f,scale*.14f),scale*.07f);
            int count=Math.Min(first.Count,second.Count);
            for(int j=0;j<count;j++)
            {
                var pa=first.Parts[j];var pb=second.Parts[j];
                var worldFrom=GoalReplay.Mix(pa.A,pb.A,t);
                var worldTo=GoalReplay.Mix(pa.B,pb.B,t);
                float zFrom=pa.ZA+(pb.ZA-pa.ZA)*t,zTo=pa.ZB+(pb.ZB-pa.ZB)*t;
                Point from=camera.Project(worldFrom,zFrom);
                Point to=camera.Project(worldTo,zTo);
                float width=Math.Max(1.5f,pa.Width*camera.Scale(GoalReplay.Mix(worldFrom,worldTo,.5f)));
                var delta=to-from;
                using var partScope=painter.Scope();
                painter.Translate(from.X,from.Y);
                painter.Rotate(MathF.Atan2(delta.Y,delta.X)*180/MathF.PI);
                float length=Math.Max(width,delta.Length);
                if(j>=count-2)
                {
                    painter.Fill=Color.Black.WithAlpha(.32f);painter.Circle(new Vector2(0,0),width*.5f+1);
                    painter.Fill=Ink(pa.Color);painter.Circle(new Vector2(0,0),width*.5f);
                    continue;
                }
                painter.Fill=Color.Black.WithAlpha(.32f);
                painter.Rect(new Rect(-1,-width*.5f-1,length+2,width+2),width*.48f);
                painter.Fill=Ink(pa.Color);
                painter.Rect(new Rect(0,-width*.5f,length,width),width*.45f);
                if(pa.Color=="#17282b")
                {
                    painter.Fill=Color.White.WithAlpha(.3f);
                    painter.Rect(new Rect(length*.46f,-width*.27f,length*.36f,width*.13f),width*.07f);
                }
                if(width>3 && length>width*1.15f)
                {
                    painter.Fill=Color.Black.WithAlpha(.14f);
                    painter.Rect(new Rect(width*.16f,width*.18f,length-width*.32f,width*.19f),width*.09f);
                    painter.Fill=Color.White.WithAlpha(.17f);
                    painter.Rect(new Rect(width*.18f,-width*.33f,length-width*.36f,width*.16f),width*.08f);
                }
            }
        }
        var ball=GoalReplay.Mix(a.Ball,b.Ball,t);
        float ballHeight=a.BallHeight+(b.BallHeight-a.BallHeight)*t;
        if(camera.Depth(ball)>.8f)
        {
            var shadow=camera.Project(ball);var at=camera.Project(ball,ballHeight);
            painter.Fill=Color.Black.WithAlpha(.32f);painter.Circle(V(shadow),Math.Max(2,camera.Scale(ball)*.13f));
            painter.Fill=Color.White;painter.Circle(V(at),Math.Max(3,camera.Scale(ball)*.11f));
        }
    }
}