The main game component for a football management game. Owns the MatchSimulation, career and club screens, broadcast camera, goal replay, direct player orders, and the per-frame loop that steps the simulation and builds the HUD components (MatchHud, ClubHud, StrategyEditor, GameSettingsHud). Also handles career saves, pack opening, injuries/substitutions and preview/test scenarios.
using Sandbox;
using Sandbox.UI;
using Socer;
using System.Collections.Generic;
using System.Linq;
public sealed partial class MatchController : Component
{
// Technical-area presentation is updated alongside the crowd, outside simulation ticks.
public MatchSimulation Match { get; private set; } = new();
public MatchAtmosphere Atmosphere {get;}=new();
public bool Paused { get; set; }
public MatchDirector Director { get; private set; } = new();
public float Speed { get => Director.ManualSpeed; set { Director.ManualSpeed=System.Math.Clamp(value,.5f,1.5f); Director.Automatic=false; Director.Update(Match,0); } }
public BroadcastCamera Broadcast { get; private set; } = new();
public const float ManualCameraHoldSeconds=2.5f;
float manualCameraUntil;
MatchSimulation manualCameraMatch;
public bool ManualCameraActive=>Time.Now<manualCameraUntil;
public float ManualCameraRemaining=>System.Math.Clamp(manualCameraUntil-Time.Now,0,ManualCameraHoldSeconds);
public float ManualCameraProgress=>ManualCameraRemaining/ManualCameraHoldSeconds;
public bool FieldDragSlow {get;private set;}
public float PlaybackRate=>(FieldDragSlow || CommandWheelOpen || AwaitingOrder!=DirectOrderKind.None)?.2f:MediaLeagueActive?1.5f:FootballLabActive?Director.Rate:Director.PlaybackRate;
public void SetFieldDragSlow(bool active)
{
active&=!ClubOpen && !StrategyLabOpen && !Match.Finished && !Replay.Playing && !Paused;
if(FieldDragSlow==active)return;
FieldDragSlow=active;
accumulator=0;
}
public void PanBroadcastCamera(Point delta)
{
if(ClubOpen || StrategyLabOpen || Match.Finished || Replay.Playing)return;
Broadcast.Center=new Point(System.Math.Clamp(Broadcast.Center.X+delta.X,-8,113),
System.Math.Clamp(Broadcast.Center.Y+delta.Y,-9,77));
Broadcast.Enabled=true;
Broadcast.ResumeFollow();
manualCameraUntil=Time.Now+ManualCameraHoldSeconds;
}
public void ZoomBroadcastCamera(float wheel)
{
if(ClubOpen || StrategyLabOpen || Match.Finished || Replay.Playing || System.Math.Abs(wheel)<.001f)return;
float baseWidth=System.Math.Max(113,76*ViewAspect);
float zoom=System.Math.Clamp(baseWidth/System.Math.Max(20,Broadcast.ViewWidth),1,5);
// Panel wheel values are positive when scrolling down.
zoom=System.Math.Clamp(zoom*System.MathF.Exp(-wheel*.13f),1,5);
Broadcast.Zoom=zoom;
Broadcast.ViewWidth=baseWidth/zoom;
Broadcast.ViewHeight=Broadcast.ViewWidth/System.Math.Max(.5f,ViewAspect);
Broadcast.Enabled=true;
Broadcast.ResumeFollow();
manualCameraUntil=Time.Now+ManualCameraHoldSeconds;
}
public void ReturnToBroadcastCamera(){manualCameraUntil=0;Broadcast.Enabled=true;}
public void ToggleBroadcastCamera()
{
if(ManualCameraActive)ReturnToBroadcastCamera();
else Broadcast.Enabled=!Broadcast.Enabled;
}
public GoalReplay Replay { get; } = new();
public float ViewAspect { get; set; } = 1.8f;
public float PreviewCameraWidth {get;set;}
public bool FootballLabActive {get;private set;}
public string FootballLabScenario {get;private set;}="dribble";
public int FootballLabSeed {get;private set;}=2026;
public int FootballLabAttackerRating {get;private set;}=84;
public int FootballLabDefenderRating {get;private set;}=78;
public bool FootballLabTrialComplete {get;private set;}
public float FootballLabStartedAt {get;private set;}
public float FootballLabDuration=>FootballLabScenario switch
{
"nutmeg"=>2.0f,"tackle" or "tackle-front" or "tackle-side" or "tackle-recovery" or "tackle-slide" or "shoulder"=>4.5f,"sprint"=>6f,
"dribble" or "one-on-one" or "cross"=>8f,
"free-kick" or "corner" or "penalty"=>12f,
"match"=>float.PositiveInfinity,_=>FootballLabScenario.StartsWith("skill-")?3.5f:7f
};
public bool ShowVision { get; set; } = true;
public bool ShowSpeech { get; set; } = true;
public int SelectedPlayer { get; set; } = 9;
public int OrderedPlayer { get; private set; }=9;
public DirectOrderKind AwaitingOrder { get; private set; }
readonly List<FieldPing> fieldPings=new();
int nextFieldPing;
MatchSimulation pingMatch;
public IReadOnlyList<FieldPing> FieldPings=>fieldPings;
public void EmitFieldPing(FieldPingKind kind,Point point,int playerId=-1)
{
if(pingMatch!=Match){fieldPings.Clear();pingMatch=Match;}
fieldPings.RemoveAll(p=>Time.Now-p.StartedAt>1.55f);
if(fieldPings.Count>=5)fieldPings.RemoveAt(0);
fieldPings.Add(new FieldPing(++nextFieldPing,kind,point,playerId,Time.Now));
TechnicalArea.Command(this,kind,point,playerId);
ObserveMatchTutorialPing(kind,point,playerId);
}
public string OrderPrompt=>AwaitingOrder switch
{
DirectOrderKind.PressPlayer=>CommandText("Выберите соперника для прессинга","Select an opponent to press"),
DirectOrderKind.MarkPlayer=>CommandText("Выберите соперника для опеки","Select an opponent to mark"),
DirectOrderKind.GuardZone=>CommandText("Выберите зону на поле","Choose a defensive zone on the pitch"),
DirectOrderKind.AttackRun=>CommandText("Выберите точку для забегания","Choose a point for the attacking run"),
_=>"Select your player and give an instruction"
};
public void SelectPlayer(int id,bool showPing=true)
{
if(id<0 || id>=Match.Players.Count || Match.Players[id].SentOff)return;
SelectedPlayer=id;
if(id<11)
{
OrderedPlayer=id;manualSelectionMatch=Match;AwaitingOrder=DirectOrderKind.None;
if(showPing)EmitFieldPing(FieldPingKind.Select,Match.Players[id].Position,id);
return;
}
// Roster and set-piece panels select opponents for inspection. The
// field surface issues default marking explicitly through CallMarkTarget.
if(AwaitingOrder is DirectOrderKind.PressPlayer or DirectOrderKind.MarkPlayer)CallMarkTarget(id);
else if(showPing)EmitFieldPing(FieldPingKind.Select,Match.Players[id].Position,id);
}
int ManualCommandActor=>manualSelectionMatch==Match && OrderedPlayer>=0 && OrderedPlayer<11 &&
!Match.Players[OrderedPlayer].SentOff && !Match.Players[OrderedPlayer].Keeper && Match.Seconds>=Match.Players[OrderedPlayer].FallUntil?OrderedPlayer:-1;
public bool CallMarkTarget(int targetId,int actor=-1)
{
var kind=actor<0 && AwaitingOrder is DirectOrderKind.PressPlayer or DirectOrderKind.MarkPlayer?AwaitingOrder:DirectOrderKind.MarkPlayer;
if(actor<0)actor=AwaitingOrder==kind && AwaitingOrder!=DirectOrderKind.None?OrderedPlayer:ManualCommandActor;
return CallOpponentTarget(targetId,kind,actor);
}
bool CallOpponentTarget(int targetId,DirectOrderKind kind,int actor)
{
string reason=FieldCallContextFailure();
if(reason!=""){NotifyCommand(reason);return false;}
if(actor>=0)
{
reason=Match.FieldOrderFailure(actor,kind,targetId);
if(reason!=""){NotifyCommand(FieldCommandFailureText(reason));return false;}
}
else
{
if(targetId<11 || targetId>=Match.Players.Count || Match.Players[targetId].SentOff)
{NotifyCommand(FieldCommandFailureText("opponent"));return false;}
if(Match.Players[targetId].Keeper){NotifyCommand(FieldCommandFailureText("target-keeper"));return false;}
}
int accepted=Match.CallOpponentOrder(targetId,kind,actor);
if(accepted<0){NotifyCommand(FieldCommandFailureText("available-actor"));return false;}
OrderedPlayer=accepted;SelectedPlayer=targetId;AwaitingOrder=DirectOrderKind.None;
EmitFieldPing(kind==DirectOrderKind.PressPlayer?FieldPingKind.Press:FieldPingKind.Mark,Match.Players[targetId].Position,targetId);
var player=Match.Players[accepted];var target=Match.Players[targetId];
string action=kind==DirectOrderKind.PressPlayer?CommandText("Прессинг","Press"):CommandText("Опека","Mark");
NotifyCommand($"#{player.Number} · {action} #{target.Number}"+(Match.DefensiveOrderPending(player)?CommandText(" при потере мяча"," after possession is lost"):""));
return true;
}
public bool CallAttackTarget(bool force=false)
{
string reason=FieldCallContextFailure();
if(reason=="")reason=FieldCommandFailureText(Match.FieldAttackFailure());
if(reason!=""){NotifyCommand(reason);return false;}
if(!Match.CallAttack(force)){NotifyCommand(FieldCommandFailureText(Match.FieldAttackFailure()));return false;}
EmitFieldPing(FieldPingKind.Attack,Match.HomeAttackGoal);
NotifyCommand(force?CommandText("Подготовить удар сейчас","Prepare a shot now"):CommandText("Обострить и искать момент для удара","Attack and look for a shooting chance"));
return true;
}
public bool CallPassTarget(int targetId)
{
string reason=FieldCallContextFailure();
if(reason=="")reason=FieldCommandFailureText(Match.FieldPassFailure(targetId));
if(reason!=""){NotifyCommand(reason);return false;}
if(!Match.CallPass(targetId)){NotifyCommand(FieldCommandFailureText(Match.FieldPassFailure(targetId)));return false;}
EmitFieldPing(FieldPingKind.Pass,Match.Players[targetId].Position,targetId);
NotifyCommand(CommandText($"Пас на #{Match.Players[targetId].Number}",$"Pass to #{Match.Players[targetId].Number}"));
return true;
}
public bool CallMoveTarget(int actor,Point point)
{
string reason=FieldCallContextFailure();
var kind=(Match.Owner>=0?Match.Players[Match.Owner].Team:Match.LastTouch)==0?DirectOrderKind.AttackRun:DirectOrderKind.GuardZone;
if(reason=="")reason=FieldCommandFailureText(Match.FieldOrderFailure(actor,kind,point:point));
if(reason!=""){NotifyCommand(reason);return false;}
if(!Match.CallMovePlayer(actor,point)){NotifyCommand(FieldCommandFailureText(Match.FieldOrderFailure(actor,kind,point:point)));return false;}
OrderedPlayer=actor;
EmitFieldPing(kind==DirectOrderKind.AttackRun?FieldPingKind.Run:FieldPingKind.Zone,point);
NotifyCommand(CommandText($"#{Match.Players[actor].Number} · "+(kind==DirectOrderKind.AttackRun?"Забегание":"Держать зону"),
$"#{Match.Players[actor].Number} · "+(kind==DirectOrderKind.AttackRun?"Make a run":"Hold zone")));
return true;
}
public void BeginDirectOrder(DirectOrderKind kind)
{
string reason=FieldCallContextFailure();
if(reason=="" && (OrderedPlayer<0 || OrderedPlayer>=11 || Match.Players[OrderedPlayer].SentOff))reason=FieldCommandFailureText("actor");
if(reason=="" && Match.Players[OrderedPlayer].Keeper)reason=FieldCommandFailureText("actor-keeper");
if(reason=="" && Match.Seconds<Match.Players[OrderedPlayer].FallUntil)reason=FieldCommandFailureText("fallen");
if(reason!=""){NotifyCommand(reason);return;}
if(kind is DirectOrderKind.PressPlayer or DirectOrderKind.MarkPlayer && SelectedPlayer>=11 && SelectedPlayer<Match.Players.Count && !Match.Players[SelectedPlayer].SentOff)
{
CallOpponentTarget(SelectedPlayer,kind,OrderedPlayer);return;
}
commandWheelMatch=Match;AwaitingOrder=kind;
NotifyCommand(OrderPrompt);
}
public bool AssignFieldOrder(Point where)
{
if(AwaitingOrder is not (DirectOrderKind.GuardZone or DirectOrderKind.AttackRun))return false;
string reason=FieldCallContextFailure();
if(reason=="")reason=FieldCommandFailureText(Match.FieldOrderFailure(OrderedPlayer,AwaitingOrder,point:where));
if(reason!=""){NotifyCommand(reason);return false;}
bool done=Match.GiveDirectOrder(OrderedPlayer,AwaitingOrder,point:where);
if(done)
{
EmitFieldPing(AwaitingOrder==DirectOrderKind.AttackRun?FieldPingKind.Run:FieldPingKind.Zone,where);
NotifyCommand(CommandText($"#{Match.Players[OrderedPlayer].Number} · приказ принят",$"#{Match.Players[OrderedPlayer].Number} · instruction received"));
AwaitingOrder=DirectOrderKind.None;
}
else NotifyCommand(FieldCommandFailureText(Match.FieldOrderFailure(OrderedPlayer,AwaitingOrder,point:where)));
return done;
}
public void CancelDirectOrder()=>AwaitingOrder=DirectOrderKind.None;
public void ClearDirectOrder()
{
if(!Match.GiveDirectOrder(OrderedPlayer,DirectOrderKind.None))
{NotifyCommand(FieldCommandFailureText(Match.FieldOrderFailure(OrderedPlayer,DirectOrderKind.None)));return;}
if(OrderedPlayer==Match.FieldPassReceiver)Match.CancelFieldPass();
if(OrderedPlayer==Match.Owner)Match.FieldAttackUntil=Match.FieldShootUntil=0;
AwaitingOrder=DirectOrderKind.None;
EmitFieldPing(FieldPingKind.Clear,Match.Players[OrderedPlayer].Position,OrderedPlayer);
NotifyCommand(CommandText($"#{Match.Players[OrderedPlayer].Number} · приказ снят",$"#{Match.Players[OrderedPlayer].Number} · order cleared"));
}
public int Revision { get; private set; }
public ClubCareer Career { get; private set; }
public bool ClubOpen { get; private set; }=true;
public bool GameSettingsOpen {get;private set;}
bool settingsWasPaused;
public int TutorialStep {get;private set;}=-1;
public string ClubPage { get; set; }="Состав";
public string ClubNotice { get; private set; }="";
public int SelectedClub { get; set; }=1;
public PlayerCard RevealedCard { get; private set; }
public PackOpening RevealedPack { get; private set; }
bool saveBlocked;
bool careerMatch;
bool attemptBound;
string lastRoundError;
MatchSimulation appliedClubPlan;
int friendlyOpponent=1;
MatchSimulation previewOriginal;
MatchDirector previewDirector;
BroadcastCamera previewCamera;
bool previewWasPaused, previewClubOpen;
int previewSelection;
float accumulator;
int observedGoals;
float replayAfterGoal;
public void EnableAutomatic() { Director.Automatic=true; Director.Update(Match,0); }
protected override void OnStart()
{
GamePreferences.Load();
GameAudio.Prepare();
PlayerCatalog.Reload();
LoadCareerForStart();
Career.EnsurePersonalities();
if(Career.Pending!=null)
{
if(Career.HasCompleteLineup){StartCareer(Career.Pending.Cup);ClubNotice="Unfinished match restarted. Rewards are paid after full time.";}
else
{
// A saved empty XI is a valid preparation state. Keep its
// fixture editable after reload, without starting simulation.
Match=new MatchSimulation(Career.Pending.Seed);careerMatch=true;Paused=true;
ClubNotice="Fill all 11 positions before starting a match.";
}
}
else {friendlyOpponent=MatchSimulation.FriendlyOpponent(Match.Seed);Career.Apply(Match,friendlyOpponent);Paused=true;}
ResetMatchSubstitutions();
observedGoals=Match.Goals[0]+Match.Goals[1];Replay.Capture(Match);
ClubPage="Главная";
ClubOpen=Career.Pending==null || !Career.HasCompleteLineup;
ClubTour.Reload();
TutorialStep=ClubOpen && !GamePreferences.TutorialSeen?0:-1;
var ui = new GameObject(true, "Match interface");
ui.Components.Create<ScreenPanel>();
ui.Components.Create<MatchHud>();
ui.Components.Create<ClubHud>();
ui.Components.Create<StrategyEditor>();
ui.Components.Create<GameSettingsHud>();
careerSaveHud=ui.Components.Create<CareerSaveHud>();
relegationHud=ui.Components.Create<RelegationChoiceHud>();
LoadStrategies();
SaveCareer();
if(Career.HasRelegationChoice)OpenRelegationChoice();
Log.Info($"Socer: automatic match ready, seed {Match.Seed}");
}
protected override void OnUpdate()
{
long performanceStart=PerformanceDiagnostics.Begin();
TickGameHotkeys();
EnsureCareerSaveInterface();
TickCareerSaveRetry();
TickCupWorldCompletion();
TickPlatformAchievements();
EnsureRelegationInterface();
if(RelegationChoiceOpen){Paused=true;accumulator=0;}
double simulationMilliseconds=0;
if(observedPeriodMatch!=Match){observedPeriodMatch=Match;observedSecondHalfIntro=0;}
Match.HomeName=ClubIdentity.DisplayName(Match.HomeName);Match.AwayName=ClubIdentity.DisplayName(Match.AwayName);
if(careerMatch && !IsRulesPreview && Career.Pending!=null && Career.HasCompleteLineup)
{
if(appliedClubPlan!=Match){Match.SetBroadcastPlan(Career.CoachTempo,Career.CoachMentality<0?Career.CoachTactic*.5f:Career.CoachMentality);appliedClubPlan=Match;}
Career.RunBackground??=action=>GameTask.RunInThreadAsync(action);
Career.PrepareRoundResults();
}
if(manualCameraMatch!=Match){manualCameraMatch=Match;manualCameraUntil=0;FieldDragSlow=false;}
if(ClubOpen || StrategyLabOpen || Match.Finished || Replay.Playing || Paused)SetFieldDragSlow(false);
if(SubstitutionOpen){Paused=true;accumulator=0;}
EnsureInjuryControl();
SyncAutomaticStrategies();
Match.InteractiveSetPieces=ManualSetPieces && !Match.ShootoutActive && !QuickMatchRunning && !StrategyLabOpen && (!IsRulesPreview || PreviewManualSetPieces);
if(Match.ManualRestartWaiting)
{
if(!Match.InteractiveSetPieces)Match.AutoReleaseSetPiece();
else if(!ClubOpen && !Paused && !Replay.Playing)Match.AdvanceManualRestartWait(Time.Delta);
}
// One-time migration for a career match already running when this version hotloads.
// Preview/friendly simulations must never bind or settle a career ticket.
if(careerMatch && !IsRulesPreview && !attemptBound && Career?.Pending!=null)
{Career.Pending.Seed=Match.Seed;attemptBound=true;}
if(Replay.Playing && !Paused)Replay.Advance(Time.Delta);
AdvanceQuickMatch();
ObserveShootoutAudio();
if (!ClubOpen && !Paused && !Match.Finished && !Replay.Playing && !Match.ManualRestartWaiting && !MatchIntroActive)
{
long simulationStart=PerformanceDiagnostics.Begin();
accumulator += System.Math.Min(Time.Delta, .1f);
// Spend real time per simulation tick, rechecking danger after every tick.
while (true)
{
float cost=Match.PeriodWalkRemaining>0?1f/60f:(1f/60f)/PlaybackRate;
if(accumulator<cost) break;
accumulator-=cost;
Match.Step(1f/60f);
EnsureInjuryControl();
TickMatchTutorial();
if(Paused){accumulator=0;break;}
if(Match.SecondHalfIntroSequence>observedSecondHalfIntro && !StrategyLabOpen)
{
observedSecondHalfIntro=Match.SecondHalfIntroSequence;
BeginSecondHalfIntro();accumulator=0;break;
}
if(FootballLabActive && !FootballLabTrialComplete &&
(Match.Finished || Match.Seconds-FootballLabStartedAt>=FootballLabDuration))
{FootballLabTrialComplete=true;Paused=true;accumulator=0;break;}
if(Match.ManualRestartWaiting){accumulator=0;break;}
if(StrategyLabOpen && StrategySeeking && StrategySeekCanFinish)
{FinishStrategySeek();accumulator=0;break;}
Director.Update(Match,cost);
if(!Match.MedicalActive)Replay.Capture(Match,1f/60f);
int goals=Match.Goals[0]+Match.Goals[1];
if(goals>observedGoals)
{
observedGoals=goals;
GameAudio.Goal();
if(!IsRulesPreview) {Replay.MarkGoal(Match);replayAfterGoal=4.1f;}
}
if(replayAfterGoal>0 && Match.GoalHoldRemaining>0)
{
replayAfterGoal-=1f/60f;
if(replayAfterGoal<=0 && Replay.Start()) {accumulator=0;break;}
}
}
simulationMilliseconds=PerformanceDiagnostics.Milliseconds(simulationStart);
}
SettleFinishedCareerMatch();
if(!GameSettingsOpen)TickStrategyLab();
Atmosphere.Update(this,Time.Delta);
TechnicalArea.Update(this,Time.Delta);
if(pingMatch!=Match){fieldPings.Clear();pingMatch=Match;}
fieldPings.RemoveAll(p=>Time.Now-p.StartedAt>1.55f);
TickCommandWheel();
Revision++;
bool fixedPreview=IsRulesPreview && !StrategyLabOpen && PreviewCameraWidth>0;
if((!StrategyLabOpen || (!StrategyCameraManual && (StrategyTesting || StrategySeeking))) && !ManualCameraActive && !fixedPreview)
Broadcast.Update(Match,System.Math.Min(Time.Delta,.05f),ViewAspect);
if(IsRulesPreview && !StrategyLabOpen && PreviewCameraWidth>0 && !ManualCameraActive)
{
// Keep the athlete and the ball in frame after a shot or a turnover.
// Following only the ball used to leave completed drills showing an empty goal.
var focus=Match.Owner>=0?Match.Players[Match.Owner].Position:Match.Ball;
if(FootballLabActive && FootballLabScenario is not ("corner" or "free-kick" or "penalty" or "cross" or "pass" or "first-touch"))
{
var athlete=Match.Players[9].Position;
// A short drill can finish with the ball at a distant corner
// restart. Keep the duel in view instead of centring empty grass.
focus=(Match.Ball-athlete).Length<16?athlete*.65f+Match.Ball*.35f:athlete;
}
Broadcast.FollowPreview(Match,focus,PreviewCameraWidth,Time.Delta,ViewAspect);
}
PerformanceDiagnostics.RecordFrame(Time.Delta,performanceStart,simulationMilliseconds);
}
// Shared by field and text presentation. The career ticket is consumed once.
void SettleFinishedCareerMatch()
{
if(careerMatch && !IsRulesPreview && Match.Finished)
{
if(!Career.RoundResultsReady)
ClubNotice=GamePreferences.Russian?"Считаем остальные матчи тура…":"Finishing the other matches…";
if(!string.IsNullOrEmpty(Career.RoundResultsError) && lastRoundError!=Career.RoundResultsError)
{lastRoundError=Career.RoundResultsError;Log.Error(lastRoundError);}
int coins=Career.Coins,xp=Career.Card(0).Experience,cupTokens=Career.CupTokens;
var fixture=Career.ActiveFixture;
if(Career.Complete(Match,MatchParticipants))
{
Settlement=new MatchSettlement(Match,Career.Coins-coins,Career.Card(0).Experience-xp,fixture.Winner==0,fixture.Winner<0,Career.LastResult,Career.CupTokens-cupTokens);
careerMatch=false;ClubNotice=Career.LastResult;SaveCareer();
}
}
}
public void Restart(bool newSeed)
{
if(!RequireFinishedMatchForNavigation())return;
if(!RequireRelegationResolved())return;
if(!RequireCompleteLineup())return;
EndRulesPreview();
if(Career?.Pending!=null) {ClubNotice="Finish the current competitive match first.";return;}
var tactics=Match.Tactics;
Match=new MatchSimulation(MatchSimulation.CreateSeed(Match.Seed));
AwaitingOrder=DirectOrderKind.None;SelectedPlayer=OrderedPlayer=9;
if(newSeed)friendlyOpponent=MatchSimulation.FriendlyOpponent(Match.Seed);
Replay.Reset();observedGoals=0;replayAfterGoal=0;
Match.Tactics=tactics;
Career?.Apply(Match,friendlyOpponent);
ResetMatchSubstitutions();
var automatic=Director.Automatic; var manual=Director.ManualSpeed;
Director=new MatchDirector { Automatic=automatic, ManualSpeed=manual };
Broadcast=new BroadcastCamera { Enabled=Broadcast.Enabled };
Paused=false; accumulator=0;
careerMatch=false;ClubOpen=false;
BeginMatchIntro();
}
public void SaveCareer()
{
if(saveBlocked || careerLoadBlocked || Career==null)return;
try
{
Career.EnsurePersonalities();
var result=SaveStore.Save(Career);
if(result.Success){CareerWriteSucceeded();platformAchievementsDirty=true;RefreshPlatformAchievementEvidence();}
else CareerWriteFailed(result.Error);
}
catch(System.Exception e){CareerWriteFailed(e.Message);}
}
public void OpenClub()
{
if(InjurySubstitutionRequired){EnsureInjuryControl();return;}
if(MediaLeagueActive){if(CanLeaveMediaLeague)CompleteMediaLeagueMatch();else OpenRelegationChoice();return;}
if(!RequireFinishedMatchForNavigation())return;
CloseSubstitutions();EndRulesPreview();Replay.Stop();replayAfterGoal=0;ClubOpen=true;
RevealedCard=null;RevealedPack=null;Career?.EnsurePersonalities();SaveCareer();
}
public void StartTutorial()
{
if(Career==null || !ClubOpen || PreparingMatch || MatchModeSelection || QuickBroadcastOpen)return;
ClubTour.Reload();
TutorialStep=0;ClubPage=ClubTour.Steps[0].Page;
}
public void NextTutorialStep()
{
if(TutorialStep<0)return;
if(TutorialStep>=ClubTour.Steps.Length-1){FinishTutorial();return;}
TutorialStep++;ClubPage=ClubTour.Steps[TutorialStep].Page;
}
public void PreviousTutorialStep()
{
if(TutorialStep<=0)return;
TutorialStep--;ClubPage=ClubTour.Steps[TutorialStep].Page;
}
public void FinishTutorial(bool remember=true)
{
if(TutorialStep<0)return;
TutorialStep=-1;ClubPage="Главная";if(remember)GamePreferences.MarkTutorialSeen();
}
public void ToggleGameSettings()
{
if(MatchExitPromptOpen)return;
if(GameSettingsOpen)
{
GameHotkeys.CancelCapture();
GameSettingsOpen=false;
Paused=settingsWasPaused || SubstitutionOpen || InjuryChoiceOpen;
}
else
{
InputFocus.Clear();
settingsWasPaused=Paused;
GameSettingsOpen=true;
Paused=true;
}
GameAudio.Tap();
}
protected override void OnDestroy()
{
GameAudio.StopCrowd();
base.OnDestroy();
}
public void SkipGoalReplay(){Replay.Stop();accumulator=0;}
public void ExitFinishedMatch()
{
if(!Match.Finished) return;
OpenClub();
ClubPage="Главная";
}
public bool RequireCompleteLineup()
{
if(Career.HasCompleteLineup)return true;
ClubOpen=true;ClubPage="Состав";ClubNotice="Fill all 11 positions before starting a match.";return false;
}
public void ResumeMatch() {if(InjurySubstitutionRequired){EnsureInjuryControl();return;}if(!RequireRelegationResolved() || QuickMatchRunning || (Match.Seconds<=0 && !RequireCompleteLineup()))return;ClubOpen=false;Paused=false;if(Match.Seconds==0)BeginMatchIntro();}
public void StartCareer(bool cup)
{
if(!RequireFinishedMatchForNavigation())return;
if(!RequireRelegationResolved())return;
if(!RequireCompleteLineup())return;
EndRulesPreview();
Career.RunBackground=action=>GameTask.RunInThreadAsync(action);
var ticket=Career.Begin(cup); if(ticket==null) return;
var fixture=Career.ActiveFixture;
ticket.Seed=MatchSimulation.CreateSeed(Match.Seed);
attemptBound=true;
Match=new MatchSimulation(ticket.Seed);Career.Apply(Match,fixture.Home==0?fixture.Away:fixture.Home);
ResetMatchSubstitutions();
Replay.Reset();observedGoals=0;replayAfterGoal=0;
Director=new();Broadcast=new();accumulator=0;Paused=false;ClubOpen=false;careerMatch=true;
BeginMatchIntro();
SaveCareer();
}
public void SetFormation(int index)
{
if(!CanEditStartingLineup || !Career.SetFormation(index,BeforeKickoff)) {ClubNotice="Formation changes resume after full time.";return;}
RefreshStartingLineup();SaveCareer();
}
bool BeforeKickoff=>careerMatch && !IsRulesPreview && ClubOpen && !QuickMatchRunning &&
Career.Pending!=null && Career.Pending.Seed==Match.Seed && Match.Seconds<=0 && !Match.Finished;
public bool CanEditStartingLineup=>Career!=null && (Career.Pending==null || BeforeKickoff);
void RefreshStartingLineup()
{
if(!BeforeKickoff || !Career.HasCompleteLineup)return;
var fixture=Career.ActiveFixture;
Career.Apply(Match,fixture.Home==0?fixture.Away:fixture.Home);
ResetMatchSubstitutions();Replay.Reset();Replay.Capture(Match);
}
public void AssignCard(int slot,int id)
{
bool assigned=CanEditStartingLineup && Career.Assign(slot,id,BeforeKickoff);
bool repeated=Career.Cards.FirstOrDefault(c=>c.Id==id) is {} chosen && Career.Lineup.Where((other,i)=>i!=slot && other!=id).Any(other=>Career.Cards.Any(c=>c.Id==other && c.CatalogId==chosen.CatalogId));
ClubNotice=assigned?"Lineup updated.":repeated?(GamePreferences.Russian?"Этот футболист уже находится в составе. Замените его карточку на той же позиции.":"This footballer is already in the lineup. Replace their card in the same slot."):"Cannot assign: goalkeepers play in goal, and the current match must be finished.";
if(assigned)RefreshStartingLineup();SaveCareer();
}
public void RemoveStartingCard(int slot)
{
if(!CanEditStartingLineup || !Career.RemoveStarter(slot,BeforeKickoff))return;
ClubNotice="Fill all 11 positions before starting a match.";SaveCareer();
}
public void Instruction(int slot,int order)
{
if(slot<0 || slot>=11) return;
if(MediaLeagueActive){Match.Players[slot].Instruction=System.Math.Clamp(order,0,3);return;}
order=System.Math.Clamp(order,0,3);Career.Instructions[slot]=order;
Match.Players[slot].Instruction=order;SaveCareer();
}
public void SetClubTactic(int plan)
{
plan=System.Math.Clamp(plan,0,2);
if(MediaLeagueActive){Match.SetBroadcastPlan(Match.TeamTempo[0],plan*.5f);return;}
Career.CoachTactic=plan;Career.CoachMentality=plan*.5f;Match.SetBroadcastPlan(Career.CoachTempo,Career.CoachMentality);
Match.AddEvent($"{Match.HomeName} · tactics changed");SaveCareer();
}
public void SetBroadcastPlan(float tempo,float mentality,bool commit=false)
{
if(Match.Finished || Replay.Playing || MatchIntroActive)return;
Match.SetBroadcastPlan(tempo,mentality);
TickMatchTutorial();
if(!IsRulesPreview)
{
Career.CoachTempo=Match.TeamTempo[0];Career.CoachMentality=Match.TeamMentality[0];Career.CoachTactic=Match.Tactics[0];
if(commit)SaveCareer();
}
}
public void Pack(int packIndex=0)
{
RevealedPack=Career.OpenOwnedPack(packIndex);
RevealedCard=RevealedPack?.Players.FirstOrDefault();
ClubNotice=RevealedPack==null?"This pack is unavailable.":GamePreferences.Russian?$"Предметов добавлено: {RevealedPack.Items.Count}":$"Items added: {RevealedPack.Items.Count}";SaveCareer();
}
public bool BuyPacks(int packIndex,int quantity)
{
bool bought=Career.BuyPacks(packIndex,quantity);
ClubNotice=bought?"Packs added to your club.":"This purchase is unavailable.";SaveCareer();return bought;
}
public void ShowPackPreview(PackOpening opening)
{
RevealedPack=opening;RevealedCard=opening?.Players.FirstOrDefault();ClubOpen=true;ClubPage="Карточки";
}
public bool UseTrainingItem(int itemId,int playerId)
{
bool used=Career.UseTrainingCard(itemId,playerId);
ClubNotice=GamePreferences.Russian?(used?"Карточка улучшения применена.":"Выберите игрока и карточку улучшения между матчами."):
(used?"Upgrade card applied.":"Choose a player and an upgrade card between matches.");SaveCareer();return used;
}
public bool DiscardTrainingItem(int itemId)
{
bool discarded=Career.DiscardTrainingCard(itemId);
ClubNotice=GamePreferences.Russian
?discarded?"Карточка улучшения списана без возврата монет.":"Карточку можно списать только между матчами и кубками."
:discarded?"Upgrade card discarded. No coins refunded.":"Discard cards between matches and cup runs.";
if(discarded)SaveCareer();return discarded;
}
public bool BuyCosmetic(string kind,string id)
{
bool bought=kind=="kit"?Career.BuyKit(id):kind=="ball" && Career.BuyBall(id);
ClubNotice=bought?"New equipment added to your collection.":"This item is locked, owned, or too expensive.";SaveCareer();return bought;
}
public bool SaveCosmetics(string kitId,string ballId)
{
bool equipped=Career.EquipCosmetics(kitId,ballId);
ClubNotice=equipped?"Match look saved.":"Own both items before saving this look.";SaveCareer();return equipped;
}
public void SellCard(int id)
{
int value=Career.Sell(id);
ClubNotice=value>0?$"Player sold for {value} coins.":"Only reserve players can be sold between matches.";
SaveCareer();
}
public void RecycleDuplicateCard(int id)
{
bool exchanged=Career.RecycleDuplicate(id)!=null;
ClubNotice=GamePreferences.Russian?(exchanged?"Дубль обменян на карточку улучшения.":"Можно обменять только запасную копию между матчами."):
(exchanged?"Duplicate exchanged for an upgrade card.":"Only reserve duplicates can be exchanged between matches.");
SaveCareer();
}
public void ExpandReserve()
{
int cost=Career.ExpansionCost;
ClubNotice=Career.ExpandReserve()?$"Bench expanded to {Career.ReserveCapacity} places.":Career.ReserveCapacity>=ClubCareer.MaxReserveLimit?"Bench is already at maximum capacity.":$"You need {cost} coins and no active competitive match.";
SaveCareer();
}
public void NewSeason() {if(!RequireRelegationResolved())return;if(Career.ClaimableMissions>0){ClubPage="Missions";ClubNotice="Claim rewards before starting the next season.";return;}if(Career.NextSeason()) {ClubNotice=Career.LastResult;SaveCareer();if(Career.HasRelegationChoice)OpenRelegationChoice();}}
public void ShowRulesPreview(string kind)
{
if(MediaLeagueActive)return;
EndRulesPreview();previewOriginal=Match;previewDirector=Director;previewCamera=Broadcast;previewWasPaused=Paused;previewClubOpen=ClubOpen;previewSelection=SelectedPlayer;
PreviewCameraWidth=0;
SaveSubstitutionsBeforePreview();
Replay.Reset();replayAfterGoal=0;
Match=kind.StartsWith("manual-")?MatchSimulation.InteractiveRestartPreview(kind switch {"manual-penalty"=>RestartKind.Penalty,"manual-corner"=>RestartKind.Corner,"manual-indirect"=>RestartKind.Indirect,_=>RestartKind.FreeKick}):MatchSimulation.RulesPreview(kind);Director=new();Director.Update(Match,.02f);Broadcast=new();
Paused=true;ClubOpen=false;SelectedPlayer=Match.ShownPlayer>=0?Match.ShownPlayer:9;
}
public void RunFootballLab(string scenario,int attackerRating=-1,int defenderRating=-1,bool nextSeed=false)
{
if(MediaLeagueActive)return;
if(!FootballLabScenarios.Contains(scenario))return;
if(attackerRating>0)FootballLabAttackerRating=System.Math.Clamp(attackerRating,50,99);
if(defenderRating>0)FootballLabDefenderRating=System.Math.Clamp(defenderRating,50,99);
if(nextSeed)FootballLabSeed=MatchSimulation.CreateSeed(FootballLabSeed);
ShowRulesPreview("football-ai");
FootballLabActive=true;FootballLabScenario=scenario;
Match=MatchSimulation.Laboratory(scenario,FootballLabSeed,FootballLabAttackerRating,FootballLabDefenderRating);
FootballLabStartedAt=Match.Seconds;FootballLabTrialComplete=false;
Director=new MatchDirector {Automatic=false,ManualSpeed=1f};
Director.Update(Match,0);
PreviewCameraWidth=scenario switch
{
"match"=>0,"corner" or "free-kick"=>51,
"cross" or "pass" or "first-touch"=>38,
"penalty" or "one-on-one" or "shot"=>30,
"sprint"=>36,_=>22
};
SelectedPlayer=9;Paused=false;ClubOpen=false;
Replay.Reset();Replay.Capture(Match);
observedGoals=0;replayAfterGoal=0;accumulator=0;
SkipMatchIntro();
}
public void RestartFootballLab(bool changeSeed=false)
{
if(FootballLabActive)RunFootballLab(FootballLabScenario,-1,-1,changeSeed);
}
public void CloseFootballLab()
{
if(FootballLabActive)EndRulesPreview();
}
// An isolated exhibition uses the real club XI and the strongest distinct
// catalogue identities for each position. Leaving the match restores the
// career simulation without recording a result or changing the saved club.
public IReadOnlyList<PlayerCard> StartAllStarsExhibition()
{
if(MediaLeagueActive)return null;
if(Career==null || !Career.HasCompleteLineup)return null;
ShowRulesPreview("football-ai");
Match=new MatchSimulation(MatchSimulation.CreateSeed(previewOriginal.Seed));
Career.Apply(Match,1);
var shape=Formation.All[0];
var used=new HashSet<string>();
var lineup=new List<PlayerCard>();
for(int slot=0;slot<11;slot++)
{
var role=shape.Roles[slot];
var card=PlayerCatalog.CardVariants
.Where(c=>c.Position==role && !used.Contains(c.CatalogId))
.OrderByDescending(c=>c.Overall)
.ThenByDescending(c=>c.Edition==CardRatings.Legendary)
.ThenBy(c=>c.Name)
.First();
used.Add(card.CatalogId);lineup.Add(card);
var player=Match.Players[11+slot];
player.Home=shape.Homes[slot];
player.Position=Match.World(player.Home,1);
player.Velocity=new Point();
Career.ApplyCard(player,card,slot,shape);
}
Match.AwayName="WORLD ALL-STARS";
Match.TeamCohesion[1]=.93f;
ResetMatchSubstitutions();
Replay.Reset();Replay.Capture(Match);
observedGoals=0;replayAfterGoal=0;accumulator=0;
Director.Update(Match,0);
SelectedPlayer=9;PreviewCameraWidth=0;
SkipMatchIntro();ClubOpen=false;Paused=false;
return lineup;
}
public void EndRulesPreview()
{
if(MediaLeagueActive && !mediaExitAllowed)return;
if(previewOriginal==null) return;
FootballLabActive=false;
FootballLabTrialComplete=false;
PreviewManualSetPieces=false;PreviewCameraWidth=0;
Match=previewOriginal;Director=previewDirector;Broadcast=previewCamera;Paused=previewWasPaused;ClubOpen=previewClubOpen;SelectedPlayer=previewSelection;previewOriginal=null;
RestoreSubstitutionsAfterPreview();
Replay.Reset();replayAfterGoal=0;observedGoals=Match.Goals[0]+Match.Goals[1];
RestoreMatchTutorialSession();
}
public bool IsRulesPreview=>previewOriginal!=null;
}