FieldCallInput.cs

Mouse input surface for a 3D football pitch panel. Handles player hover picking, click selection, drag orders, goal clicking, right-button camera panning, command wheel opening and cursor markers, then calls into MatchController for the resulting orders.

Rough Code
using Sandbox;
using Sandbox.UI;
using Socer;
using System;
using System.Collections.Generic;

public enum FieldPingKind { Select, Move, Mark, Attack, Zone, Run, Press, Pass, Clear }
public readonly record struct FieldPing(int Serial,FieldPingKind Kind,Point Point,int PlayerId,float StartedAt);

// One surface owns selection, tactical targets and drags; captures end on release/cancel.
public sealed class FieldCallInput : Panel
{
    public MatchController Game {get;set;}
    public FootballWorldPanel World {get;set;}
    public Panel MatchConsole {get;set;}
    public int DragPlayer {get;private set;}=-1;
    public int HoverPlayer {get;private set;}=-1;
    public Point CursorField {get;private set;}
    public Vector2 CursorPixel {get;private set;}
    public bool CursorVisible {get;private set;}
    public bool HoverGoal {get;private set;}
    public bool AttackAvailable=>Game?.Match.FieldAttackFailure()=="";
    public string CursorActionHint=>GamePreferences.Russian?"АТАКА · ОБОСТРИТЬ":"ATTACK · CREATE A CHANCE";
    public IReadOnlyList<FieldPing> Pings=>Game?.FieldPings??Array.Empty<FieldPing>();
    public Point Destination {get;private set;}
    public bool Dragging {get;private set;}
    public string Notice {get;private set;}="";
    public float NoticeUntil {get;private set;}
    public bool Enabled=>Game!=null && World!=null && !Game.ClubOpen && !Game.StrategyLabOpen &&
        !Game.CommandWheelOpen && !Game.Replay.Playing && !Game.MatchIntroActive && !Game.Match.Finished && !Game.Match.ManualRestartWaiting;
    Vector2 down;
    bool pressed,rightPressed,rightMoved;
    int goalClicks;
    float lastGoalClick=-10;
    Vector2 rightDown,rightLast;
    Vector2 lastClickPixel;
    float lastClickAt=-10;
    int lastClickedPlayer=-1;
    Vector2 previewPointer;
    float previewPointerUntil;
    MatchSimulation gestureMatch;
    MatchSimulation cameraPanMatch;
    Vector2 Fraction(Vector2 pixel)=>new(pixel.x/Math.Max(1,Box.Rect.Width),pixel.y/Math.Max(1,Box.Rect.Height));
    bool Inside(Vector2 p)=>p.x>=0 && p.y>=0 && p.x<=Box.Rect.Width && p.y<=Box.Rect.Height;
    Vector2 Pixels(Vector2 p)=>new(p.x*Box.Rect.Width,p.y*Box.Rect.Height);
    bool UnderBroadcastHud(Vector2 p)=>MatchConsole?.HasHovered==true || p.y<50 || (p.x<330 && p.y<85) ||
        (Game?.ManualCameraActive==true && p.x>Box.Rect.Width-300 && p.y<85);
    public Vector2 PlayerPixel(int id)=>Pixels(World.Project(Game.Match.Players[id].Position,1));
    public Vector2 GoalPixel()=>Pixels(World.Project(Game.Match.HomeAttackGoal,1.1f));
    float PlayerDistance(Footballer p,Vector2 at)
    {
        var feet=Pixels(World.Project(p.Position,.15f));var head=Pixels(World.Project(p.Position,1.7f));
        var segment=head-feet;float length2=segment.x*segment.x+segment.y*segment.y;
        var offset=at-feet;float t=length2<1?0:(offset.x*segment.x+offset.y*segment.y)/length2;
        if(t>1.12f || (t<-.2f && offset.Length>25))return float.MaxValue;
        return (at-feet-segment*Math.Clamp(t,0,1)).Length;
    }
    int PickPlayer(Vector2 at)
    {
        int best=-1;float distance=24;
        foreach(var p in Game.Match.ActivePlayers)
        {
            float d=PlayerDistance(p,at);
            if(d<distance){distance=d;best=p.Id;}
        }
        return best;
    }
    int PickPlayerForClick(Vector2 at)
    {
        var candidates=new List<(int Id,float Distance)>();
        foreach(var p in Game.Match.ActivePlayers)
        {float distance=PlayerDistance(p,at);if(distance<24)candidates.Add((p.Id,distance));}
        if(candidates.Count==0)return -1;
        candidates.Sort((a,b)=>a.Distance.CompareTo(b.Distance));
        int index=0;
        if(Time.Now-lastClickAt<1.1f && (at-lastClickPixel).Length<20)
        {
            int previous=candidates.FindIndex(p=>p.Id==lastClickedPlayer);
            if(previous>=0)index=(previous+1)%candidates.Count;
        }
        lastClickAt=Time.Now;lastClickPixel=at;lastClickedPlayer=candidates[index].Id;
        return lastClickedPlayer;
    }
    bool AtGoal(Vector2 pixel)
    {
        var g=Game.Match.HomeAttackGoal;
        var points=new[]{Pixels(World.Project(new Point(g.X,30.34f),0)),
            Pixels(World.Project(new Point(g.X,37.66f),0)),
            Pixels(World.Project(new Point(g.X,30.34f),2.44f)),
            Pixels(World.Project(new Point(g.X,37.66f),2.44f))};
        float minX=float.MaxValue,maxX=float.MinValue,minY=float.MaxValue,maxY=float.MinValue;
        foreach(var p in points){minX=Math.Min(minX,p.x);maxX=Math.Max(maxX,p.x);minY=Math.Min(minY,p.y);maxY=Math.Max(maxY,p.y);}
        return pixel.x>=minX-16 && pixel.x<=maxX+16 && pixel.y>=minY-12 && pixel.y<=maxY+12;
    }
    void Say(string text){Notice=text;NoticeUntil=Time.Now+2.7f;}
    Point CommandPoint(Vector2 pixel)
    {
        var point=World.PickField(Fraction(pixel));
        return (Pixels(World.Project(point))-pixel).Length<=6?point:new Point(float.NaN,float.NaN);
    }
    void MoveCursor(Vector2 pixel,bool visible)
    {
        CursorVisible=false;
        HoverGoal=false;
        SetClass("goal-hover",false);
        SetClass("off-pitch",false);
        if(!Enabled || !visible || !Inside(pixel))return;
        CursorPixel=pixel;
        HoverGoal=Game.AwaitingOrder==DirectOrderKind.None && !UnderBroadcastHud(pixel) && AtGoal(pixel);
        SetClass("goal-hover",HoverGoal);
        CursorField=World.PickField(Fraction(pixel));
        var projected=Pixels(World.Project(CursorField));
        // Beyond the touchlines the ray has no field position. HUD panels can
        // also hide the turf marker. Keep a crosshair at the pointer there.
        CursorVisible=(projected-pixel).Length<=6 && !UnderBroadcastHud(pixel);
        SetClass("off-pitch",!CursorVisible);
    }
    // Editor diagnostics can hold a pointer over the isolated preview without moving the user's mouse.
    public void PreviewPointer(Vector2 pixel)
    {
        if(Game?.IsRulesPreview!=true)return;
        previewPointer=pixel;previewPointerUntil=Time.Now+3;
        MoveCursor(pixel,true);HoverAt(pixel,Time.Now);
    }
    public void CancelGesture()
    {Game?.SetFieldDragSlow(false);pressed=false;rightPressed=false;rightMoved=false;cameraPanMatch=null;Dragging=false;DragPlayer=-1;HoverPlayer=-1;HoverGoal=false;SetClass("goal-hover",false);if(HasMouseCapture)SetMouseCapture(false);}
    public void BeginCameraPan(Vector2 pixel)
    {
        if(!Enabled || !Inside(pixel))return;
        CancelGesture();cameraPanMatch=Game.Match;
        rightDown=rightLast=pixel;rightPressed=true;rightMoved=false;
        MoveCursor(pixel,true);
    }
    public void MoveCameraPan(Vector2 pixel)
    {
        if(!rightPressed || World==null || Game==null)return;
        if(!Enabled || cameraPanMatch!=Game.Match){CancelGesture();return;}
        var delta=pixel-rightLast;
        if(delta.Length<.001f)return;
        var from=rightMoved?rightLast:rightDown;
        if(!rightMoved && (pixel-rightDown).Length>=4)rightMoved=true;
        if(rightMoved)
        {
            // Absolute pointer movement drags the picked piece of turf with
            // the cursor. Relative mouse capture would pin and warp it back.
            World.PanCamera(Fraction(from),Fraction(pixel));
        }
        rightLast=pixel;
        MoveCursor(pixel,Inside(pixel));
    }
    public void EndCameraPan(Vector2 pixel)
    {
        if(!rightPressed)return;
        MoveCameraPan(pixel);
        if(!rightPressed)return;
        bool open=!rightMoved;
        rightPressed=false;rightMoved=false;cameraPanMatch=null;
        if(HasMouseCapture)SetMouseCapture(false);
        MoveCursor(pixel,Inside(pixel));
        if(open && Inside(pixel) && !UnderBroadcastHud(pixel) && !UnderBroadcastHud(rightDown))
        {
            bool goal=AtGoal(rightDown);
            var point=goal?Game.Match.HomeAttackGoal:CommandPoint(rightDown);
            if(float.IsFinite(point.X) && float.IsFinite(point.Y))
                Game?.OpenCommandWheel(point,goal?-1:PickPlayer(rightDown),Fraction(rightDown),goal);
        }
    }
    public void BeginGesture(Vector2 pixel)
    {
        if(!Enabled || !Inside(pixel) || UnderBroadcastHud(pixel))return;
        gestureMatch=Game.Match;down=pixel;pressed=true;Dragging=false;
        MoveCursor(pixel,true);
        DragPlayer=Game.AwaitingOrder is DirectOrderKind.GuardZone or DirectOrderKind.AttackRun?-1:PickPlayer(pixel);
        HoverPlayer=-1;
        Game.SetFieldDragSlow(DragPlayer>=0 && DragPlayer<11 && !Game.Match.Players[DragPlayer].Keeper);
    }
    public void MoveGesture(Vector2 pixel)
    {
        if(!Enabled || gestureMatch!=Game.Match){CancelGesture();return;}
        MoveCursor(pixel,true);
        if(!pressed || DragPlayer<0 || DragPlayer>=11 || Game.Match.Players[DragPlayer].Keeper)return;
        if((pixel-down).Length>=8)Dragging=true;
        if(Dragging)Destination=World.PickField(Fraction(pixel));
    }
    public void EndGesture(Vector2 pixel)
    {
        if(!pressed)return;
        MoveGesture(pixel);
        if(pressed && Enabled && Inside(pixel) && !UnderBroadcastHud(pixel))
        {
            if(Dragging)
            {
                Game.SelectPlayer(DragPlayer,false);
                int enemy=PickPlayer(pixel);
                if(enemy>=11)
                {
                    Game.CallMarkTarget(enemy,DragPlayer);
                    Say(Game.CommandNotice);
                }
                else
                {
                    Game.CallMoveTarget(DragPlayer,CommandPoint(pixel));
                    Say(Game.CommandNotice);
                }
            }
            else if((pixel-down).Length<8)
            {
                if(Game.AwaitingOrder is DirectOrderKind.GuardZone or DirectOrderKind.AttackRun)
                {
                    var where=CommandPoint(pixel);
                    Game.AssignFieldOrder(where);
                }
                else if(Game.AwaitingOrder is DirectOrderKind.MarkPlayer or DirectOrderKind.PressPlayer)
                {
                    Game.CallMarkTarget(PickPlayerForClick(pixel));
                    Say(Game.CommandNotice);
                }
                else if(Game.AwaitingOrder==DirectOrderKind.None && AtGoal(pixel) && AtGoal(down))
                {
                    goalClicks=Time.Now-lastGoalClick<1?goalClicks+1:1;lastGoalClick=Time.Now;
                    bool force=goalClicks>=3;
                    Game.CallAttackTarget(force);
                    Say(Game.CommandNotice);
                }
                else
                {
                    int clicked=DragPlayer>=0?PickPlayerForClick(pixel):-1;
                    if(clicked>=11){Game.CallMarkTarget(clicked);Say(Game.CommandNotice);}
                    else if(clicked>=0)Game.SelectPlayer(clicked);
                }
            }
        }
        CancelGesture();
    }
    protected override void OnMouseDown(MousePanelEvent e)
    {
        base.OnMouseDown(e);
        if(e.MouseButton==MouseButtons.Right)
        {
            BeginCameraPan(MousePosition);
            // This panel handles the drag. Parent drag-scrolling must not
            // synthesize an early mouse-up after its own movement threshold.
            if(rightPressed)e.StopPropagation();
            return;
        }
        if(e.MouseButton==MouseButtons.Left){if(HasMouseCapture)SetMouseCapture(false);BeginGesture(MousePosition);}
    }
    protected override void OnMouseUp(MousePanelEvent e)
    {base.OnMouseUp(e);if(e.MouseButton==MouseButtons.Left)EndGesture(MousePosition);else if(e.MouseButton==MouseButtons.Right)EndCameraPan(MousePosition);}
    public override void OnMouseWheel(Vector2 value)
    {
        base.OnMouseWheel(value);
        if(Enabled)Game.ZoomBroadcastCamera(value.y);
    }
    public override void Tick()
    {
        base.Tick();
        if(World!=null)World.CallInput=this;
        if(!Enabled || !IsVisible || !Application.IsFocused){CancelGesture();CursorVisible=false;return;}
        if(rightPressed)
        {
            MoveCameraPan(MousePosition);
            if(rightMoved)Game.PanBroadcastCamera(new Point());
            MoveCursor(MousePosition,Inside(MousePosition));return;
        }
        if(pressed){MoveGesture(MousePosition);return;}
        if(Game.IsRulesPreview && Time.Now<previewPointerUntil)
        {MoveCursor(previewPointer,true);HoverAt(previewPointer,Time.Now);return;}
        // UI labels can briefly take hover ownership while the pointer is still
        // inside the field. Its actual position is the reliable visibility test.
        bool overField=Inside(MousePosition);
        MoveCursor(MousePosition,overField);
        HoverAt(overField?MousePosition:new Vector2(-1,-1),Time.Now);
    }
    public void HoverAt(Vector2 pixel,float now)
    {
        if(!Enabled || pressed)return;
        bool pointTarget=Game.AwaitingOrder is DirectOrderKind.GuardZone or DirectOrderKind.AttackRun;
        int hover=!HoverGoal && !pointTarget && Inside(pixel) && !UnderBroadcastHud(pixel)?PickPlayer(pixel):-1;
        HoverPlayer=hover;
    }
    public override void OnDeleted()
    {
        CancelGesture();
        if(World?.CallInput==this)World.CallInput=null;
        base.OnDeleted();
    }
}