TechnicalArea.cs

Presentation for the two teams' coaches in the technical area. Drives pacing, gestures, speech bubbles and reactions from match events (goals, misses, saves, red cards) and exposes a separate Report snapshot. Also adds a BenchCards helper to MatchController for bench display.

using Sandbox;
using Socer;
using System;
using System.Linq;

public enum CoachGesture { Watch, Point, Call, Press, Hold, Celebrate, Frustrated, Applaud }

// Presentation clocks and reactions are independent of football decisions and match results.
public sealed class TechnicalAreaPresentation
{
    public sealed class Coach
    {
        public int Team;
        public Point Position,Target;
        public CoachGesture Gesture;
        public string Speech="";
        public float Started,Until,SpeechUntil;
        // The shared footballer rig supplies locomotion only; this actor has no match AI.
        public Footballer Figure {get;}=new(){Id=100,Action="Coach"};
        public float Age(float clock)=>Math.Max(0,clock-Started);
    }
    public Coach[] Coaches {get;}={new(){Team=0},new(){Team=1}};
    public float Clock {get;private set;}
    public int Commands {get;private set;}
    public int Reactions {get;private set;}
    MatchSimulation observed;
    readonly int[] goals=new int[2],misses=new int[2];
    int effectSerial;
    float coachOrderUntil;
    public static float BenchX(int team)=>team==0?39.5f:65.5f;

