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.
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();
}
}