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