A partial of MatchController, the football manager match presenter. Handles the pre-match preparation flow (lineup confirm, match mode choice), the intro/second half intro timers, and a text-broadcast 'quick match' that fast-forwards the simulation and generates commentary lines for goals, cards, corners, saves, injuries and substitutions. Also exposes a settlement record and a medical summary string.
using Sandbox;
using Socer;
using System;
using System.Collections.Generic;
public sealed partial class MatchController
{
public bool ManualSetPieces=true;
public bool PreviewManualSetPieces;
// Presentation consumes the completed transaction; it never grants a reward.
public sealed record MatchSettlement(MatchSimulation Match,int Coins,int StarterXp,bool Won,bool Draw,string Summary,int CupTokens=0);
public MatchSettlement Settlement { get; private set; }
public float MatchIntroUntil { get; private set; }
public bool SecondHalfIntro {get;private set;}
MatchSimulation observedPeriodMatch;
int observedSecondHalfIntro;
public const float IntroDuration=6.6f;
public bool MatchIntroActive=>!StrategyLabOpen && Time.Now<MatchIntroUntil;
public float MatchIntroAge=>IntroDuration-(MatchIntroUntil-Time.Now);
public bool PreparingMatch {get;private set;}
public bool MatchModeSelection {get;private set;}
public bool QuickMatchRunning {get;private set;}
public bool QuickBroadcastOpen {get;private set;}
public bool QuickBroadcastCup {get;private set;}
public bool QuickBroadcastCompetitive {get;private set;}
public sealed record BroadcastLine(string Clock,string Kind,string Text,int Team);
public List<BroadcastLine> BroadcastLines {get;}=new();
public BroadcastLine FeaturedBroadcastLine {get;private set;}
public int QuickBroadcastMinute=>Match.Finished?90:Match.Minute;
int quickMatchSteps, quickRoutineMinute;
float quickTickDebt, quickHoldUntil;
string previewBroadcastNotice;
bool quickSecondHalf;
readonly int[] quickGoals=new int[2],quickShots=new int[2],quickSaves=new int[2],quickYellows=new int[2],quickReds=new int[2],quickCorners=new int[2];
readonly int[] quickPlayerYellows=new int[22];
readonly bool[] quickPlayerSentOff=new bool[22];
Footballer quickMedicalPatient;
// Keep the old entry point safe for callers: the choice now follows lineup preparation.
public void StartQuickLeagueMatch()=>PrepareCareer(false);
public bool CanFinishCurrentMatchQuickly=>Career!=null && !MediaLeagueActive && !IsRulesPreview && !FootballLabActive && !StrategyLabOpen &&
!QuickMatchRunning && !Match.Finished && !Match.ShootoutActive && !InjurySubstitutionRequired && !SubstitutionOpen &&
(careerMatch?Career.Pending!=null && Match.Seed==Career.Pending.Seed:Career.Pending==null);
public void FinishCurrentMatchQuickly()
{
if(!CanFinishCurrentMatchQuickly || (Match.Seconds<=0 && !RequireCompleteLineup()))return;
BeginQuickBroadcast(true);
}
public void FinishCurrentLeagueQuickly()=>FinishCurrentMatchQuickly();
void BeginQuickBroadcast(bool remaining,bool preview=false)
{
if(MediaLeagueActive || Match.IsMediaLeague)return;
if(!preview)enteredMatch=Match;
if(!preview)EndRulesPreview();
// Only presentation input is cancelled. Keep the live simulation, its
// players, substitutions and already-issued tactical instructions.
CloseSubstitutions();CloseCommandWheel();CancelDirectOrder();SetFieldDragSlow(false);
fieldPings.Clear();manualCameraUntil=0;accumulator=0;
Replay.Stop();replayAfterGoal=0;
QuickBroadcastOpen=true;QuickMatchRunning=true;quickMatchSteps=0;quickTickDebt=0;quickHoldUntil=0;
QuickBroadcastCompetitive=!preview && careerMatch;
QuickBroadcastCup=QuickBroadcastCompetitive && Career.Pending?.Cup==true;
quickRoutineMinute=Match.Minute;quickSecondHalf=Match.SecondHalf;
for(int team=0;team<2;team++)
{
quickGoals[team]=Match.Goals[team];quickShots[team]=Match.Shots[team];quickSaves[team]=Match.Saves[team];
quickYellows[team]=Match.YellowCards[team];quickReds[team]=Match.RedCards[team];quickCorners[team]=Match.Corners[team];
}
for(int player=0;player<Match.Players.Count;player++)
{quickPlayerYellows[player]=Match.Players[player].Yellows;quickPlayerSentOff[player]=Match.Players[player].SentOff;}
BroadcastLines.Clear();FeaturedBroadcastLine=null;quickMedicalPatient=null;
SkipMatchIntro();observedSecondHalfIntro=Match.SecondHalfIntroSequence;
Match.InteractiveSetPieces=false;
if(Match.ManualRestartWaiting)Match.AutoReleaseSetPiece();
ClubOpen=true;Paused=true;
AddBroadcast(remaining?"LIVE TEXT":"KICK OFF",remaining?$"Live text coverage continues at {Match.ClockLabel}′. {Match.HomeName} {Match.Goals[0]}–{Match.Goals[1]} {Match.AwayName}.":"The whistle sounds. We are under way!",-1);
CollectMedicalBroadcastEvents();
ClubNotice="Live text commentary is on air.";
}
void AddBroadcast(string kind,string message,int team,bool headline=false)
{
var line=new BroadcastLine(Match.ClockLabel+"′",kind,message,team);
BroadcastLines.Add(line);
if(BroadcastLines.Count>100)BroadcastLines.RemoveAt(0);
if(headline){FeaturedBroadcastLine=line;quickHoldUntil=Time.Now+(kind=="GOAL"?1.25f:.75f);}
}
void CollectBroadcastEvents()
{
CollectMedicalBroadcastEvents();
if(!quickSecondHalf && Match.SecondHalf)
{
quickSecondHalf=true;
AddBroadcast("HALF TIME",$"The first half ends with {Match.HomeName} {Match.Goals[0]}–{Match.Goals[1]} {Match.AwayName}.",-1);
AddBroadcast("SECOND HALF","The teams return. Forty-five minutes remain.",-1,true);
}
for(int team=0;team<2;team++)
{
string name=Match.TeamName(team);
if(Match.Shots[team]>quickShots[team])
{
quickShots[team]=Match.Shots[team];
AddBroadcast("CHANCE",$"{name} take aim! The attack reaches a shooting position.",team);
}
if(Match.Saves[team]>quickSaves[team])
{
quickSaves[team]=Match.Saves[team];
AddBroadcast("SAVE",$"A sharp stop by the {name} goalkeeper keeps it out.",team);
}
if(Match.Corners[team]>quickCorners[team])
{
quickCorners[team]=Match.Corners[team];
AddBroadcast("CORNER",$"{name} win a corner. Players crowd the penalty area.",team);
}
if(Match.Goals[team]>quickGoals[team])
{
quickGoals[team]=Match.Goals[team];
GameAudio.Goal();
string scorer=Match.GoalScorerId>=0 && Match.GoalScorerId<Match.Players.Count?Match.Players[Match.GoalScorerId].Profile.Name:"The scorer";
AddBroadcast("GOAL",$"GOAL! {scorer} scores for {name}. {Match.HomeName} {Match.Goals[0]}–{Match.Goals[1]} {Match.AwayName}!",team,true);
}
if(Match.YellowCards[team]>quickYellows[team])
{
quickYellows[team]=Match.YellowCards[team];
GameAudio.Card(false);
int player=Match.Players.FindIndex(p=>p.Team==team && p.Yellows>quickPlayerYellows[p.Id]);
string booked=player>=0?Match.Players[player].Profile.Name:$"A {name} player";
if(player>=0)quickPlayerYellows[player]=Match.Players[player].Yellows;
AddBroadcast("YELLOW CARD",$"{booked} is booked. The referee reaches for a yellow card.",team,true);
}
if(Match.RedCards[team]>quickReds[team])
{
quickReds[team]=Match.RedCards[team];
GameAudio.Card(true);
int player=Match.Players.FindIndex(p=>p.Team==team && p.SentOff && !quickPlayerSentOff[p.Id]);
string dismissed=player>=0?Match.Players[player].Profile.Name:name;
if(player>=0)quickPlayerSentOff[player]=true;
AddBroadcast("RED CARD",$"{dismissed} is sent off! {name} must play with fewer players.",team,true);
}
}
if(Match.Minute>=quickRoutineMinute+6 && !Match.Finished)
{
quickRoutineMinute=Match.Minute;
int team=Match.Owner>=0?Match.Players[Match.Owner].Team:Match.LastTouch;
string name=Match.TeamName(team);
string[] notes={
$"{name} recycle possession and look for a route forward.",
$"The tempo rises as {name} push into the opposition half.",
$"A spell of midfield play. {name} keep the ball moving.",
$"{name} probe down the flank, but the defence holds its shape."
};
AddBroadcast("ON THE PITCH",notes[(Match.Minute/6+Math.Abs(Match.Seed%notes.Length))%notes.Length],team);
}
}
// A medical cycle retains its patient after removal. Observe that single
// reference, rather than searching all footballers on every simulation step.
void CollectMedicalBroadcastEvents()
{
if(Match.MedicalActive)
{
if(!ReferenceEquals(quickMedicalPatient,Match.MedicalPatient))
{
quickMedicalPatient=Match.MedicalPatient;
AddBroadcast("INJURY",$"Injury: {quickMedicalPatient.Profile.Name} cannot continue. Medical staff bring the stretcher.",quickMedicalPatient.Team,true);
}
return;
}
if(quickMedicalPatient==null)return;
var outgoing=quickMedicalPatient;quickMedicalPatient=null;
var incoming=Match.Players[outgoing.Id];
if(!ReferenceEquals(outgoing,incoming) && incoming.InPlay)
AddBroadcast("SUBSTITUTION",$"Substitution: {incoming.Profile.Name} replaces injured {outgoing.Profile.Name}.",outgoing.Team,true);
else
AddBroadcast("INJURY",$"No substitute: {outgoing.Profile.Name} leaves the match. {Match.TeamName(outgoing.Team)}"+
(Match.Abandoned?" have fewer than seven players: match abandoned.":" continue with fewer players."),outgoing.Team,true);
}
// Home substitutions also include the coach's ordinary changes. Opponent
// replacements are tracked by the simulation's existing medical counter.
public string MatchMedicalSummary
{
get
{
if(Match==null)return "";
bool ru=GamePreferences.Russian;
int injuriesHome=Match.InjuryKnocks[0]+Match.MinorInjuries[0]+Match.SeriousInjuries[0];
int injuriesAway=Match.InjuryKnocks[1]+Match.MinorInjuries[1]+Match.SeriousInjuries[1];
int substitutionsHome=Math.Max(SubstitutionsUsed,Match.InjurySubstitutionsUsed[0]);
int substitutionsAway=Match.InjurySubstitutionsUsed[1];
var parts=new List<string>(3);
if(injuriesHome+injuriesAway>0)
parts.Add($"{(ru?"Ушибы и травмы":"Knocks and injuries")}: {injuriesHome} : {injuriesAway}");
if(Match.SeriousInjuries[0]+Match.SeriousInjuries[1]>0)
parts.Add($"{(ru?"Не смогли продолжить":"Unable to continue")}: {Match.SeriousInjuries[0]} : {Match.SeriousInjuries[1]}");
if(substitutionsHome+substitutionsAway>0)
parts.Add($"{(ru?"Замены":"Substitutions")}: {substitutionsHome} : {substitutionsAway}");
return string.Join(" · ",parts);
}
}
void AdvanceQuickMatch()
{
if(!QuickMatchRunning || GameSettingsOpen || Time.Now<quickHoldUntil)return;
FeaturedBroadcastLine=null;
Match.InteractiveSetPieces=false;
// 30 simulated seconds per real second gives a full match about 12–18 seconds of air time.
quickTickDebt=Math.Min(quickTickDebt+Time.Delta*1800f,180f);
int steps=Math.Min((int)quickTickDebt,180);
quickTickDebt-=steps;
for(int i=0;i<steps && !Match.Finished;i++)
{
if(Match.ManualRestartWaiting)Match.AutoReleaseSetPiece();
Match.Step(1f/60f);quickMatchSteps++;
EnsureInjuryControl();
CollectBroadcastEvents();
if(Match.ShootoutActive)break;
if(Time.Now<quickHoldUntil)break;
}
if(Match.ShootoutActive)
{
// A tied cup match continues with the actual visible kicks. The
// same match/ticket survives the transition from text to the field.
QuickMatchRunning=false;QuickBroadcastOpen=false;ClubOpen=false;Paused=false;
Replay.Reset();replayAfterGoal=0;accumulator=0;Director.Update(Match,0);
ClubNotice=GamePreferences.Russian?"Ничья. Серия пенальти продолжается на поле.":"Draw. Watch the penalty shootout on the pitch.";
return;
}
if(Match.Finished)
{
QuickMatchRunning=false;
AddBroadcast("FULL TIME",$"The final whistle. {Match.HomeName} {Match.Goals[0]}–{Match.Goals[1]} {Match.AwayName}.",-1,true);
ClubNotice="Full time. Match report and rewards are ready.";
}
else if(quickMatchSteps>40000)
{
QuickMatchRunning=false;QuickBroadcastOpen=false;ClubOpen=false;Paused=false;
ClubNotice="Text commentary paused. Continue the match on the pitch.";
}
}
public void OpenBroadcastReport()
{
if(QuickMatchRunning || !Match.Finished || Career.Pending!=null && !IsRulesPreview)return;
QuickBroadcastOpen=false;ClubOpen=false;Paused=true;
}
// Editor verification uses an isolated match so the player's career and save are untouched.
public void PreviewQuickBroadcast()
{
if(MediaLeagueActive)return;
previewBroadcastNotice=ClubNotice;
ShowRulesPreview("football-ai");
BeginQuickBroadcast(false,true);
}
public void RestoreQuickBroadcastPreview()
{
if(!IsRulesPreview)
{
// A hotload can replace preview fields before the editor asks to restore.
if(ClubNotice=="Full time. Match report and rewards are ready." && Career.Pending==null)ClubNotice="";
return;
}
QuickMatchRunning=false;QuickBroadcastOpen=false;FeaturedBroadcastLine=null;
EndRulesPreview();
ClubNotice=previewBroadcastNotice??"";
previewBroadcastNotice=null;
}
bool preparationCup;
bool preparationFriendly;
public void PrepareCareer(bool cup)
{
if(!RequireFinishedMatchForNavigation())return;
if(!RequireRelegationResolved())return;
if(Career.Pending!=null){ResumeMatch();return;}
if(Career.Next(cup)==null)return;
EndRulesPreview();preparationCup=cup;preparationFriendly=false;PreparingMatch=true;MatchModeSelection=false;ClubOpen=true;ClubPage="Состав";
}
public void PrepareFriendly()
{
if(!RequireFinishedMatchForNavigation())return;
if(!RequireRelegationResolved())return;
if(Career.Pending!=null){ResumeMatch();return;}
if(Career.CupWindow)return;
EndRulesPreview();preparationFriendly=true;PreparingMatch=true;MatchModeSelection=false;ClubOpen=true;ClubPage="Состав";
}
public void CancelPreparation(){PreparingMatch=false;MatchModeSelection=false;ClubPage="Главная";}
public void ConfirmPreparation(){if(!PreparingMatch || !RequireCompleteLineup())return;PreparingMatch=false;MatchModeSelection=true;}
public void ReturnToLineup(){if(!MatchModeSelection)return;MatchModeSelection=false;PreparingMatch=true;ClubPage="Состав";}
public void ChooseMatchMode(bool textBroadcast)
{
if(!MatchModeSelection || !RequireCompleteLineup())return;
MatchModeSelection=false;
if(preparationFriendly){Restart(true);return;}
StartCareer(preparationCup);
if(textBroadcast && Career.Pending!=null)BeginQuickBroadcast(false);
}
public string PreparationOpponent
{
get {if(preparationFriendly)return "FRIENDLY MATCH";var f=Career.Next(preparationCup);return f==null?"":Career.FixtureTeamName(f,f.Home==0?f.Away:f.Home);}
}
public string PreparationTactics
{
get
{
if(preparationFriendly)return "";
var f=Career.Next(preparationCup);if(f==null)return "";
string style=MatchSimulation.TacticalName(Career.TacticalIdentity(f.Home==0?f.Away:f.Home,Career.TournamentFor(f)),GamePreferences.Russian);
string stage=preparationCup?(GamePreferences.Russian?f.Round==3?"Финал":f.Round==2?"Полуфинал":"Четвертьфинал":
f.Round==3?"Final":f.Round==2?"Semifinal":"Quarterfinal"):"";
return stage==""?style:stage+" · "+style;
}
}
public void BeginMatchIntro(){enteredMatch=Match;SecondHalfIntro=false;MatchIntroUntil=Time.Now+IntroDuration;}
public void BeginSecondHalfIntro(){SecondHalfIntro=true;MatchIntroUntil=Time.Now+IntroDuration;}
public void SkipMatchIntro() { MatchIntroUntil=0;SecondHalfIntro=false; }
}