SetPieceControl.razor

Set piece aiming panel for penalties and free kicks. Projects a ballistic arc of dots toward the goal, draws the reticle, and provides the charging power bar, power/shoot button, auto-release button, hotkey aiming and English/Russian help text. Click aiming solves a 2D Newton step against the match camera projection.

Rough Code
@using Sandbox
@using Sandbox.UI
@using Socer
@using System
@inherits Panel

<root class="localized-text">
    <SetPieceAimInput Game="@Game" World="@World" Pick="@((Action<float,float>)Pick)" />
    @for(int i=1;i<19;i++)
    {var t=i/19f;var point=M.SetPieceSpot+(Target-M.SetPieceSpot)*t;float duration=(Target-M.SetPieceSpot).Length/(16+Power*20);float z=.12f*(1-t)+Height*t+4.905f*duration*duration*t*(1-t);
        <div class="flight-dot" style="@Position(point,z)"></div>}
    <div class="goal-reticle" style="@Position(Target,Height)"><label>+</label></div>
    <div class="standard-bottom">
        <div class="standard-name"><label>@(M.SetPiece==RestartKind.Penalty?Text("PENALTY","ПЕНАЛЬТИ"):Text("FREE KICK","ШТРАФНОЙ"))</label><label>@($"{M.Players[M.SetPieceTaker].Profile.Name} · {(Target-M.SetPieceSpot).Length:0} "+Text("m","м"))</label></div>
        <label class="standard-help">@Text("Choose a point in goal. First click POWER to start charging, then SHOOT to lock power and kick. Right-drag pans the camera.","Выберите точку в воротах. Нажмите «СИЛА» для набора силы, затем «УДАР», чтобы зафиксировать её и пробить. Перетаскивание ПКМ двигает камеру.")</label>
        <div class="power-track"><div class="power-fill" style="@FormattableString.Invariant($"width:{Power*100:0}%;")"></div><div class="power-risk"></div></div>
        <div class="standard-actions"><button class="execute-setpiece" onclick=@Shoot>@(Charging?Text("SHOOT →","УДАР →"):Text("POWER →","СИЛА →"))</button><label>@($"{Power*100:0}%")</label><button class="auto-setpiece" onclick=@(()=>M.AutoReleaseSetPiece())>@Text("Let player decide","Игрок решит сам")</button></div>
        <label class="standard-warning">@(M.ManualRestartWarned?Text("Referee: hurry up! Repeated delay risks a card. ","Судья: не затягивайте! Повторная задержка грозит карточкой. "):Text("Aim is a guide; accuracy depends on the taker. ","Прицел — ориентир; точность зависит от исполнителя. "))@(GamePreferences.Russian?$"Авторозыгрыш через {Math.Max(0,20-(int)M.ManualRestartWait)} с":$"Auto in {Math.Max(0,20-(int)M.ManualRestartWait)} s")</label>
    </div>
</root>
@code {
    public MatchController Game {get;set;}
    public FootballWorldPanel World {get;set;}
    MatchSimulation M=>Game.Match;
    MatchSimulation controlMatch;
    int sequence=-1;
    float GoalY=34,Height=1.2f,chargeAt,chargePausedAt;
    bool Charging,chargePaused;
    static string Text(string en,string ru)=>GamePreferences.Russian?ru:en;
    Point Target=>new Point(M.Direction(0)>0?105:0,GoalY);
    float Power=>Charging?.15f+.85f*(1-MathF.Abs((((chargePaused?chargePausedAt:Time.Now)-chargeAt)/1.15f%2)-1)):.55f;
    public override void Tick()
    {
        base.Tick();if(Game==null)return;
        if(!ReferenceEquals(controlMatch,M) || sequence!=M.RestartSequence)
        {controlMatch=M;sequence=M.RestartSequence;Charging=chargePaused=false;GoalY=34;Height=1.2f;}
        // Tutorial kick input intentionally works while its match is paused.
        // Only opening Settings freezes the power bar's presentation clock.
        if(Charging && Game.GameSettingsOpen && !chargePaused){chargePaused=true;chargePausedAt=Time.Now;}
        else if(chargePaused && !Game.GameSettingsOpen){chargeAt+=Time.Now-chargePausedAt;chargePaused=false;}
    }
    void Pick(float x,float y)
    {
        if(World==null)return;
        // Intersect the click with the actual goal plane, using the same 3D camera as the match.
        for(int i=0;i<9;i++)
        {
            var at=World.Project(Target,Height);var dy=World.Project(Target+new Point(0,.1f),Height)-at;
            var dz=World.Project(Target,Height+.1f)-at;var error=new Vector2(x,y)-at;
            float det=dy.x*dz.y-dy.y*dz.x;if(Math.Abs(det)<.0000001f)break;
            GoalY=Math.Clamp(GoalY+Math.Clamp((error.x*dz.y-error.y*dz.x)/det*.1f,-2,2),29.6f,38.4f);
            Height=Math.Clamp(Height+Math.Clamp((dy.x*error.y-dy.y*error.x)/det*.1f,-1,1),0,2.9f);
        }
        Game.ObserveMatchTutorialSetPieceAim(M);
    }
    void Shoot(){if(Game.GameSettingsOpen)return;if(!Charging){Charging=true;chargePaused=false;chargeAt=Time.Now;return;}if(Game.SubmitManualSetPieceShot(Target,Height,Power))Charging=chargePaused=false;}
    public bool HandleHotkey(string id)
    {
        if(Game==null || Game.GameSettingsOpen || !M.ManualRestartWaiting)return false;
        float horizontal=World!=null && World.Project(Target+new Point(0,.25f),Height).x<World.Project(Target,Height).x?-1:1;
        switch(id)
        {
            case "standard.confirm":Shoot();return true;
            case "standard.automatic":M.AutoReleaseSetPiece();return true;
            case "standard.left":GoalY=Math.Clamp(GoalY-horizontal*.25f,29.6f,38.4f);break;
            case "standard.right":GoalY=Math.Clamp(GoalY+horizontal*.25f,29.6f,38.4f);break;
            case "standard.up":Height=Math.Clamp(Height+.15f,0,2.9f);break;
            case "standard.down":Height=Math.Clamp(Height-.15f,0,2.9f);break;
            default:return false;
        }
        Game.ObserveMatchTutorialSetPieceAim(M);return true;
    }
    string Position(Point p,float z){var q=World?.Project(p,z)??new Vector2(-10,-10);return FormattableString.Invariant($"left:{q.x*100:0.00}%;top:{q.y*100:0.00}%;");}
    protected override int BuildHash()=>HashCode.Combine(Game?.Revision,GamePreferences.Revision);
}