    public void Update(MatchController game,float delta)
    {
        var match=game.Match;
        if(match==null)return;
        if(observed!=match)
        {
            observed=match;Clock=0;effectSerial=match.Effects.Count==0?0:match.Effects.Max(e=>e.Id);
            coachOrderUntil=match.CoachUntil;Commands=Reactions=0;
            for(int team=0;team<2;team++)
            {
                goals[team]=match.Goals[team];misses[team]=match.ShotsWide[team]+match.ShotsOver[team];
                var coach=Coaches[team];coach.Position=new Point(BenchX(team),-2.2f);coach.Target=match.Ball;
                coach.Gesture=CoachGesture.Watch;coach.Speech="";coach.Started=coach.Until=coach.SpeechUntil=0;
                coach.Figure.Team=team;coach.Figure.Position=coach.Figure.VisualBefore=coach.Position;
                coach.Figure.Facing=MathF.PI*.5f;coach.Figure.GaitPhase=coach.Figure.VisualSpeed=0;
                coach.Figure.VisualVelocity=coach.Figure.VisualAcceleration=new Point();
            }
        }
        bool live=!game.ClubOpen && !game.StrategyLabOpen && !game.Replay.Playing;
        if(!live){GameAudio.StopCoachVoice();return;}
        if(!game.Paused && !game.GameSettingsOpen)Clock+=Math.Clamp(delta,0,.1f);
        foreach(var coach in Coaches)
        {
            if(Clock>=coach.Until){coach.Gesture=CoachGesture.Watch;coach.Target=match.Ball;}
            // A few steps within the marked area; the coach never enters the pitch.
            float pacing=MathF.Sin(Clock*.55f+coach.Team*2)*1.8f;
            var wanted=new Point(BenchX(coach.Team)+pacing,-2.2f);
            if(!game.Paused && !game.GameSettingsOpen && delta>0)
            {
                var figure=coach.Figure;figure.VisualBefore=coach.Position;
                if(coach.Gesture==CoachGesture.Watch)coach.Position+=(wanted-coach.Position)*Math.Clamp(delta*2,0,1);
                figure.Position=coach.Position;MatchSimulation.AdvanceGait(figure,delta);
                var facing=figure.VisualSpeed>.12f?figure.VisualVelocity:(coach.Target-coach.Position);
                if(facing.Length>.01f)
                {
                    float turn=MathF.Atan2(facing.Y,facing.X)-figure.Facing;
                    turn=MathF.Atan2(MathF.Sin(turn),MathF.Cos(turn));
                    figure.Facing+=turn*Math.Clamp(delta*5,0,1);
                }
            }
        }
        if(match.CoachUntil>coachOrderUntil)
        {
            coachOrderUntil=match.CoachUntil;
            Command(game,match.CoachOrder==1?FieldPingKind.Press:match.CoachOrder==2?FieldPingKind.Attack:FieldPingKind.Zone,match.Ball);
        }
        for(int team=0;team<2;team++)
        {
            if(match.Goals[team]>goals[team])
            {React(game,team,CoachGesture.Celebrate);React(game,1-team,CoachGesture.Frustrated);}
            else if(match.ShotsWide[team]+match.ShotsOver[team]>misses[team])React(game,team,CoachGesture.Frustrated);
            goals[team]=match.Goals[team];misses[team]=match.ShotsWide[team]+match.ShotsOver[team];
        }
        foreach(var effect in match.Effects)
        {
            if(effect.Id<=effectSerial)continue;
            effectSerial=effect.Id;
            if(effect.Player<0 || effect.Player>=match.Players.Count)continue;
            int team=match.Players[effect.Player].Team;
            if(team>1)continue;
            if(effect.Kind=="save" && match.GoalHoldRemaining<=0)
            {React(game,team,CoachGesture.Applaud);React(game,1-team,CoachGesture.Frustrated);}
            else if(effect.Kind=="red-card")React(game,team,CoachGesture.Frustrated);
        }
    }
    public void Command(MatchController game,FieldPingKind kind,Point target,int player=-1)
    {
        if(game.StrategyLabOpen || game.ClubOpen || game.Match.Finished || game.Replay.Playing)return;
        // Initialize before the first ping in a new match, so it cannot be lost on the next update.
        if(observed!=game.Match)Update(game,0);
        var gesture=kind switch {FieldPingKind.Select=>CoachGesture.Point,FieldPingKind.Press or FieldPingKind.Mark=>CoachGesture.Press,FieldPingKind.Zone=>CoachGesture.Hold,FieldPingKind.Attack=>CoachGesture.Call,_=>CoachGesture.Point};
        string text=kind switch {FieldPingKind.Select=>player>=0?$"Number {game.Match.Players[player].Number}!":"Over here!",FieldPingKind.Pass=>"Play it to him!",FieldPingKind.Clear=>"Back to your position!",FieldPingKind.Press=>"Close him down!",FieldPingKind.Mark=>"Stay with him!",FieldPingKind.Zone=>"Hold your positions!",FieldPingKind.Attack=>"Go! Take the shot!",FieldPingKind.Run=>"Make the run!",_=>"Move there!"};
        Set(Coaches[0],gesture,target,text,kind==FieldPingKind.Select?1.5f:2.6f);
        Commands++;
        // Selecting a target gets a gesture. A completed instruction gets a voiced call.
        if(kind!=FieldPingKind.Select)GameAudio.CoachCall(kind==FieldPingKind.Zone?"hold":kind is FieldPingKind.Mark or FieldPingKind.Press?"cover":"go",0);
    }
    public void React(MatchController game,int team,CoachGesture gesture)
    {
        if(observed!=game.Match)Update(game,0);
        var coach=Coaches[team];
        if(coach.Gesture==CoachGesture.Celebrate && Clock<coach.Until && gesture!=CoachGesture.Celebrate)return;
        Set(coach,gesture,game.Match.Ball,"",gesture==CoachGesture.Celebrate?5:3.1f);Reactions++;
    }
    void Set(Coach coach,CoachGesture gesture,Point target,string speech,float duration)
    {coach.Gesture=gesture;coach.Target=target;coach.Speech=speech;coach.Started=Clock;coach.Until=Clock+duration;coach.SpeechUntil=string.IsNullOrEmpty(speech)?0:Clock+Math.Min(duration,FieldSpeechPresentation.ActiveDuration);}
    public object Report()=>new {Clock,Commands,Reactions,Coaches=Coaches.Select(c=>new {c.Team,Position=new{c.Position.X,c.Position.Y},c.Gesture,c.Speech,Remaining=Math.Max(0,c.Until-Clock),c.Figure.GaitPhase,c.Figure.VisualSpeed,Velocity=new{c.Figure.VisualVelocity.X,c.Figure.VisualVelocity.Y}}).ToArray()};
}

public sealed partial class MatchController
{
    public TechnicalAreaPresentation TechnicalArea {get;}=new();
    // Include players who have come off: the physical bench mirrors actual substitutions.
    public System.Collections.Generic.IEnumerable<PlayerCard> BenchCards=>AvailableSubstitutes
        .Concat(Career.Cards.Where(c=>substitutedOut.Contains(c.Id))).Take(9);
}