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