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;
}
}