Partial MatchController that adds an interactive match tutorial. Walks a list of Socer.MatchTutorialLesson steps, builds practice scenarios, pauses or observes play, watches field pings/set-piece shots/substitutions, and saves and restores the surrounding club and match state.
using Sandbox;
using Socer;
using System;
using System.Collections.Generic;
using System.Linq;
public sealed partial class MatchController
{
public bool CanStartMatchTutorial=>Career!=null && ClubOpen && !PreparingMatch && !MatchModeSelection &&
!QuickBroadcastOpen && !QuickMatchRunning && !IsRulesPreview && !StrategyLabOpen && !MediaLeagueActive &&
!SubstitutionOpen && !Match.MedicalActive && !RelegationChoiceOpen && !Career.HasRelegationChoice;
public bool MatchTutorialActive {get;private set;}
public int MatchTutorialStep {get;private set;}=-1;
public int MatchTutorialStepCount=>Socer.MatchTutorialLesson.All.Count;
public MatchTutorialLesson MatchTutorialLesson=>MatchTutorialActive?Socer.MatchTutorialLesson.All[MatchTutorialStep]:null;
public bool MatchTutorialSatisfied {get;private set;}
public bool MatchTutorialObserving {get;private set;}
public bool MatchTutorialSetPieceLesson=>MatchTutorialActive && MatchTutorialLesson?.Key is "penalty" or "free-kick";
public bool MatchTutorialSubstitutionLesson=>MatchTutorialActive && MatchTutorialLesson?.Key=="substitution";
public bool MatchTutorialInjuryLesson=>MatchTutorialActive && MatchTutorialLesson?.Key=="injury";
// Real substitution code reads a disposable roster during the two squad
// lessons. Career itself is never replaced or modified by the training.
public ClubCareer ActiveMatchRoster=>MatchTutorialActive && tutorialPracticeRoster!=null?tutorialPracticeRoster:Career;
ClubCareer tutorialPracticeRoster;
readonly HashSet<string> tutorialCompletedLessons=new();
bool tutorialSetPieceAimed,tutorialManualShotSubmitted;
int tutorialRosterOutgoingId;
public bool IsMatchTutorialLessonCompleted(int index)=>index>=0 && index<MatchTutorialStepCount &&
tutorialCompletedLessons.Contains(Socer.MatchTutorialLesson.All[index].Key);
string tutorialFeedbackRu="",tutorialFeedbackEn="";
public string MatchTutorialFeedback=>TutorialText(tutorialFeedbackRu,tutorialFeedbackEn);
void SetTutorialFeedback(string ru="",string en="")
{tutorialFeedbackRu=ru;tutorialFeedbackEn=en;}
public int MatchTutorialActor=>MatchTutorialLesson?.Key is "mark" or "press" or "zone" or "clear"?4:9;
public int MatchTutorialTargetPlayer=>MatchTutorialLesson?.Key switch
{"select" or "pass" or "stamina" or "links" or "substitution" or "injury"=>9,"mark" or "press"=>20,"clear"=>4,_=>-1};
public Point? MatchTutorialTarget=>MatchTutorialLesson?.Key switch
{"run"=>new Point(78,22),"zone"=>new Point(61,38),"attack" or "shoot"=>Match.HomeAttackGoal,_=>null};
// Presentation only: the renderer and HUD guide the same player while an instruction is pending.
public bool MatchTutorialNeedsAction=>MatchTutorialActive && MatchTutorialLesson?.IsTask==true &&
!MatchTutorialSatisfied && !MatchTutorialObserving && !GameSettingsOpen;
public int MatchTutorialFocusPlayer=>!MatchTutorialNeedsAction?-1:MatchTutorialLesson.Key switch
{"select" or "run" or "pass"=>9,"zone" or "clear"=>4,
"mark" or "press"=>OrderedPlayer==4?20:4,_=>-1};
public bool CanAdvanceMatchTutorial=>MatchTutorialActive && MatchTutorialSatisfied && !MatchTutorialObserving;
float tutorialObservationRemaining,tutorialObservedSeconds,tutorialObservedGoalAge;
string tutorialReturnPage,tutorialReturnNotice;
int tutorialReturnOrdered;
DirectOrderKind tutorialReturnOrder;
MatchSimulation tutorialReturnSelectionMatch,tutorialReturnManualMatch;
float tutorialReturnManualUntil;
bool tutorialReturnSaveBlocked,tutorialReturnManualSetPieces,tutorialReturnPausedBeforeSubstitution;
MatchSimulation tutorialReturnInjuryManagedMatch;
int tutorialReturnShownInjurySlot;
bool tutorialReturnPausedBeforeInjuryChoice;
public void StartMatchTutorial()
{
if(!CanStartMatchTutorial)return;
Socer.MatchTutorialLesson.Reload();
if(GameSettingsOpen)ToggleGameSettings();
if(TutorialStep>=0)FinishTutorial();
tutorialReturnPage=ClubPage;tutorialReturnNotice=ClubNotice;
tutorialReturnOrdered=OrderedPlayer;tutorialReturnOrder=AwaitingOrder;
tutorialReturnSelectionMatch=manualSelectionMatch;tutorialReturnManualMatch=manualCameraMatch;
tutorialReturnManualUntil=manualCameraUntil;tutorialReturnSaveBlocked=saveBlocked;
tutorialReturnManualSetPieces=ManualSetPieces;tutorialReturnPausedBeforeSubstitution=pausedBeforeSubstitution;
tutorialReturnInjuryManagedMatch=injuryManagedMatch;tutorialReturnShownInjurySlot=shownInjurySlot;
tutorialReturnPausedBeforeInjuryChoice=pausedBeforeInjuryChoice;
ShowRulesPreview("football-ai");
MatchTutorialActive=true;saveBlocked=true;tutorialCompletedLessons.Clear();MatchTutorialStep=0;LoadMatchTutorialStep();
}
void LoadMatchTutorialStep()
{
try{LoadMatchTutorialStepCore();}
catch{if(IsRulesPreview)EndRulesPreview();throw;}
}
void LoadMatchTutorialStepCore()
{
CloseSubstitutions();CloseCommandWheel();CancelDirectOrder();
string key=MatchTutorialLesson.Key;
tutorialPracticeRoster=key is "substitution" or "injury"?MatchSimulation.CreateTutorialRoster():null;
Match=MatchSimulation.TutorialScenario(key,tutorialPracticeRoster);
ResetMatchSubstitutions();tutorialRosterOutgoingId=MatchCardId(9);
tutorialSetPieceAimed=tutorialManualShotSubmitted=false;
Director=new MatchDirector{Automatic=false,ManualSpeed=1};Director.Update(Match,0);
Broadcast=new BroadcastCamera();PreviewCameraWidth=MatchTutorialLesson.Key=="freeplay"?0:52;
SelectedPlayer=OrderedPlayer=MatchTutorialActor;
if(MatchTutorialLesson.Key=="select")SelectedPlayer=OrderedPlayer=6;
manualSelectionMatch=Match;manualCameraMatch=Match;manualCameraUntil=0;FieldDragSlow=false;
fieldPings.Clear();pingMatch=Match;FootballLabActive=false;PreviewManualSetPieces=MatchTutorialSetPieceLesson;
ManualSetPieces=MatchTutorialSetPieceLesson || tutorialReturnManualSetPieces;ClubOpen=false;
Replay.Reset();Replay.Capture(Match);observedGoals=0;replayAfterGoal=0;accumulator=0;SkipMatchIntro();
SetTutorialFeedback();
MatchTutorialSatisfied=!MatchTutorialLesson.IsTask;MatchTutorialObserving=key is "stamina" or "links";
ResetTutorialObservation(6);Paused=key is not ("freeplay" or "injury") && !MatchTutorialObserving;
Broadcast.FollowPreview(Match,Match.Ball,52,1,ViewAspect);Revision++;
}
public void AdvanceMatchTutorial()
{
if(!CanAdvanceMatchTutorial)return;
tutorialCompletedLessons.Add(MatchTutorialLesson.Key);
if(MatchTutorialStep==MatchTutorialStepCount-1){EndMatchTutorial(true);return;}
MatchTutorialStep++;LoadMatchTutorialStep();
}
public void JumpToMatchTutorialChapter(string chapterKey)
{
if(!MatchTutorialActive || GameSettingsOpen)return;
var chapter=Socer.MatchTutorialLesson.Chapters.FirstOrDefault(c=>c.Key==chapterKey);
if(chapter==null)return;
int step=Socer.MatchTutorialLesson.All.ToList().FindIndex(l=>l.Key==chapter.FirstLesson);
if(step<0)return;
if(CanAdvanceMatchTutorial)tutorialCompletedLessons.Add(MatchTutorialLesson.Key);
MatchTutorialStep=step;LoadMatchTutorialStep();
}
public void RetryMatchTutorialStep()
{if(MatchTutorialActive && !GameSettingsOpen)LoadMatchTutorialStep();}
public void EndMatchTutorial(bool completed=false)
{
if(!MatchTutorialActive)return;
if(GameSettingsOpen)ToggleGameSettings();EndRulesPreview();
if(completed && Socer.MatchTutorialLesson.All.Where(l=>l.IsTask).All(l=>tutorialCompletedLessons.Contains(l.Key)))
GamePreferences.MarkMatchTutorialSeen();
}
void RestoreMatchTutorialSession()
{
if(!MatchTutorialActive)return;
MatchTutorialActive=false;MatchTutorialStep=-1;MatchTutorialSatisfied=false;MatchTutorialObserving=false;
SetTutorialFeedback();OrderedPlayer=tutorialReturnOrdered;AwaitingOrder=tutorialReturnOrder;
manualSelectionMatch=tutorialReturnSelectionMatch;manualCameraMatch=tutorialReturnManualMatch;
manualCameraUntil=tutorialReturnManualUntil;FieldDragSlow=false;
ClubPage=tutorialReturnPage;ClubNotice=tutorialReturnNotice;saveBlocked=tutorialReturnSaveBlocked;
ManualSetPieces=tutorialReturnManualSetPieces;pausedBeforeSubstitution=tutorialReturnPausedBeforeSubstitution;
injuryManagedMatch=tutorialReturnInjuryManagedMatch;shownInjurySlot=tutorialReturnShownInjurySlot;
pausedBeforeInjuryChoice=tutorialReturnPausedBeforeInjuryChoice;tutorialPracticeRoster=null;
CloseCommandWheel();fieldPings.Clear();pingMatch=Match;accumulator=0;Revision++;
}
void ObserveMatchTutorialPing(FieldPingKind kind,Point point,int playerId)
{
if(!MatchTutorialActive || MatchTutorialSatisfied || GameSettingsOpen)return;
string key=MatchTutorialLesson.Key;var actor=Match.Players[MatchTutorialActor];
bool orderLive=actor.DirectOrderUntil>Match.Seconds;
bool accepted=key switch
{
"select"=>kind==FieldPingKind.Select && playerId==9 && SelectedPlayer==9 && OrderedPlayer==9,
"run"=>kind==FieldPingKind.Run && OrderedPlayer==9 && orderLive && actor.DirectOrder==DirectOrderKind.AttackRun && (actor.DirectPoint-new Point(78,22)).Length<4,
"pass"=>kind==FieldPingKind.Pass && playerId==9 && Match.FieldPasser==6 && Match.FieldPassReceiver==9 && Match.FieldPassUntil>Match.Seconds,
"attack"=>kind==FieldPingKind.Attack && Match.FieldAttackUntil>Match.Seconds && Match.FieldShootUntil<=Match.Seconds,
"shoot"=>kind==FieldPingKind.Attack && Match.FieldShootUntil>Match.Seconds,
"mark"=>kind==FieldPingKind.Mark && playerId==20 && orderLive && actor.DirectOrder==DirectOrderKind.MarkPlayer && actor.DirectTargetId==20,
"press"=>kind==FieldPingKind.Press && playerId==20 && orderLive && actor.DirectOrder==DirectOrderKind.PressPlayer && actor.DirectTargetId==20,
"zone"=>kind==FieldPingKind.Zone && OrderedPlayer==4 && orderLive && actor.DirectOrder==DirectOrderKind.GuardZone && (actor.DirectPoint-new Point(61,38)).Length<4,
"clear"=>kind==FieldPingKind.Clear && playerId==4 && actor.DirectOrder==DirectOrderKind.None,
_=>false
};
if(!accepted && kind==FieldPingKind.Select && playerId==MatchTutorialActor && key!="select")
{SetTutorialFeedback("Игрок выбран. Теперь выполните команду из подсказки.","Player selected. Now follow the command in the hint.");return;}
if(!accepted)
{SetTutorialFeedback("Выполните действие на отмеченном игроке или точке. Можно повторить эпизод.","Use the highlighted player or point for this instruction. You can repeat the moment.");return;}
AcceptMatchTutorialAction(key=="select"?0:key is "attack" or "shoot"?5:4);
}
static string TutorialText(string ru,string en)=>GamePreferences.Russian?ru:en;
public void ObserveMatchTutorialSetPieceAim(MatchSimulation source)
{
if(!MatchTutorialSetPieceLesson || !ReferenceEquals(source,Match) || GameSettingsOpen || !Match.ManualRestartWaiting)return;
tutorialSetPieceAimed=true;
SetTutorialFeedback("Цель выбрана. Нажмите «СИЛА», затем «УДАР», чтобы пробить.","Aim selected. Press POWER, then SHOOT to take the kick.");Revision++;
}
// This is the normal control's submission path. Training advances only
// after a genuine manual submission; an automatic release cannot pass it.
public bool SubmitManualSetPieceShot(Point target,float height,float power)
{
if(MatchTutorialSetPieceLesson && (!tutorialSetPieceAimed || GameSettingsOpen))
{
SetTutorialFeedback("Сначала нажмите на свою точку внутри ворот.","First click your own point inside the goal.");Revision++;return false;
}
if(!Match.SubmitSetPiece(SetPiecePlay.Shot,target,height,power))return false;
if(MatchTutorialSetPieceLesson)
{tutorialManualShotSubmitted=true;AcceptMatchTutorialAction(4);}
return true;
}
void AcceptMatchTutorialAction(float watchSeconds)
{
MatchTutorialSatisfied=true;MatchTutorialObserving=watchSeconds>0;ResetTutorialObservation(watchSeconds);
SetTutorialFeedback("Команда принята. Посмотрите, как меняются действия игроков.","Order accepted. Watch the players respond.");
Paused=!MatchTutorialObserving;accumulator=0;Revision++;
}
void ResetTutorialObservation(float duration)
{tutorialObservationRemaining=duration;tutorialObservedSeconds=Match.Seconds;tutorialObservedGoalAge=Match.GoalCelebrationElapsed;}
public void TickMatchTutorial()
{
if(!MatchTutorialActive || GameSettingsOpen)return;
if(!MatchTutorialSatisfied && MatchTutorialSetPieceLesson && !Match.ManualRestartWaiting && !tutorialManualShotSubmitted)
{
Paused=true;SetTutorialFeedback("Стандарт разыгран автоматически. Нажмите «Повторить» и выполните ручной удар.",
"The kick was released automatically. Repeat this lesson and take a manual kick.");
}
if(!MatchTutorialSatisfied && (MatchTutorialSubstitutionLesson || MatchTutorialInjuryLesson) &&
SubstitutionsUsed>0 && MatchCardId(9)!=tutorialRosterOutgoingId && !Match.MedicalActive)
{
CloseSubstitutions();AcceptMatchTutorialAction(3);
SetTutorialFeedback("Замена выполнена. Новый игрок выходит на поле; учебный состав не меняет ваш клуб.",
"Substitution made. The replacement joins play; this practice roster leaves your club unchanged.");
}
if(MatchTutorialLesson.Key=="plan" && !MatchTutorialSatisfied && Match.TeamTempo[0]>=.75f && Match.TeamMentality[0]>=.75f)AcceptMatchTutorialAction(4);
if(!MatchTutorialObserving)return;
// A scored penalty can stop the football clock while celebration still
// moves. Observe that real animation time without waiting for kickoff;
// repeated HUD/controller calls and paused Settings add no extra time.
float elapsed=Math.Max(Math.Max(0,Match.Seconds-tutorialObservedSeconds),Math.Max(0,Match.GoalCelebrationElapsed-tutorialObservedGoalAge));
tutorialObservationRemaining-=elapsed;tutorialObservedSeconds=Match.Seconds;tutorialObservedGoalAge=Match.GoalCelebrationElapsed;
if(tutorialObservationRemaining>.001f && !Match.Finished)return;
MatchTutorialObserving=false;Paused=true;accumulator=0;
if(MatchTutorialSetPieceLesson && Match.Shots[0]==0)
{
MatchTutorialSatisfied=false;SetTutorialFeedback("Удар не состоялся. Повторите эпизод и выполните прицел и удар ещё раз.",
"The shot was not released. Repeat the moment and try your aim and kick again.");Revision++;return;
}
switch(MatchTutorialLesson.Key)
{
case "pass":
if(Match.CommandPasses>0)SetTutorialFeedback("Передача выполнена. Можно повторить эпизод или продолжить.","Pass played. Repeat the moment or move on.");
else SetTutorialFeedback("Приказ на пас принят; ситуация может помешать передаче. Повторите или продолжите.","Pass requested; the situation can prevent delivery. Repeat or move on.");
break;
case "shoot":case "attack":
if(Match.Shots[0]>0)SetTutorialFeedback("Попытка удара состоялась. Исход зависит от игрока и защиты.","A shot was attempted. Its outcome depends on the player and defence.");
else SetTutorialFeedback("Атакующий приказ выполнен. Игроки сохраняют футбольные решения.","Attacking instruction followed. Players still make football decisions.");
break;
case "penalty":case "free-kick":
SetTutorialFeedback("Ручной удар выполнен. Гол не обязателен: важны прицел и выбор силы. Можно повторить или продолжить.",
"Manual kick taken. Scoring is optional: practice aim and power. Repeat or continue.");break;
case "substitution":case "injury":
SetTutorialFeedback("Игрок заменён через обычный экран матча. Лимит замен и запрет возврата действуют и в карьере.",
"Player replaced through the normal match screen. Career matches use the same limit and no-return rule.");break;
default:SetTutorialFeedback("Эпизод завершён. Можно повторить или перейти дальше.","Moment complete. Repeat it or move on.");break;
}
Revision++;
}
}