BroadcastView.razor

A Razor UI component that defines the look and behavior of the game's broadcast view. It renders the 3D game world, overlays tactical indicators, score bug, and various match status elements.

NetworkingFile AccessProcess ExecutionNative Interop
@using Sandbox
@using Sandbox.UI
@using Socer
@using System
@using System.Linq
@using System.Globalization
@inherits Panel

<root class="broadcast-view @(Game.MatchIntroActive?"ceremony-view":"")">
    <div class="camera-window" @ref="CameraWindow">
        <FootballWorldPanel @key="13" @ref="WorldView" Game="@Game" />
        @if(Game.MatchTutorialActive){<MatchTutorialHud Game="@Game" World="@WorldView" />}
        @if(Game.MediaLeagueActive){<MediaLeagueHud Game="@Game" />}
        @if(!Game.Replay.Playing && !Game.MatchIntroActive && !M.ManualRestartWaiting && !M.Finished && !M.ShootoutActive && !Game.StrategyLabOpen)
        {
            <FieldCallInput @ref="Calls" Game="@Game" World="@WorldView" class="field-calls-input" />
            @if(Game.CommandWheelOpen){<FieldCommandMenu Game="@Game" />}
            @foreach(var ping in Game.FieldPings)
            {
                var point=PingPoint(ping);
                @if(PingVisible(point))
                {
                    <div @key="ping.Serial" class="field-ping @(ping.Kind.ToString().ToLowerInvariant())" style="@Position(point,.05f)">
                        <div class="ping-aura"></div>
                        <div class="ping-head"><img class="ping-icon" src="@FieldCommandIcons.For(ping.Kind)" /></div>
                    </div>
                }
            }
            @if(Calls?.Enabled==true && Calls.HoverGoal)
            {
                <div class="field-action-cursor attack @(Calls.AttackAvailable?"ready":"unavailable") @(CursorCopyOnLeft?"copy-left":"")" style="@CursorPosition">
                    <img class="action-reticle" src="/ui-icons/field-cursor-attack.svg" />
                    <div class="action-cursor-copy localized-text"><label class="action-cursor-title">@Calls.CursorActionHint</label><label class="action-cursor-detail">@(GamePreferences.Russian?(Calls.AttackAvailable?"ЛКМ: обострить · 3 клика: удар":"АТАКА СЕЙЧАС НЕДОСТУПНА"):(Calls.AttackAvailable?"CLICK: ATTACK · 3 CLICKS: SHOOT":"ATTACK CURRENTLY UNAVAILABLE"))</label></div>
                </div>
            }
            else if(Calls?.Enabled==true && Calls.HoverPlayer>=11 && !Calls.Dragging && (Game.AwaitingOrder is DirectOrderKind.None or DirectOrderKind.MarkPlayer or DirectOrderKind.PressPlayer))
            {
                <div class="field-action-cursor mark @(CursorCopyOnLeft?"copy-left":"")" style="@CursorPosition">
                    <img class="action-reticle" src="/ui-icons/field-cursor-mark.svg" />
                    <div class="action-cursor-copy localized-text"><label class="action-cursor-title">@(GamePreferences.Russian?(Game.AwaitingOrder==DirectOrderKind.PressPlayer?"ПРЕССИНГ":"ОПЕКА"):(Game.AwaitingOrder==DirectOrderKind.PressPlayer?"PRESS":"MARK"))</label><label class="action-cursor-detail">@(GamePreferences.Russian?"ЛКМ: назначить команду":"CLICK TO GIVE THE ORDER")</label></div>
                </div>
            }
            @if(Calls?.HoverPlayer>=0 && Calls.HoverPlayer<11)
            {
                var target=M.Players[Calls.HoverPlayer];
                <div class="call-hover @(target.Team==0?"ally":"enemy")" style="@Position(target.Position,2.5f)">@HoverCaption(target)</div>
            }
            <div class="field-call-help localized-text">@(GamePreferences.Russian?"ЛКМ: свой — выбрать · соперник — опека · ворота — атака · перетащить: направить · ПКМ: команды / камера · колесо: масштаб":"Left click: ally — select · opponent — mark · goal — attack · drag: direct · right click / drag: commands / camera · wheel: zoom")</div>
            @if(Game.CommandNoticeUntil>Time.Now){<div class="field-call-notice localized-text">@Game.CommandNotice</div>}
            @if(Game.CommandNoticeUntil<=Time.Now && Calls!=null && Calls.NoticeUntil>Time.Now)
            {<div class="field-call-notice">@UiEnglish.Text(Calls.Notice,"Instruction received")</div>}
        }
        @if(M.ManualRestartWaiting){<SetPieceControl Game="@Game" World="@WorldView" />}
        @if(Game.AwaitingOrder is DirectOrderKind.GuardZone or DirectOrderKind.AttackRun)
        {
            <label class="field-target-prompt localized-text">@Game.OrderPrompt</label>
        }
        <div class="tv-scorebug localized-text">
            <div class="tv-score-row"><div class="tv-time"><label>@(M.ShootoutActive?(GamePreferences.Russian?"ПЕН.":"PEN"):Game.FootballLabActive?(M.Seconds-Game.FootballLabStartedAt).ToString("0.0",CultureInfo.InvariantCulture)+"s":M.BroadcastClock)</label></div><div class="tv-team home"><img class="scoreboard-crest" src="@ClubIdentity.Crest(M.HomeName)" /><label>@ClubIdentity.ShortName(M.HomeName)</label></div><label class="tv-score">@($"{M.Goals[0]} : {M.Goals[1]}")</label><div class="tv-team away"><img class="scoreboard-crest" src="@ClubIdentity.Crest(M.AwayName)" /><label>@ClubIdentity.ShortName(M.AwayName)</label></div></div>
            <div class="tv-status">@(M.ShootoutActive?(GamePreferences.Russian?"СЕРИЯ ПЕНАЛЬТИ":"PENALTY SHOOTOUT"):Game.FootballLabActive?Game.FootballLabTrialComplete?(GamePreferences.Russian?"ЭПИЗОД ЗАВЕРШЁН":"MOMENT COMPLETE"):(GamePreferences.Russian?"ТРЕНИРОВКА":"TRAINING GROUND"):Local(Game.Replay.Playing?"REPLAY":M.Finished?"FULL TIME":Game.Paused?"PAUSED":M.InAddedTime?$"ADDED +{M.AddedMinutes} MIN":M.SecondHalf?"SECOND HALF · LIVE":"FIRST HALF · LIVE"))</div>
        </div>
        @if(M.ShootoutActive){<PenaltyShootoutHud Match="@M" />}
        @if(!Game.MatchIntroActive && !Game.Replay.Playing && !M.Finished && !M.ShootoutActive && !Game.StrategyLabOpen && !M.ManualRestartWaiting)
        {<BroadcastMatchConsole @ref="MatchConsole" Game="@Game" />}
        @if(!Game.Paused && !M.Finished && !Game.Replay.Playing && (Game.FieldDragSlow || Math.Abs(Game.PlaybackRate-1f)>.04f))
        {
            <div class="tv-tempo localized-text @(Game.PlaybackRate<1 ? "slow" : "fast")">
                <label class="tempo-icon">@(Game.PlaybackRate<1 ? "◷" : "»")</label>
                <label class="tempo-value">@(Game.PlaybackRate.ToString("0.0",CultureInfo.InvariantCulture)+"×")</label>
                <label class="tempo-caption">@(Game.CommandWheelOpen || Game.AwaitingOrder!=DirectOrderKind.None?(GamePreferences.Russian?"КОМАНДЫ":"COMMANDS"):Local(Game.FieldDragSlow?"DIRECTING PLAYER":Game.PlaybackRate<1 ? "SLOW MOTION" : "FAST FORWARD"))</label>
            </div>
        }
        @if(Game.ManualCameraActive && !Game.Replay.Playing && !M.Finished)
        {
            <button class="camera-return localized-text" onclick=@(()=>Game.ReturnToBroadcastCamera())>
                <div class="camera-return-ring">
                    @for(int i=0;i<24;i++){int tick=i;<div class="camera-return-tick @(tick<Math.Ceiling((1-Game.ManualCameraProgress)*24)?"active":"")" style="@CameraTickStyle(tick)"></div>}
                    <label>↶</label>
                </div>
                <div class="camera-return-copy"><label>@Local("FREE CAMERA")</label><label>@(GamePreferences.Russian?$"АВТО ЧЕРЕЗ {Game.ManualCameraRemaining.ToString("0.0",CultureInfo.InvariantCulture)} с · НАЖМИТЕ ДЛЯ ВОЗВРАТА":$"AUTO IN {Game.ManualCameraRemaining.ToString("0.0",CultureInfo.InvariantCulture)}s · CLICK TO RETURN")</label></div>
            </button>
        }
        @if(M.GoalScorerId>=0 && (M.GoalHoldRemaining>0 || Game.Replay.Playing))
        {
            var scorer=M.Players[M.GoalScorerId];
            <div class="tv-goal"><label class="tv-goal-tag">GOAL</label><div class="tv-goal-info"><label class="tv-scorer">@scorer.Profile.Name</label><label class="tv-goal-club">@($"{(scorer.Team==0?M.HomeName:M.AwayName)} · No. {scorer.Number}")</label></div><label class="tv-goal-minute">@($"{M.GoalMinute}′")</label></div>
        }
        @if(Game.Replay.Playing)
        {
            <div class="replay-letterbox top"></div><div class="replay-letterbox bottom"></div>
            <div class="replay-title">GOAL REPLAY <label>@($"FOLLOW CAMERA #{Game.Replay.ScorerNumber}")</label></div>
            <button class="replay-skip" onclick=@(()=>Game.SkipGoalReplay())>Skip ›</button>
        }
        else if(!M.ManualRestartWaiting)
        {
            @foreach(var p in M.ActivePlayers)
            {
                @if(PlayerNumberVisible(p.Position))
                {
                    <div @key="p.Id" class="world-player @(p.Team==0?"ally":"enemy") @(p.Id==Game.OrderedPlayer?"order-source":"") @(Calls?.HoverPlayer==p.Id?(p.Team==0?"hover-ally":"hover-enemy"):"")" style="@Position(p.Position,.05f)">
                        <div class="number-badge"><label>@p.Number</label></div>
                        @if(p.Team==0 && (p.DirectOrder!=DirectOrderKind.None || p.Id==M.FieldPasser))
                        {<div class="order-marker"><img class="order-icon" src="@FieldCommandIcons.ForOrder(p.DirectOrder,p.Id==M.FieldPasser)" /></div>}
                        <div class="player-stamina @(p.Stamina<.3f?"low":p.Stamina<.6f?"tired":"")" style="@FormattableString.Invariant($"opacity:{Energy.Opacity[p.Id]:0.00};")"><div style="@FormattableString.Invariant($"width:{Energy.Fill[p.Id]*100:0.0}%;")"></div></div>
                        @if(p.Yellows>0){<div class="booking-marker"></div>}
                    </div>
                }
                @if(p.PassCallUntil>M.Seconds)
                {<div class="pass-call @(p.AngryPassCall?"angry":"")" style="@Position(p.Position,2.35f)">!</div>}
            }
            @if(ReviewSpeechVariant>=0)
            {
                <div class="speech-review-caption localized-text"><label>@ReviewSpeechTitle</label><label>@ReviewSpeechDescription</label></div>
                @for(int sample=0;sample<3;sample++)
                {
                    int n=sample;var actor=M.Players[new[]{9,6,14}[n]];
                    <div class="speech-review-anchor" style="@SpeechPosition(actor.Position)"><FieldSpeechView Variant="@ReviewSpeechVariant" Age="@(SpeechReviewAge-n*.35f)" Duration="@FieldSpeechPresentation.Duration(ReviewSpeechVariant)" Text="@SpeechReviewText(n)" /></div>
                }
            }
            @if(Game.ShowSpeech && ReviewSpeechVariant<0)
            {
                @if(!Game.StrategyLabOpen)
                {
                    @foreach(var coach in Game.TechnicalArea.Coaches.Where(c=>c.SpeechUntil>Game.TechnicalArea.Clock))
                    {
                        var screen=WorldView?.Project(coach.Position,2.15f)??new Vector2(-10,-10);
                        @if(screen.x>.04f && screen.x<.96f && screen.y>.08f && screen.y<.9f)
                        {<div class="world-speech coach-speech localized-text @(coach.Team==0?"home":"away")" style="@Position(coach.Position,2.15f)"><FieldSpeechView Variant="@FieldSpeechPresentation.ActiveVariant" Age="@(Game.TechnicalArea.Clock-coach.Started)" Duration="@(coach.SpeechUntil-coach.Started)" Text="@(GamePreferences.Russian?UiRussian.Translate(coach.Speech):coach.Speech)" /></div>}
                    }
                }
                @foreach(var p in M.Players.Where(p=>!p.SentOff && !p.InjuryRemovedFromField && p.SpeechUntil>M.InjuryAnimationSeconds(p) && PlayerNumberVisible(p.Position)).OrderByDescending(p=>p.InjurySeverity).Take(2))
                {
                    <div @key="(p.Id,p.SpeechSerial)" class="world-speech player-dialogue localized-text @p.SpeechTone.ToString().ToLowerInvariant()" style="@SpeechPosition(p.Position)">
                        <FieldSpeechView Variant="@FieldSpeechPresentation.ActiveVariant" Age="@(M.InjuryAnimationSeconds(p)-p.LastSpeechAt)" Duration="@(p.SpeechUntil-p.LastSpeechAt)" Text="@PlayerDialogue.Text(p,GamePreferences.Russian)" CleanText="@PlayerDialogue.CleanText(p,GamePreferences.Russian)" />
                    </div>
                }
            }
            @foreach(var fx in M.Effects.Where(e=>LegendaryVisuals.ShowCaption(e,M)))
            {<div class="world-effect @(fx.Kind is "skill" or "legendary"?"skill":"") @(fx.Kind=="legendary"?"legendary localized-text":"")" style="@Position(fx.Kind is "skill" or "legendary" && fx.Player>=0?M.Players[fx.Player].Position:fx.Position,(fx.Kind is "skill" or "legendary"?3.2f:2.5f)+fx.Age*.25f)">@(fx.Kind=="legendary" && fx.Player>=0?LegendaryAbilities.Get(M.Players[fx.Player].Profile)?.Name(GamePreferences.Russian):fx.Text)</div>}
            @if(M.RefereeSignalUntil>M.Seconds && !M.MedicalActive){<div class="world-speech" style="@Position(M.RefereeActor.Position,2.3f)">@M.RefereeSignal</div>}
            @if(!Game.ManualCameraActive){<label class="camera-mode">@(Game.Broadcast.Enabled?"CAMERA: BROADCAST":"CAMERA: FULL PITCH")</label>}

            @if(M.MedicalActive && !Game.InjuryChoiceOpen){<div class="medical-status localized-text"><label>@("✚ "+MedicalCaption)</label><label class="medical-patient">@(M.MedicalPatient.Profile.Name+" · "+(GamePreferences.Russian?"Время матча остановлено":"Match clock stopped"))</label></div>}
            @if(!M.MedicalActive && (M.RestartRemaining>0 || M.Finished))
            {<div class="announcement @(!M.Finished && M.SetPiece!=RestartKind.KickOff?"stoppage":"")"><label>@UiEnglish.Text(M.Status)</label></div>}
        }
    </div>
