BroadcastCamera.cs

A broadcast camera controller for a soccer match simulation. Turns match state (shot sequence, corners, incidents, medical stoppages, danger) into a target pitch center and a view width, then damps pan and zoom toward it so the rendered camera never snaps between frames.

using System;

namespace Socer;

// Pure presentation: metres are projected with one uniform scale, never stretched to fill a panel.
public sealed class BroadcastCamera
{
    public bool Enabled=true;
    public Point Center=new(52.5f,34);
    public float Zoom=1;
    public float ViewWidth=113, ViewHeight=76;
    bool attackingFrame, closeFrame;
    MatchSimulation trackedMatch;
    Point filteredBall,filteredLead,panVelocity;
    float heldWidth,zoomVelocity,tighterAge;
    Point shotOrigin,shotGoal,shotFocus;
    float shotHold,shotWidth;
    int followedShotSequence=-1;
    public bool ShotFraming {get;private set;}
    public Point PanVelocity=>panVelocity;
    public void ResumeFollow()
    {
        panVelocity=default;filteredLead=default;zoomVelocity=0;
        filteredBall=Center;tighterAge=0;
    }
    public void FollowPreview(MatchSimulation match,Point target,float width,float dt,float aspect)
    {
        dt=Math.Clamp(dt,0,.05f);aspect=Math.Clamp(aspect,.8f,3.5f);
        target=new Point(Math.Clamp(target.X,-1,106),Math.Clamp(target.Y,-1,69));
        if(trackedMatch!=match)
        {
            trackedMatch=match;Center=target;panVelocity=default;
        }
        else MoveCenter(target,dt,false);
        // Drill framing stays fixed; only its focus follows the action. It used
        // to copy the raw 60 Hz ball position straight into the rendered camera.
        ViewWidth=width;ViewHeight=width/aspect;Zoom=Math.Max(113,76*aspect)/width;
    }
    public void Update(MatchSimulation m,float dt,float aspect)
    {
        if(m.ShootoutActive){FollowPreview(m,new Point(98,34),Math.Max(31,aspect*20),dt,aspect);return;}
        dt=Math.Clamp(dt,0,.05f);
        aspect=Math.Clamp(aspect,.8f,3.5f);
        if(trackedMatch!=m)
        {
            trackedMatch=m;filteredBall=Center;filteredLead=panVelocity=default;
            heldWidth=ViewWidth;zoomVelocity=tighterAge=0;
            attackingFrame=closeFrame=false;
            ShotFraming=false;shotHold=0;followedShotSequence=-1;
        }
        bool medical=Enabled && m.MedicalActive;
        if(medical){shotHold=0;ShotFraming=false;}
        bool shotAction=Enabled && !medical && !m.Finished && (m.WindupPlayer>=0 || m.ShotInFlight ||
            m.ShotMissFlightRemaining>0 || m.GoalHoldRemaining>0);
        if(shotAction)
        {
            if(!ShotFraming || followedShotSequence!=m.ShotSequence)
            {
                shotOrigin=m.WindupPlayer>=0?m.Players[m.WindupPlayer].Position:m.Ball;
                int team=m.WindupPlayer>=0?m.Players[m.WindupPlayer].Team:m.LastTouch;
                shotGoal=m.World(new Point(105,34),team);
                shotWidth=Math.Max(42,Math.Max(Math.Abs(shotGoal.X-shotOrigin.X)*1.1f+16,aspect*25));
                followedShotSequence=m.ShotSequence;
                // An attack can come from a wider frame. Start the tighter
                // target now; the damped zoom still limits the actual motion.
                heldWidth=Math.Min(heldWidth,shotWidth);
            }
            shotHold=.85f;
        }
        else shotHold=Math.Max(0,shotHold-dt);
        ShotFraming=Enabled && !m.Finished && shotHold>0;
        bool corner=m.RestartRemaining>0 && m.SetPiece==RestartKind.Corner;
        bool incident=medical || m.IncidentRemaining>0 || (m.RestartRemaining>0 && m.RefereeSignalUntil>m.Seconds);
        // Live tackles must not switch the camera scale or focus on every challenge.
        // Reserve the closer framing for a sustained, stopped-play scuffle.
        bool local=Enabled && !m.Finished && !m.ShotInFlight && m.WindupPlayer<0 &&
            (medical || m.IncidentRemaining>0) && !ShotFraming;
        bool wide=!Enabled || (m.RestartRemaining>0 && !corner && !incident && !ShotFraming) || m.Finished;
        float baseWidth=Math.Max(113,76*aspect);
        float goalDistance=Math.Min(m.Ball.X,105-m.Ball.X);
        // Hysteresis keeps the framing from alternating at the danger thresholds.
        attackingFrame=!wide && (attackingFrame?m.Danger>.24f:m.Danger>.36f);
        closeFrame=!wide && goalDistance<(closeFrame?26:22) && Math.Abs(m.Ball.Y-34)<(closeFrame?23:20) &&
            (closeFrame?m.Danger>.38f:m.Danger>.55f);
        float framedWidth=closeFrame?Math.Max(38,aspect*22):m.WindupPlayer>=0?58:attackingFrame?70:94;
        if(corner) framedWidth=Math.Max(78,aspect*44);
        else if(incident) framedWidth=Math.Max(46,aspect*26);
        if(local) framedWidth=Math.Max(34,aspect*19);
        if(medical)framedWidth=Math.Max(m.MedicalStage==MedicalTreatmentStage.Approach?70:46,aspect*26);
        // Give a flying ball room before it reaches the edge of a tight shot frame.
        if(ShotFraming)framedWidth=shotWidth;
        else if(m.BallSpeed>18 && !local)framedWidth=Math.Max(framedWidth,Math.Max(90,aspect*48));
        float requestedWidth=wide?baseWidth:framedWidth;
        // Keep the wide view through consecutive touches/deflections. Only
        // tighten after the situation has remained calm for a real-time second.
        if(requestedWidth>heldWidth+2){heldWidth=requestedWidth;tighterAge=0;}
        else if(requestedWidth<heldWidth-2)
        {
            tighterAge+=dt;
            if(tighterAge>1.1f)heldWidth=requestedWidth;
        }
        else tighterAge=0;
        float desiredZoom=wide?1:Math.Clamp(baseWidth/Math.Max(20,heldWidth),1,local?5:4);
        // A damped zoom has continuous velocity when the desired scale reverses.
        float logZoom=MathF.Log(Math.Max(1,Zoom));
        float zoomAcceleration=4*(MathF.Log(desiredZoom)-logZoom)-4*zoomVelocity;
        zoomVelocity+=Math.Clamp(zoomAcceleration,-.65f,.65f)*dt;
        zoomVelocity=Math.Clamp(zoomVelocity,-.22f,.15f);
        Zoom=Math.Clamp(MathF.Exp(logZoom+zoomVelocity*dt),1,5);
        if(Zoom==1 && zoomVelocity<0)zoomVelocity=0;
        ViewWidth=baseWidth/Zoom; ViewHeight=ViewWidth/aspect;
        var ahead=m.Owner>=0?m.Players[m.Owner].Velocity:m.BallVelocity;
        var lead=ahead*(m.Owner>=0?.3f:.12f);
        if(lead.Length>3) lead=lead.Unit*3;
        filteredLead+=(lead-filteredLead)*(1-MathF.Exp(-dt*2));
        // Do not chase a ball into the stands or react directly to its teleport
        // onto the next restart spot. Camera state is entirely presentation-only.
        var liveBall=new Point(Math.Clamp(m.Ball.X,-1,106),Math.Clamp(m.Ball.Y,-1,69));
        filteredBall+=(liveBall-filteredBall)*(1-MathF.Exp(-dt*3));
        Point target=wide?new Point(52.5f,34):filteredBall+filteredLead;
        if(corner && !wide) target=(m.Ball+new Point(m.Ball.X>52.5f?100:5,34))*.5f;
        if(closeFrame) target=target*.9f+new Point(filteredBall.X>52.5f?102:3,34)*.1f;
        if(local) target=medical?m.MedicalCarryingPosition:m.IncidentFocus;
        if(ShotFraming)
        {
            // Frame the shot corridor, not the complete pitch. Keep the last
            // result in view briefly when a save/miss teleports the restart ball.
            if(shotAction)shotFocus=(shotOrigin+shotGoal)*.35f+filteredBall*.3f;
            target=shotFocus;
        }
        float halfW=ViewWidth*.5f,halfH=ViewHeight*.5f;
        if(ShotFraming)
        {
            target.X=Math.Clamp(target.X,1,104);target.Y=Math.Clamp(target.Y,6,62);
        }
        else
        {
            target.X=ViewWidth>=113?52.5f:Math.Clamp(target.X,halfW-4,109-halfW);
            target.Y=ViewHeight>=76?34:Math.Clamp(target.Y,halfH-4,72-halfH);
        }
        // Protect the ball through the target, not by snapping the rendered camera
        // to each discrete 60 Hz ball position after the smooth movement below.
        float xMargin=Math.Min(10,halfW*.28f),yMargin=Math.Min(10,halfH*.32f);
        float safeX=Math.Max(1,halfW-xMargin),safeY=Math.Max(1,halfH-yMargin);
        // Screen protection uses the bounded ball; an out-of-play ricochet must
        // not override the pitch bounds and pull the whole stadium off screen.
        if(!wide)
        {
            var protectedBall=medical?m.MedicalCarryingPosition:ShotFraming && !shotAction?shotFocus:liveBall;
            target.X=Math.Clamp(target.X,protectedBall.X-safeX,protectedBall.X+safeX);
            target.Y=Math.Clamp(target.Y,protectedBall.Y-safeY,protectedBall.Y+safeY);
        }
        MoveCenter(target,dt,wide);
    }
    void MoveCenter(Point target,float dt,bool wide)
    {
        var offset=target-Center;
        // Panning follows real time, including slow motion. Both acceleration
        // and speed are bounded, including after a target reversal.
        float deadZone=wide?0:Math.Min(1.5f,ViewWidth*.02f);
        offset=offset.Length>deadZone?offset.Unit*(offset.Length-deadZone):default;
        float response=2.4f;
        var acceleration=offset*(response*response)-panVelocity*(2*response);
        if(acceleration.Length>32)acceleration=acceleration.Unit*32;
        panVelocity+=acceleration*dt;
        if(panVelocity.Length>36)panVelocity=panVelocity.Unit*36;
        Center+=panVelocity*dt;
    }
}