MatchDirector.cs

Match director for a football match simulation. Chooses playback speed and a Russian caption based on match state: slow motion during shots, faster tempo during buildup, fixed speed during incidents, shootouts and the final whistle. Only changes presentation timing, not simulation steps.

using System;

namespace Socer;

// Changes wall-clock presentation only. Football always advances in identical 1/60 s steps.
public sealed class MatchDirector
{
    public bool Automatic=true;
    public float ManualSpeed=1;
    public float Rate=1;
    // Automatic direction uses the livelier baseline; manual labels are exact wall-clock speeds.
    public const float BaseTempo=1.25f;
    public const float MaximumRate=1.5f;
    public float PlaybackRate => Automatic?Math.Min(MaximumRate,Rate*BaseTempo):Math.Clamp(ManualSpeed,.5f,MaximumRate);
    public float TickCost => (1f/60f)/PlaybackRate;
    public string Caption="ОБЗОР МАТЧА";
    float dangerHold;
    int seenShot, seenContact;
    float shotRealAge, contactRelease;
    public void Update(MatchSimulation match,float realDelta)
    {
        float target;
        Rate=Math.Min(Rate,MaximumRate);
        if(!Automatic) { Rate=Math.Clamp(ManualSpeed,.5f,MaximumRate); Caption="РУЧНАЯ СКОРОСТЬ"; return; }
        if(match.MedicalActive){Rate=1/BaseTempo;Caption="МЕДИЦИНСКАЯ ПОМОЩЬ";return;}
        if(match.ShootoutActive){Rate=1/BaseTempo;Caption="СЕРИЯ ПЕНАЛЬТИ";return;}
        if(seenShot!=match.ShotSequence) {seenShot=match.ShotSequence;shotRealAge=0;contactRelease=0;}
        if(match.ShotContactSequence>0 && seenContact!=match.ShotContactSequence)
        {seenContact=match.ShotContactSequence;contactRelease=.7f;}
        if(match.Finished) {Rate=1;Caption="ФИНАЛЬНЫЙ СВИСТОК";return;}
        if(match.WindupPlayer>=0 || match.RestartRemaining>0 || match.IncidentRemaining>0)
        {
            contactRelease=0;Rate=1;dangerHold=1.5f;
            Caption=match.IncidentRemaining>0?"СУДЬЯ ОСТАНОВИЛ ИГРУ":match.RestartRemaining>0?"СТАНДАРТ":"ПОДГОТОВКА УДАРА";
            return;
        }
        bool slowFlight=match.ShotInFlight && match.ShotContactSequence!=match.ShotSequence && match.ShotCueRemaining>0;
        if(contactRelease>0)
        {
            contactRelease=Math.Max(0,contactRelease-realDelta);
            target=1.5f;Caption=$"{match.ShotContactKind} · ВОЗВРАТ ТЕМПА";
        }
        else if(slowFlight)
        {
            shotRealAge+=realDelta;
            float easeDuration=Math.Clamp((match.ShotFlightAge+match.ShotCueRemaining-.35f)*.5f,.06f,.32f);
            float blend=Math.Clamp(shotRealAge/easeDuration,0,1);blend=blend*blend*(3-2*blend);
            float progress=match.ShotFlightAge/Math.Max(.01f,match.ShotFlightAge+match.ShotCueRemaining);
            target=1-blend*(.76f+.1f*progress);
            dangerHold=1.6f;Caption="ПОЛЁТ МЯЧА · СЛОУ-МО";
        }
        else if(match.GoalHoldRemaining>0) {target=1;Caption="ГОЛ!";}
        else
        {
            if(match.Danger>.18f || match.WindupPlayer>=0 || match.DuelDefender>=0 || match.PickupDelay>0) dangerHold=1.5f;
            else dangerHold=Math.Max(0,dangerHold-realDelta);
            bool danger=dangerHold>0;
            target=danger?1:MaximumRate;
            Caption=danger?"ОПАСНАЯ АТАКА":"ПОСТРОЕНИЕ АТАКИ";
        }
        // Ease into the shot, then accelerate through the first physical contact.
        if(slowFlight && contactRelease<=0) Rate=target;
        else Rate+=Math.Clamp(target-Rate,-realDelta*4,realDelta*(contactRelease>0?6:2.5f));
        if(!slowFlight && contactRelease<=0) Rate=Math.Max(1,Rate);
    }
}