</root>
@code {
    public MatchController Game {get;set;}
    MatchSimulation M=>Game.Match;
    string MedicalCaption=>M.MedicalStage switch {
        MedicalTreatmentStage.Approach=>GamePreferences.Russian?"Медики выходят на поле":"Medical staff entering the pitch",
        MedicalTreatmentStage.Assess=>GamePreferences.Russian?"Осмотр игрока":"Assessing the player",
        MedicalTreatmentStage.Load=>GamePreferences.Russian?"Подготовка носилок":"Preparing the stretcher",
        MedicalTreatmentStage.CarryOff=>GamePreferences.Russian?"Игрок не сможет продолжить":"The player cannot continue",
        _=>GamePreferences.Russian?"Обязательная замена":"Compulsory substitution"};
    Panel CameraWindow {get;set;}
    FootballWorldPanel WorldView {get;set;}
    FieldCallInput Calls {get;set;}
    BroadcastMatchConsole MatchConsole {get;set;}
    public StaminaReadout Energy {get;}=new();
    public override void Tick()
    {
        base.Tick();
        if(Calls!=null)Calls.MatchConsole=MatchConsole;
        if(Game!=null && !Game.ClubOpen)Energy.Update(M,Time.Delta,Game.Paused);
    }
    static string Local(string source)=>GamePreferences.Russian?UiRussian.Translate(source):source;
    string SpeechPosition(Point point)
    {
        var screen=WorldView?.Project(point,2.25f)??new Vector2(-10,-10);
        // Leave room for the entire bubble at either touchline. ScaleToScreen
        // converts the CSS width to the physical panel size used by projection.
        float margin=CameraWindow==null ? .1f : Math.Clamp(130*CameraWindow.ScaleToScreen/Math.Max(1,CameraWindow.Box.Rect.Width),.04f,.35f);
        screen.x=Math.Clamp(screen.x,margin,1-margin);
        screen.y=Math.Clamp(screen.y,.14f,.94f);
        return FormattableString.Invariant($"left:{screen.x*100:0.###}%;top:{screen.y*100:0.###}%;");
    }
    bool CursorCopyOnLeft=>Calls!=null && Calls.CursorPixel.x>Calls.Box.Rect.Width-245*Calls.ScaleToScreen;
    // Input pixels use the physical panel rectangle; percentages keep the
    // hotspot aligned when s&box scales UI CSS for another display size.
    string CursorPosition=>Calls==null?"":FormattableString.Invariant($"left:{Calls.CursorPixel.x/Math.Max(1,Calls.Box.Rect.Width)*100:0.###}%;top:{Calls.CursorPixel.y/Math.Max(1,Calls.Box.Rect.Height)*100:0.###}%;");
    Point PingPoint(FieldPing ping)=>ping.PlayerId>=0 && ping.PlayerId<M.Players.Count && M.Players[ping.PlayerId].InPlay
        ?M.Players[ping.PlayerId].Position:ping.Point;
    bool PingVisible(Point point)
    {
        if(WorldView==null)return false;
        var q=WorldView.Project(point,.05f);
        return q.x>.01f && q.x<.99f && q.y>.02f && q.y<.98f;
    }
    string HoverCaption(Footballer target)
    {
        if(target.Team==0)return GamePreferences.Russian?$"#{target.Number} · ВЫБРАТЬ / ПЕРЕТАЩИТЬ":$"#{target.Number} · SELECT / DRAG";
        if(Game.AwaitingOrder==DirectOrderKind.PressPlayer)return GamePreferences.Russian?$"#{target.Number} · ЛКМ: ПРЕССИНГ":$"#{target.Number} · CLICK TO PRESS";
        return GamePreferences.Russian?$"#{target.Number} · ЛКМ: ОПЕКА":$"#{target.Number} · CLICK TO MARK";
    }
    bool PlayerNumberVisible(Point at)
    {
        if(WorldView==null)return false;
        var screen=WorldView.Project(at,.05f);
        return screen.x>.018f && screen.x<.982f && screen.y>.03f && screen.y<.94f;
    }
    string CameraTickStyle(int tick)=>FormattableString.Invariant($"left:{25+MathF.Sin(tick*MathF.Tau/24)*21:0.##}px;top:{25-MathF.Cos(tick*MathF.Tau/24)*21:0.##}px;transform:rotate({tick*15:0}deg);");
    string Position(Point p,float z=0)
    {
        if(CameraWindow!=null)Game.ViewAspect=Math.Max(.5f,CameraWindow.Box.Rect.Width/Math.Max(1,CameraWindow.Box.Rect.Height)*.64f);
        var q=WorldView?.Project(p,z)??new Vector2(-10,-10);
        return $"left: {(q.x*100).ToString("0.###",CultureInfo.InvariantCulture)}%; top: {(q.y*100).ToString("0.###",CultureInfo.InvariantCulture)}%;";
    }
    public int ReviewSpeechVariant=-1;
    public float ReviewSpeechStarted;
    public float? ReviewSpeechFrozenAge;
    public float SpeechReviewAge=>ReviewSpeechFrozenAge??Time.Now-ReviewSpeechStarted;
    string ReviewSpeechTitle=>new[]{"A · Чистый текст","B · Лёгкая подложка","C · Тонкая рамка"}[Math.Clamp(ReviewSpeechVariant,0,2)];
    string ReviewSpeechDescription=>new[]{"Без рамки · плавный подъём и затухание · 1,25 с","Без обводки · мягкое увеличение и затухание · 1,55 с","Рамка 1 px · короткое скольжение вверх · 1,4 с"}[Math.Clamp(ReviewSpeechVariant,0,2)];
    string SpeechReviewText(int n)=>GamePreferences.Russian?new[]{"Сюда, я открыт!","Хороший пас!","Держим линию!"}[n]:new[]{"Here, I'm open!","Great pass!","Hold the line!"}[n];
    protected override int BuildHash()=>Game?.ClubOpen==true?0:HashCode.Combine(Game?.Revision,GamePreferences.Revision,ReviewSpeechVariant,ReviewSpeechVariant>=0?(int)(Time.Now*60):0);
}