MatchTutorial.cs

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.

Rough Code
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++;
    }
}