UI/HorizonHud.razor

The main roleplay HUD Razor panel. It renders the player's name, job and status rings, money, hotbar, weapon ammo, interaction prompts, vehicle and door cards, GPS, key hints, speedometer, menus, scoreboard, toasts, downed/revive overlays and screen effects. It reads replicated state and sends requests such as RequestJob and RequestBuy to the host.

@using Sandbox;
@using Sandbox.UI;
@namespace HorizonRP
@inherits PanelComponent

@*
	HUD d'Horizon RP. Du balisage HTML et une feuille SCSS (HorizonHud.razor.scss) :
	s&box n'exécute pas de JavaScript, la logique d'affichage est ici en C#.
	Le HUD ne décide de rien : il lit les valeurs synchronisées par l'hôte et
	envoie des demandes (RequestJob, RequestBuy…) que l'hôte vérifie.
*@

<root class="@(Me?.NeedsCharacter == true ? "creating" : "") @(PoliceShowroom.Framing ? "showroom" : "") @(Me?.Downed == true ? "is-downed" : "")">
	<div class="brand">
		<div class="logo"></div>
		<div class="brand-text">
			<div class="brand-name">HORIZON</div>
			<div class="brand-tag">ROLEPLAY</div>
		</div>
	</div>

	@if ( Me is not null && !Me.NeedsCharacter )
	{
		@* Ce qu'on regarde et qui réagit à E : un objet au sol, une arme lâchée, un véhicule, un PNJ. *@
		@if ( VehicleCard is { } card )
		{
			@* Contre une portière : la place, l'état du verrou, E à maintenir et sa barre. Dans une
			   rangée pleine largeur qui la centre (voir la barre des poches ci-dessous). *@
			<div class="center-row">
			<div class="vehicle-card">
				<div class="vc-title">@card.Title</div>
				<div class="vc-state @(card.Locked ? "locked" : "open")"><i>@(card.Locked ? "lock" : "lock_open")</i>@(card.Locked ? "Verrouillé" : "Déverrouillé")</div>
				@if ( card.Mode == "door" )
				{
					<div class="vc-hold"><div class="keycap">@KeyName( "use" )</div><div>Maintenir</div></div>
					<div class="vc-bar"><div class="vc-fill" style="width: @((card.Progress * 100).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture ))%"></div></div>
				}
				else if ( card.Mode == "trunk" )
				{
					<div class="vc-hold"><div class="keycap">@KeyName( "use" )</div><div>Maintenir pour ouvrir</div></div>
					<div class="vc-bar"><div class="vc-fill" style="width: @((card.Progress * 100).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture ))%"></div></div>
				}
				@if ( Me.IsStaff )
				{
					<div class="vc-hold"><div class="keycap">@KeyName( "VehicleAdmin" )</div><div>Gérer</div><div class="admin-tag">(Admin)</div></div>
				}
			</div>
			</div>
		}
		@* Une porte de la map, pour qui peut la verrouiller (le staff) : son état, E, et G à maintenir. *@
		else if ( LockableDoor is { } door )
		{
			<div class="center-row">
			<div class="vehicle-card">
				<div class="vc-title">Porte</div>
				<div class="vc-state @(door.Locked ? "locked" : "open")"><i>@(door.Locked ? "lock" : "lock_open")</i>@(door.Locked ? "Verrouillée" : "Déverrouillée")</div>
				<div class="vc-hold"><div class="keycap">@KeyName( "use" )</div><div>@(door.IsOpen ? "Fermer" : "Ouvrir")</div></div>
				<div class="vc-hold"><div class="keycap">@KeyName( "Search" )</div><div>@(door.Locked ? "Déverrouiller" : "Verrouiller")</div><div class="admin-tag">(Admin)</div></div>
				<div class="vc-bar"><div class="vc-fill" style="width: @((MapDoor.LockTarget == door ? MapDoor.LockProgress * 100 : 0).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture ))%"></div></div>
			</div>
			</div>
		}
		@* Ou un point du commissariat sans objet à viser : le garage de police (PoliceGarageMenu.Prompt). *@
		else if ( (Interaction ?? PoliceGarageMenu.Prompt) is { } tip )
		{
			<div class="center-row">
			<div class="interact">
				<div class="keycap">@KeyName( "use" )</div>
				<div class="interact-action">@tip.Description</div>
				<div class="interact-title">@tip.Title</div>
			</div>
			</div>
		}

		@* Les cinq poches, en bas au centre : quelques secondes après une touche 1 à 5 ou un coup de molette, puis fondu. *@
		@if ( HotbarVisible )
		{
			@* La rangée pleine largeur centre la barre : un translateX( -50% ) vaut 0 tant que
			   la barre n'a pas sa largeur, et elle naissait à gauche avant de glisser. *@
			<div class="hotbar">
			<div class="hotbar-bar">
				@* « Mains » : pas de touche, la molette y passe pour tout ranger. *@
				<div class="hb-slot hands @(SelectedPocket == HandsPosition ? "active" : "")">
					<i class="hb-icon">back_hand</i>
					<div class="hb-name">Mains</div>
				</div>
				@for ( var i = 0; i < InventoryRules.HotbarSlots; i++ )
				{
					var index = i;
					var slot = Me.PublishedInventory.FirstOrDefault( x => x.Slot == index );
					var active = index + 1 == SelectedPocket;
					<div class="hb-slot @(slot is null ? "empty" : "") @(active ? "active" : "")">
						<div class="keycap">@KeyName( InventoryRules.KeyOf( index ) )</div>
						@if ( slot?.Definition is { } item )
						{
							@if ( item.Picture is { } picture )
							{
								<div class="hb-pic" style="background-image: url( @picture )"></div>
							}
							else
							{
								<i class="hb-icon">@item.Icon</i>
							}
							<div class="hb-name">@item.Name</div>
							@if ( slot.Count > 1 )
							{
								<div class="hb-count">@($"×{(slot.Count)}")</div>
							}
						}
					</div>
				}
				@* Les billets : l'argent liquide, toujours là, comme les mains (Money/Cash.cs). *@
				<div class="hb-slot cash @(SelectedPocket == CashPosition ? "active" : "")">
					<div class="hb-pic" style="background-image: url( ui/billets.png )"></div>
					<div class="hb-name">Billets</div>
					<div class="hb-count">@Money( Me.Wallet )</div>
				</div>
			</div>
			</div>
		}

		@* L'arme sortie, en bas à droite, sur un fond sombre qui se fond vers la gauche, avec ses touches (masqué au volant : le compteur y est). *@
		@if ( Armed is { } armed && Car is null )
		{
			<div class="ammo">
				<div class="ammo-name">@armed.Name</div>
				<div class="ammo-count @(armed.Clip == 0 ? "empty" : "")">
					<b>@armed.Clip</b><span>@armed.Reserve</span>
				</div>
				<div class="ammo-bar"><div class="ammo-fill" style="width: @Percent( armed.ClipSize > 0 ? 100f * armed.Clip / armed.ClipSize : 0 )"></div></div>
				<div class="ammo-keys">
					<div class="keycap">@KeyName( "reload" )</div><div class="key-label">Recharger</div>
					<div class="keycap wide">CLIC D</div><div class="key-label">Viser</div>
					@if ( armed.Tactical is { } tactical )
					{
						<div class="keycap">@KeyName( "Flashlight" )</div><div class="key-label">@tactical</div>
					}
				</div>
			</div>
		}

		@* En bas à gauche, façon FiveM : le nom, le métier, puis des anneaux (vie, faim, voix). *@
		<div class="status">
			<div class="who">
				<div class="rp-name">@RpNameShown</div>
				<div class="job-name" style="color: @Me.Job.Color">@($"{(Me.Job.Name)}{(Me.IsStaff ? " · Staff" : "")}")</div>
			</div>
			<div class="rings">
				@foreach ( var ring in Rings )
				{
					<div class="ring @ring.Kind @(ring.Alert ? "alert" : "")">
						<div class="ring-arc" style="@RingFill( ring.Value, ring.Color )"></div>
						<div class="ring-core"><i>@ring.Icon</i></div>
					</div>
				}
			</div>
		</div>

		@* En haut à droite : l'argent, comme sur FiveM. *@
		<div class="purse">
			<div class="purse-cash"><span>$</span>@CashDigits</div>
			<div class="purse-bank"><i>account_balance</i>@Money( Me.Bank )</div>
			@* Un policier : la paie de son grade en service, rien hors service (Police/PoliceService.Service.cs). *@
			<div class="purse-pay">@($"Paie {(Money( PoliceService.SalaryOf( Me ) ))} · {(Countdown)}")</div>
		</div>

		@* La navigation des Plans du téléphone : la distance et la direction, en haut au centre. *@
		@if ( Gps.Target is not null )
		{
			<div class="gps-chip">
				<div class="gps-arrow" style="@GpsArrowStyle"><i>navigation</i></div>
				<div class="gps-text">
					<div class="gps-dist">@Gps.FormatDistance( Gps.RemainingMeters )</div>
					<div class="gps-name">@(Gps.TargetName ?? "Destination")</div>
				</div>
			</div>
		}

		@* Repliées par défaut : seule la touche qui les déroule reste à l'écran. *@
		<div class="keys @(KeysShown ? "open" : "")">
			<div class="key-row toggle">
				<div class="keycap">@KeyName( "KeyHints" )</div>
				<div class="key-label">@(KeysShown ? "Masquer les touches" : "Touches")</div>
				<i class="chevron">@(KeysShown ? "expand_less" : "expand_more")</i>
			</div>
			@if ( KeysShown )
			{
				<div class="key-list">
					@foreach ( var (key, label) in KeyHints )
					{
						<div class="key-row">
							@if ( Keybinds.ArrowIcon( key ) is { } arrow )
							{
								@* Poppins n'a pas les flèches : celles de la police d'icônes. *@
								<div class="keycap"><i>@arrow</i></div>
							}
							else
							{
								<div class="keycap @(key.Length > 2 ? "wide" : "")">@key</div>
							}
							@if ( label.EndsWith( AdminSuffix ) )
							{
								<div class="key-label">@label[..^AdminSuffix.Length]</div>
								<div class="admin-tag">(Admin)</div>
							}
							else
							{
								<div class="key-label">@label</div>
							}
						</div>
					}
				</div>
			}
		</div>

		@* Au volant (ou passager) : le compteur rond, en bas à droite, façon FiveM. Un arc épais
		   pour la vitesse, des graduations (zone rouge au bout), la vitesse sur trois chiffres
		   (les zéros de tête éteints), le rapport, la jauge d'essence, les témoins dans une
		   pastille en bas ; à gauche, l'état du moteur et de la carrosserie en pourcentages. *@
		@if ( Car is { } car )
		{
			<div class="speedo">
				<div class="speedo-stats">
			<div class="vehicle-radio @(!car.RadioEnabled ? "off" : "")">
				<div class="radio-main">
					<i>@(car.RadioEnabled ? "radio" : "volume_off")</i>
					<div class="radio-name">@car.RadioStation.Name</div>
					<div class="radio-state">@car.RadioStatus</div>
				</div>
				@if ( car.Driver == Me )
				{
					<div class="radio-keys">
						<div class="keycap">@KeyName( "RadioToggle" )</div><span>@(car.RadioEnabled ? "Couper" : "Allumer")</span>
						<div class="keycap">@KeyName( "RadioPrevious" )</div><span>Préc.</span>
						<div class="keycap">@KeyName( "RadioNext" )</div><span>Suiv.</span>
					</div>
				}
			</div>

					@* Gyrophares (police ou secours) : le boîtier de commande façon Luxart Vehicle Control —
					   l'état (éteint, visuel seul, visuel + sonore), la tonalité, le deux-tons, et les
					   touches pour le conducteur qui les commande (Vehicle.Police.cs). *@
					@if ( car.HasBeacons )
					{
						var lights = car.LightsShown;
						var tone = lights ? car.ToneShown : 0;
						<div class="lvc @(car.EmsColors ? "ems" : "")">
							<div class="lvc-head">
								<div class="lvc-lamp rouge @(lights && car.BeaconLeftPhase ? "on" : "")"></div>
								<div class="lvc-title">@LvcTitle( car )</div>
								<div class="lvc-lamp @(car.EmsColors ? "blanc" : "bleu") @(lights && !car.BeaconLeftPhase ? "on" : "")"></div>
							</div>
							<div class="lvc-mode @(!lights ? "" : tone == 0 ? "visuel" : "sonore")">@LvcMode( lights, tone )</div>
							<div class="lvc-tones">
								@for ( var t = 1; t < 4; t++ )
								{
									<div class="lvc-tone @(tone == t && !car.PoliceHornActive ? "on" : "")">@Vehicle.SirenName( t )</div>
								}
								<div class="lvc-tone horn @(car.PoliceHornActive ? "on" : "")">Deux-tons</div>
							</div>
							@if ( car.Driver == Me && car.CanUseBeacons( Me ) )
							{
								<div class="lvc-keys">
									<div class="keycap">@KeyName( "SirenLights" )</div><div class="lvc-key-label">Visuel</div>
									<div class="keycap">@KeyName( "Siren" )</div><div class="lvc-key-label">Sirène · maintenir : couper</div>
									<div class="keycap">@KeyName( "Horn" )</div><div class="lvc-key-label">Deux-tons</div>
								</div>
							}
						</div>
					}
					@foreach ( var (icon, label, value) in CarStats( car ) )
					{
						<div class="stat @StatTone( icon, value )">
							<div class="stat-value">@($"{(int)MathF.Round( Math.Clamp( value, 0, 1 ) * 100 )}%")</div>
							<div class="stat-label">@label</div>
							<i>@icon</i>
						</div>
					}
				</div>
				<div class="dial">
					<div class="dial-arc" style="@ArcFill( car.SpeedKmh / MathF.Max( 1, car.Info?.MaxSpeedKmh ?? 150 ) )"></div>
					<div class="dial-face"></div>
					@for ( var i = 0; i < DialTicks; i++ )
					{
						<div class="dial-tick @(i >= DialTicks - 2 ? "red" : "")" style="transform: rotate( @(Deg( DialAngle( i ) ))deg ) translateY( -79px );"></div>
					}
					<div class="dial-core">
						<div class="dial-unit">KM/H</div>
						<div class="dial-speed">
							@foreach ( var (digit, lit) in SpeedDigits( car.SpeedKmh ) )
							{
								<div class="digit @(lit ? "" : "dim")">@digit</div>
							}
						</div>
						<div class="dial-gear @(car.Gear == "R" ? "reverse" : "")">@GearLabel( car.Gear )</div>
						<div class="dial-fuel @StatTone( "local_gas_station", car.Fuel )">
							<i>local_gas_station</i>
							<div class="fuel-track"><div class="fuel-fill" style="width: @Percent( car.Fuel * 100 )"></div></div>
						</div>
					</div>
					<div class="dial-lights">
						<div class="lights-pill">
							@foreach ( var light in Telltales( car ) )
							{
								<div class="telltale" style="background-image: url( ui/vehicule/@(light).png );"></div>
							}
						</div>
					</div>
				</div>
			</div>
		}
	}

	<div class="toasts">
		@foreach ( var toast in HudNotifications.Current )
		{
			<div class="toast @toast.Kind">
				<i>@ToastIcon( toast.Kind )</i>
				<div class="toast-text">@toast.Message</div>
			</div>
		}
	</div>

	@if ( MenuOpen && Me is not null )
	{
		<div class="menu-backdrop">
			<div class="menu">
				<div class="menu-head">
					<div class="menu-title">HORIZON <span>RP</span></div>
					<div class="tabs">
						<div class="tab @(Tab == "metiers" ? "active" : "")" onclick=@(() => Tab = "metiers")><i>work</i> Métiers</div>
						<div class="tab @(Tab == "boutique" ? "active" : "")" onclick=@(() => Tab = "boutique")><i>storefront</i> Boutique</div>
						<div class="tab @(Tab == "banque" ? "active" : "")" onclick=@(() => Tab = "banque")><i>account_balance</i> Banque</div>
					</div>
					<div class="close" onclick=@(() => SetMenu( false ))><i>close</i></div>
				</div>

				<div class="menu-body">
					@if ( Tab == "metiers" )
					{
						<div class="grid">
							@foreach ( var job in Jobs.All.Where( x => !x.AdminOnly || Me.IsStaff ) )
							{
								var count = RoleplayPlayer.All.Count( x => x.JobId == job.Id );
								var full = job.MaxPlayers > 0 && count >= job.MaxPlayers && Me.JobId != job.Id;
								<div class="job-card @(Me.JobId == job.Id ? "current" : "") @(full ? "full" : "")" onclick=@(() => Me.RequestJob( job.Id ))>
									<div class="job-card-icon" style="background-color: @job.Color"><i>@job.Icon</i></div>
									<div class="job-card-name">@job.Name</div>
									<div class="job-card-desc">@job.Description</div>
									<div class="job-card-foot">
										<div>@($"{(Money( job.Salary ))} / paie")</div>
										<div>@($"{(count)}{(job.MaxPlayers > 0 ? $" / {job.MaxPlayers}" : "")}")</div>
									</div>
								</div>
							}
						</div>
					}
					else if ( Tab == "boutique" )
					{
						<div class="grid">
							@foreach ( var item in Shop.All )
							{
								<div class="job-card @(Me.Wallet < item.Price ? "full" : "")" onclick=@(() => Me.RequestBuy( item.Id ))>
									<div class="job-card-icon shop"><i>@item.Icon</i></div>
									<div class="job-card-name">@item.Name</div>
									<div class="job-card-desc">@item.Description</div>
									<div class="job-card-foot"><div>@Money( item.Price )</div><div>Acheter</div></div>
								</div>
							}
						</div>
					}
					else
					{
						<div class="bank">
							<div class="bank-balances">
								<div class="money-item"><div class="field-label">Portefeuille</div><div class="amount wallet">@Money( Me.Wallet )</div></div>
								<div class="money-item"><div class="field-label">Banque</div><div class="amount">@Money( Me.Bank )</div></div>
							</div>
							@* Dépôts et retraits : au distributeur seulement (Money/Atm.cs) ; virements : téléphone. *@
							<div class="bank-row">
								<div class="bank-label"><i>local_atm</i> Dépôts et retraits d'espèces dans un distributeur HZ Bank (touche E) ; virements depuis l'application HZ Bank du téléphone.</div>
							</div>
						</div>
					}
				</div>
			</div>
		</div>
	}

	@if ( PauseOpen )
	{
		<PauseMenu OnResume=@(() => SetPause( false ))></PauseMenu>
	}

	@if ( ScoreboardOpen )
	{
		<div class="scoreboard">
			<div class="scoreboard-head">
				<div class="menu-title">HORIZON <span>RP</span></div>
				<div class="brand-sub">@($"{(PlayerCount)} / {(Networking.MaxPlayers)} joueurs")</div>
			</div>
			@foreach ( var player in RoleplayPlayer.All.OrderBy( x => x.Job.Name ).ThenBy( x => x.RpName ) )
			{
				<div class="score-row">
					<div class="dot" style="background-color: @player.Job.Color"></div>
					<div class="score-name">@player.RpName</div>
					@if ( player.IsStaff )
					{
						<div class="staff">STAFF</div>
					}
					<div class="score-job" style="color: @player.Job.Color">@player.Job.Name</div>
					<div class="score-ping">@($"{(player.Network.Owner?.Ping.CeilToInt() ?? 0)} ms")</div>
				</div>
			}
		</div>
	}

	@* Inconscient (Death/) : le compte à rebours avant la mort, et qu'un EMS peut encore sauver. *@
	@if ( Me is { Downed: true } )
	{
		@* Un voile rouge sombre qui bat comme un cœur, et au centre un anneau fin qui se vide
		   avec le compte à rebours. *@
		<div class="downed @(Heartbeat.Flatlining ? "flatline" : "")">
			<div class="downed-vignette"></div>
			@* L'éclat de chaque battement de cœur, calé sur le son (Death/Heartbeat.cs). *@
			<div class="downed-pulse" style="opacity: @((Heartbeat.Pulse * 0.85f).ToString( "0.00", System.Globalization.CultureInfo.InvariantCulture ))"></div>
			<div class="downed-center">
				<div class="downed-title">INCONSCIENT</div>
				<div class="downed-ring">
					<div class="ring-arc" style="@RingFill( Me.DownedLeft / MathF.Max( 1, ServerSettings.DownSeconds ), "#ff4d6d" )"></div>
					<div class="downed-core">
						<div class="downed-timer">@($"{Me.DownedLeft / 60}:{Me.DownedLeft % 60:00}")</div>
						<div class="downed-sub">AVANT LA MORT</div>
					</div>
				</div>
				<div class="downed-text">Un EMS peut encore vous réanimer</div>
				@if ( DeathLoss is { } loss )
				{
					<div class="downed-warn"><i>warning</i>@($"Sans secours, vous perdrez {loss}")</div>
				}
			</div>
		</div>
	}

	@* Près d'un inconscient, E maintenu : un EMS le réanime, un autre le fouille (son argent liquide). *@
	@if ( RoleplayPlayer.ReviveTarget is { } patient && Me is { Downed: false } )
	{
		<div class="center-row">
		<div class="vehicle-card revive-card @(RoleplayPlayer.ReviveIsSearch ? "search" : "")">
			<div class="vc-title">@(RoleplayPlayer.ReviveProgress > 0 && RoleplayPlayer.ReviveIsSearch ? $"Fouiller {patient.RpName}" : RoleplayPlayer.ReviveProgress > 0 ? $"Réanimer {patient.RpName}" : patient.RpName)</div>
			<div class="vc-state locked"><i>emergency</i>@($"Inconscient — {patient.DownedLeft / 60}:{patient.DownedLeft % 60:00}")</div>
			@if ( RoleplayPlayer.ReviveCanRevive )
			{
				<div class="vc-hold"><div class="keycap">@KeyName( "use" )</div><div>Maintenir : réanimer</div></div>
			}
			<div class="vc-hold"><div class="keycap">@KeyName( "Search" )</div><div>Maintenir : fouiller</div></div>
			<div class="vc-bar"><div class="vc-fill" style="width: @((RoleplayPlayer.ReviveProgress * 100).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture ))%"></div></div>
		</div>
		</div>
	}

	@* Une blessure : du sang sur les bords de l'écran, qui s'efface (Death/Pain.cs). *@
	@if ( Pain.Blood > 0.01f )
	{
		<div class="sang-ecran" style="opacity: @(Pain.Blood.ToString( "0.00", System.Globalization.CultureInfo.InvariantCulture ))"></div>
	}

	@* Une grenade assourdissante : l'écran blanchit puis se rouvre (FlashEffect). *@
	@if ( FlashEffect.Amount > 0.01f )
	{
		<div class="flashbang" style="opacity: @(FlashEffect.Amount.ToString( "0.00", System.Globalization.CultureInfo.InvariantCulture ))"></div>
	}

	@* Une réanimation, une mort : fondu au noir (FadeEffect). *@
	@if ( FadeEffect.Amount > 0.01f )
	{
		<div class="fade-black" style="opacity: @(FadeEffect.Amount.ToString( "0.00", System.Globalization.CultureInfo.InvariantCulture ))"></div>
	}
</root>

@code
{

	/// <summary>
	/// Les actions rappelées sous le logo, comme sur les serveurs FiveM. La touche
	/// affichée est celle du joueur : sa disposition de clavier (A sur AZERTY là où
	/// un QWERTY a Q) et ce qu'il a changé dans les réglages.
	/// </summary>
	// Des propriétés, pas des champs : un tableau statique garderait son ancienne valeur
	// après un rechargement à chaud dans l'éditeur.
	static (string Action, string Label)[] PlayerActions => new (string, string)[]
	{
		("use", "Interagir"), ("Phone", "Téléphone"), ("Inventory", "Inventaire"), ("Wallet", "Portefeuille"),
		("JobMenu", "Menu"), ("PropMenu", "Props"), ("PropUndo", "Retirer le dernier prop"), ("WeaponMenu", "Menu de l'arme"), ("DoorMenu", "Porte"),
		("VehicleLock", "Verrou véhicule"), ("Voice", "Parler"), ("View", "Vue (1re, derrière, face)"), ("Score", "Joueurs"),
	};

	static (string Action, string Label)[] StaffActions => new (string, string)[]
	{
		("AdminMenu", "Administration" + AdminSuffix), ("StaffTool", "Physgun" + AdminSuffix), ("Noclip", "Noclip" + AdminSuffix),
	};

	/// <summary>
	/// Une action que seul le staff voit (Julien, 31/10/2026) : « (Admin) » en orange à la suite de
	/// son libellé, sur les cartes (portière, porte) et dans la liste des touches, pour savoir ce que
	/// les joueurs ne voient pas.
	/// </summary>
	const string AdminSuffix = " (Admin)";

	/// <summary>La touche d'une action, telle que le joueur la voit (et l'a peut-être changée).</summary>
	public static string KeyName( string action ) => Keybinds.DisplayOf( action );

	/// <summary>Le titre du boîtier de commande des gyrophares : le service du véhicule.</summary>
	static string LvcTitle( Vehicle car ) => car.EmsColors ? $"{EmsRules.Short} · {EmsRules.Service}" : PoliceRules.Short;

	/// <summary>Le mode des gyrophares, comme sur un boîtier Luxart : éteint, visuel seul, visuel + sonore.</summary>
	static string LvcMode( bool lights, int tone ) =>
		!lights ? "Gyrophares éteints" : tone == 0 ? "Visuel seul" : "Visuel + sonore";

	/// <summary>Repliées par défaut ; B (ou la touche choisie) les déroule.</summary>
	bool KeysShown { get; set; }

	/// <summary>
	/// Le nom RP du personnage, jamais le pseudo Steam : avant la création du
	/// personnage (ou le chargement du profil), RpName n'est encore que le pseudo.
	/// </summary>
	string RpNameShown => Me.NeedsCharacter || string.IsNullOrEmpty( Me.AppearanceJson ) ? "Nouveau citoyen" : Me.RpName;

	/// <summary>
	/// Les touches du menu déroulant (B). Dans un véhicule, ce sont les siennes : elles n'ont
	/// rien à faire en permanence sous le compteur.
	/// </summary>
	IEnumerable<(string Key, string Label)> KeyHints =>
		Car is { } car
			? CarKeys( car ).Select( x => (string.Join( " ", x.Keys ), x.Label) )
			: KeyInHand.Concat( (Me?.IsStaff == true ? PlayerActions.Concat( StaffActions ) : PlayerActions).Select( x => (KeyName( x.Action ), x.Label) ) );

	/// <summary>La clé d'un véhicule en main : la télécommande se presse au clic gauche ; un prop : il se pose.</summary>
	IEnumerable<(string Key, string Label)> KeyInHand => string.IsNullOrEmpty( Me?.HeldKey )
		? Enumerable.Empty<(string, string)>()
		: Cash.IsHeld( Me )
			? new[] { ("CLIC D", "Donner des billets · les poser au sol") }
			: PropItems.EntryOf( Me.HeldKey ) > 0
				? new[] { ("CLIC G", $"Poser {PropSystem.Current?.Get( PropItems.EntryOf( Me.HeldKey ) )?.Name ?? "le prop"}") }
				: new[] { ("CLIC G", $"Verrouiller · déverrouiller {VehicleKeys.PlateOf( Me.HeldKey )}") };

	public bool MenuOpen { get; private set; }
	public bool ScoreboardOpen { get; private set; }
	string Tab { get; set; } = "metiers";

	RoleplayPlayer Me => RoleplayPlayer.Local;

	/// <summary>Le véhicule où l'on est assis, ou null.</summary>
	Vehicle Car => Vehicle.OfLocalPlayer;

	/// <summary>Les touches à montrer : toutes au volant, seulement « descendre » en passager (le staff a la sienne en plus).</summary>
	IEnumerable<(string[] Keys, string Label)> CarKeys( Vehicle car )
	{
		var driving = car.Driver == Me && Me is not null;
		yield return (new[] { "MOLETTE" }, "Zoomer · dézoomer la caméra (3e personne)");
		if ( driving )
		{
			yield return (new[] { KeyName( "Forward" ), KeyName( "Backward" ) }, "Accélérer · freiner");
			yield return (new[] { KeyName( "Left" ), KeyName( "Right" ) }, "Tourner");
			yield return (new[] { KeyName( "Engine" ) }, car.EngineOn ? "Couper le moteur" : "Démarrer le moteur");
			yield return (new[] { KeyName( "RadioToggle" ) }, car.RadioEnabled ? "Couper la radio" : "Allumer la radio");
			yield return (new[] { KeyName( "RadioPrevious" ), KeyName( "RadioNext" ) }, "Station précédente · suivante");
			yield return (new[] { KeyName( "IndicatorLeft" ), KeyName( "IndicatorRight" ) }, "Clignotants");
			yield return (new[] { KeyName( "Hazards" ) }, "Feux de détresse");
			yield return (new[] { KeyName( "Headlights" ) }, car.Headlights == 0 ? "Allumer les codes" : car.Headlights == 1 ? "Passer en phares" : "Éteindre les phares");
			yield return (new[] { KeyName( "Jump" ) }, "Frein de parking (à l'arrêt) · frein à main");
			yield return (new[] { KeyName( "Horn" ) }, car.HasBeacons && car.EmergencyLights ? "Klaxon deux-tons" : "Klaxon");
			// Au volant d'un véhicule à gyrophares : gyrophares et sirène (Vehicle.Police).
			if ( car.Driver == Me && car.CanUseBeacons( Me ) )
			{
				yield return (new[] { KeyName( "SirenLights" ) }, car.EmergencyLights ? "Éteindre les gyrophares" : "Gyrophares (visuel seul)");
				yield return (new[] { KeyName( "Siren" ) }, car.EmergencyLights && car.SirenTone != 0
					? $"Sirène : {Vehicle.SirenName( car.SirenTone )} (suivante : {Vehicle.SirenName( (car.SirenTone + 1) % 4 ).ToLowerInvariant()}) · maintenir : couper le son"
					: "Sirène (visuel + sonore)");
			}
		}
		// Le bouton de la portière : conducteur et passagers.
		yield return (new[] { KeyName( "VehicleLock" ) }, car.Locked ? "Déverrouiller" : "Verrouiller");
		yield return (new[] { KeyName( "use" ) }, "Descendre");
		if ( Me?.IsStaff == true )
			yield return (new[] { KeyName( "VehicleAdmin" ) }, "Gérer" + AdminSuffix);
	}

	/// <summary>L'état du véhicule, en pourcentages, à côté du compteur : moteur, carrosserie (0 à 1). L'essence est dans le cadran.</summary>
	static IEnumerable<(string Icon, string Label, float Value)> CarStats( Vehicle car ) => new[]
	{
		("settings", "Moteur", car.EngineHealth / Vehicle.MaxHealth),
		("directions_car", "Carrosserie", car.BodyHealth / Vehicle.MaxHealth),
	};

	/// <summary>Blanc tant que ça va, ambre puis rouge (l'essence ne passe à l'ambre qu'en réserve).</summary>
	static string StatTone( string icon, float value ) => icon == "local_gas_station"
		? value < 0.1f ? "rouge" : value < 0.25f ? "ambre" : ""
		: value < 0.25f ? "rouge" : value < 0.6f ? "ambre" : "";

	/// <summary>
	/// Les témoins, dans l'ordre d'un vrai tableau de bord, allumés dans leur couleur normalisée
	/// (codes vert, phares bleu, frein rouge, moteur ambre puis rouge), éteints sinon.
	/// </summary>
	static IEnumerable<string> Telltales( Vehicle car )
	{
		// Les flèches, au rythme des clignotants ; les deux avec les feux de détresse.
		yield return car.IndicatorLit( 0 ) && car.Indicators is 1 or 3 ? "cligno_gauche_vert" : "cligno_gauche_eteint";
		yield return car.EngineOn ? "contact_vert" : "contact_eteint";
		yield return car.Headlights == 1 ? "codes_vert" : "codes_eteint";
		yield return car.Headlights == 2 ? "phares_bleu" : "phares_eteint";
		yield return car.ParkingBrake || car.Handbrake ? "parking_rouge" : "parking_eteint";
		yield return car.EngineDead ? "moteur_rouge" : car.EngineHealth < 400 ? "moteur_ambre" : "moteur_eteint";
		yield return car.Locked ? "verrou_orange" : "ouvert_eteint";
		yield return car.Indicators == 3 && car.BlinkOn ? "warning_rouge" : "warning_eteint";
		yield return car.IndicatorLit( 1 ) && car.Indicators is 2 or 3 ? "cligno_droite_vert" : "cligno_droite_eteint";
	}

	static string Deg( float value ) => value.ToString( "0.##", System.Globalization.CultureInfo.InvariantCulture );

	/// <summary>Un anneau rempli à <paramref name="fraction"/> (0 à 1), dans le sens des aiguilles, depuis midi.</summary>
	static string RingFill( float fraction, string color )
	{
		// Plein ou vide : une couleur unie. Le dégradé reboucle à midi (100 % touche 0 %), et l'arrêt
		// sombre posé à 100 % y laissait un fin trait sur un anneau plein (Julien, 29/10/2026).
		const string Empty = "rgba( 16, 18, 26, 0.78 )";
		if ( fraction >= 0.995f )
			return $"background-color: {color};";
		if ( fraction <= 0.005f )
			return $"background-color: {Empty};";

		// Les arrêts en pourcentages du tour : le lecteur de styles de s&box ne prend pas les degrés.
		var a = Deg( Math.Clamp( fraction, 0, 1 ) * 100f );
		return $"background: conic-gradient( {color} 0%, {color} {a}%, rgba( 16, 18, 26, 0.78 ) {a}%, rgba( 16, 18, 26, 0.78 ) 100% );";
	}

	// Le cadran : un arc de 240°, de 8 h à 4 h en passant par midi, et neuf graduations dessus.
	const float DialSweep = 240f;
	const int DialTicks = 9;
	static float DialAngle( int i ) => -DialSweep / 2 + DialSweep * i / (DialTicks - 1);

	/// <summary>
	/// L'arc de la vitesse (0 à 1 de la vitesse maximale du modèle). L'élément est tourné de
	/// -120° : le dégradé conique part de midi, l'arc part donc de 8 h.
	/// </summary>
	static string ArcFill( float fraction )
	{
		var a = Deg( Math.Clamp( fraction, 0, 1 ) * DialSweep / 3.6f );
		var end = Deg( DialSweep / 3.6f );
		var track = "rgba( 255, 255, 255, 0.10 )";
		var none = "rgba( 0, 0, 0, 0 )";
		return $"background: conic-gradient( #f4f5fb 0%, #f4f5fb {a}%, {track} {a}%, {track} {end}%, {none} {end}%, {none} 100% );";
	}

	/// <summary>La vitesse sur trois chiffres : « 047 », les zéros de tête éteints.</summary>
	static IEnumerable<(char Digit, bool Lit)> SpeedDigits( float kmh )
	{
		var text = Math.Clamp( kmh.FloorToInt(), 0, 999 ).ToString( "000" );
		var lit = false;
		for ( var i = 0; i < text.Length; i++ )
		{
			lit |= text[i] != '0' || i == text.Length - 1;
			yield return (text[i], lit);
		}
	}

	/// <summary>Le rapport en clair sous la vitesse : « POINT MORT », « MARCHE ARRIÈRE », « 3E ».</summary>
	static string GearLabel( string gear ) => gear switch
	{
		"N" => "POINT MORT",
		"R" => "MARCHE ARRIÈRE",
		"P" => "PARKING",
		_ => $"RAPPORT {gear}",
	};

	/// <summary>Ce qu'une mort coûtera, d'après les réglages (null : rien).</summary>
	static string DeathLoss => (ServerSettings.LoseInventory, ServerSettings.LoseCash) switch
	{
		(true, true) => "vos affaires et l'argent sur vous",
		(true, false) => "vos affaires",
		(false, true) => "l'argent sur vous",
		_ => null,
	};

	sealed record Ring( string Kind, string Icon, float Value, string Color, bool Alert );

	/// <summary>Les anneaux du joueur : vie, faim, et la voix (pleine quand on parle, suit le volume).</summary>
	IEnumerable<Ring> Rings
	{
		get
		{
			var me = Me;
			yield return new Ring( "health", "favorite", me.Health / 100f, "#ff4d6d", me.Health < 25 );
			yield return new Ring( "hunger", "restaurant", me.Hunger / 100f, "#ffa94d", me.Hunger < 20 );
			// L'endurance : grise à bout, le temps de reprendre son souffle.
			yield return new Ring( "stamina", "directions_run", Stamina.Value,
				Stamina.Exhausted ? "#8a8fa8" : "#4dd4ff", Stamina.Exhausted || Stamina.Value < 0.25f );
			var voice = me.GetComponent<RpVoice>();
			var talking = voice.IsValid() && voice.IsListening;
			var level = talking ? 0.35f + 0.65f * Math.Clamp( voice.Amplitude * 4f, 0, 1 ) : 1f;
			yield return new Ring( talking ? "voice talking" : "voice", talking ? "mic" : "mic_none", level, talking ? "#f4f5fb" : "rgba( 244, 245, 251, 0.28 )", false );
		}
	}

	/// <summary>Le niveau de la voix, en dix crans (-1 : on ne parle pas), pour redessiner l'anneau.</summary>
	int VoiceLevel => Me?.GetComponent<RpVoice>() is { } v && v.IsValid() && v.IsListening ? (int)(Math.Clamp( v.Amplitude * 4f, 0, 1 ) * 10) : -1;

	/// <summary>L'argent liquide sans le « $ » de Format.Money : le symbole est dessiné à part, devant.</summary>
	string CashDigits => Money( Me.Wallet ).Replace( "$", "" ).Trim();

	int PlayerCount => RoleplayPlayer.All.Count();
	string Clock => DateTime.Now.ToString( "HH:mm" );

	string Countdown
	{
		get
		{
			var seconds = Math.Max( 0, HorizonGame.Current?.SecondsToPayday ?? 0 );
			return $"{seconds / 60}:{seconds % 60:00}";
		}
	}

	public bool PauseOpen { get; private set; }

	void SetPause( bool open )
	{
		PauseOpen = open;
		if ( !open )
			Keybinds.CancelCapture();
		UiLock.Set( "pause", open );
	}

	/// <summary>
	/// Échap : referme le menu F4 ou la pause ; sinon, s'il n'y a aucun autre menu
	/// (chacun se referme lui-même), ouvre notre menu pause à la place de celui de s&box.
	/// </summary>
	void HandleEscape()
	{
		if ( !Input.EscapePressed )
			return;

		// En attente d'une nouvelle touche : Échap annule, la pause reste ouverte.
		if ( Keybinds.Capturing is not null )
			Keybinds.CancelCapture();
		else if ( PauseOpen )
			SetPause( false );
		else if ( MenuOpen )
			SetMenu( false );
		// Inconscient, la pause reste la nôtre : sans « inconscient » ici, Échap tombait dans
		// celle de s&box (Julien, 30/10/2026).
		else if ( !UiLock.IsLockedExcept( "personnage", "pause", "inconscient" ) )
			SetPause( true );
		else
			return;

		Input.EscapePressed = false;
	}

	protected override void OnUpdate()
	{
		// La fenêtre des billets vit à côté du HUD, créée ici sur chaque machine (pas dans la scène).
		if ( !Components.Get<CashMenu>().IsValid() )
			Components.Create<CashMenu>();
		if ( !Components.Get<AtmScreen>().IsValid() )
			Components.Create<AtmScreen>();
		if ( !Components.Get<SearchMenu>().IsValid() )
			Components.Create<SearchMenu>();
		HandleEscape();
		Gps.Update( Me );
		DayNight.Update( Scene );
		FirstPersonLegs.Update( Scene );
		Stamina.Update( Scene );
		// Le staff en noclip n'est pas dessiné (RoleplayPlayer.HiddenTag).
		if ( Scene.Camera is { } camera && !camera.RenderExcludeTags.Has( RoleplayPlayer.HiddenTag ) )
			camera.RenderExcludeTags.Add( RoleplayPlayer.HiddenTag );

		if ( Me is null )
		{
			MenuOpen = false;
			ScoreboardOpen = false;
			return;
		}

		// 1 à 5 = les poches (positions 1 à 5 de la barre, la 0 étant « Mains »).
		for ( var i = 1; i <= InventoryRules.HotbarSlots; i++ )
		{
			if ( Input.Pressed( $"Slot{i}" ) )
				ShowHotbar( i );
		}

		if ( Input.Pressed( "KeyHints" ) && !UiLock.IsLocked )
			KeysShown = !KeysShown;

		if ( Input.Pressed( "JobMenu" ) && !Me.NeedsCharacter )
			SetMenu( !MenuOpen );

		ScoreboardOpen = !MenuOpen && Input.Down( "Score" );

		if ( Input.Pressed( "VehicleLock" ) )
			VehicleManager.Current?.RequestToggleLock();
	}

	void SetMenu( bool open )
	{
		MenuOpen = open;

		// Menu ouvert : la souris sert au menu, le personnage ne bouge plus.
		UiLock.Set( "hud", open );
	}

	/// <summary>Ouvre le menu F4 sur un onglet : distributeur (banque), PNJ (métiers).</summary>
	public void OpenTab( string tab )
	{
		Tab = tab;
		SetMenu( true );
	}

	static string Percent( float value ) => $"{Math.Clamp( value, 0, 100 ):0.#}%";

	static string Money( int value ) => Format.Money( value );

	static string ToastIcon( string kind ) => kind switch
	{
		"argent" => "payments",
		"erreur" => "error",
		"annonce" => "campaign",
		_ => "info",
	};

	/// <summary>Depuis la dernière touche 1 à 5 (ou coup de molette) : la barre des poches reste quelques secondes.</summary>
	static RealTimeSince sinceHotkey = 100;

	/// <summary>La case choisie dans la barre (touche ou molette) : 0 = Mains, 1 à 5 = les poches.</summary>
	public static int SelectedPocket { get; private set; }

	public const int HandsPosition = 0;

	/// <summary>La case « Billets », après les cinq poches.</summary>
	public static int CashPosition => InventoryRules.HotbarSlots + 1;

	/// <summary>Une poche vient d'être choisie : la barre s'affiche et la surligne.</summary>
	public static void ShowHotbar( int pocket )
	{
		SelectedPocket = pocket;
		sinceHotkey = 0;
	}

	bool HotbarVisible => Me is not null && !Me.NeedsCharacter && !UiLock.IsLocked && sinceHotkey < 2.5f;

	/// <summary>L'objet que le joueur vise et qui réagit à E (sa bulle d'aide), hors menus.</summary>
	IPressable.Tooltip? Interaction
	{
		get
		{
			if ( UiLock.IsLocked || Me?.GetComponent<PlayerController>() is not { } controller || controller.Hovered is not IPressable pressable )
				return null;
			// Un objet visé puis détruit : une erreur ici, à chaque image, figeait tout le HUD.
			if ( pressable is Component c && !c.IsValid() )
				return null;
			var tip = pressable.GetTooltip( new IPressable.Event( controller ) );
			return tip is { } t && !string.IsNullOrEmpty( t.Title ) ? tip : null;
		}
	}

	/// <summary>La porte de la map visée, si l'on peut la verrouiller (MapDoor.CanLock), ou null.</summary>
	MapDoor LockableDoor => !UiLock.IsLocked && Me?.GetComponent<PlayerController>()?.Hovered is MapDoor door && MapDoor.CanLock( door ) ? door : null;

	/// <summary>La carte d'un véhicule dont on touche la portière (hors menus), ou null.</summary>
	(string Title, bool Locked, float Progress, string Mode)? VehicleCard
	{
		get
		{
			// Assis dans un véhicule : aucune carte (on visait sa propre voiture en conduisant).
			if ( UiLock.IsLocked || Me?.GetComponent<PlayerController>() is not { } controller || controller.Hovered is not Vehicle vehicle || !vehicle.IsValid()
				|| Me.GameObject.GetComponentInParent<Vehicle>() is not null )
				return null;
			return vehicle.CardFor( controller );
		}
	}

	/// <summary>L'arme sortie par le joueur, pour le compteur de balles ; null sans arme.</summary>
	(string Name, int Clip, int ClipSize, int Reserve, string Tactical)? Armed =>
		WeaponHolder.Local is { } holder && holder.ActiveWeapon is { } weapon && weapon.Primary is { } primary
			? (weapon.DisplayName, primary.Ammo, primary.ClipSize, holder.AmmoCount( primary.AmmoType ), TacticalLabel( weapon ))
			: null;

	/// <summary>Ce que la touche Lampe allume sur l'arme sortie (laser, lampe), null sans rien.</summary>
	static string TacticalLabel( SWB.Base.Weapon weapon ) =>
		weapon.Attachments.OfType<Fas2Tactical>().FirstOrDefault( x => x.Equipped ) is { } t
			? (t.HasLaser && t.HasLight ? "Laser et lampe" : t.HasLaser ? "Laser" : "Lampe")
			: null;

	protected override int BuildHash()
	{
		var me = Me;
		var players = RoleplayPlayer.All.Aggregate( 0, ( h, p ) => HashCode.Combine( h, p.RpName, p.JobId, p.IsStaff ) );

		return HashCode.Combine(
			HashCode.Combine( me?.RpName, me?.NeedsCharacter, me?.AppearanceJson is null, me?.JobId, me?.Wallet, me?.Bank, me?.IsStaff, HashCode.Combine( PauseOpen, KeysShown, me?.HeldKey, me?.OnDuty, me?.PoliceGrade, PoliceShowroom.Framing ) ),
			HashCode.Combine( MathF.Ceiling( me?.Health ?? 0 ), MathF.Ceiling( me?.Hunger ?? 0 ), VoiceLevel, (int)(FlashEffect.Amount * 60) + (int)(Pain.Blood * 60) * 1000, HashCode.Combine( me?.Downed, me?.DownedLeft, (int)(Heartbeat.Pulse * 40), Heartbeat.Flatlining, (int)(FadeEffect.Amount * 60), RoleplayPlayer.ReviveTarget?.DownedLeft, RoleplayPlayer.ReviveTarget?.RpName, (int)(RoleplayPlayer.ReviveProgress * 60) + (RoleplayPlayer.ReviveIsSearch ? 1000 : 0) + (RoleplayPlayer.ReviveCanRevive ? 2000 : 0) ) ),
			HashCode.Combine( MenuOpen, ScoreboardOpen, Tab, Armed, (Interaction ?? PoliceGarageMenu.Prompt)?.Title, Interaction?.Description, HotbarVisible ? HashCode.Combine( me?.InventoryJson, SelectedPocket ) : 0, HashCode.Combine( LockableDoor?.Locked, (int)(MapDoor.LockProgress * 60) ) ),
			VehicleCard is { } vc ? HashCode.Combine( vc.Title, vc.Locked, (int)(vc.Progress * 60), vc.Mode ) : 0,
			HorizonGame.Current?.SecondsToPayday,
			HashCode.Combine( HudNotifications.Version, Car?.SpeedKmh.FloorToInt(), Car?.Locked, Car?.Gear, (int)((Car?.Rpm ?? 0) * 30), HashCode.Combine( Car?.Handbrake, Car?.ParkingBrake, Car?.EngineOn, Car?.Headlights, Car is { } blink ? HashCode.Combine( blink.Indicators, blink.BlinkOn ) : 0, (int)((Car?.EngineHealth ?? 1000) / 10), (int)((Car?.BodyHealth ?? 1000) / 10), (int)((Car?.Fuel ?? 1) * 100) ) ),
			HashCode.Combine( players, Car is { } police && police.HasBeacons ? HashCode.Combine( police.LightsShown, police.ToneShown, police.LightsShown && police.BeaconLeftPhase, police.PoliceHornActive, Me?.IsCop, Me?.IsEms, police.Driver == Me ) : 0,
				HashCode.Combine( Car?.RadioStationId, Car?.RadioEnabled, Car?.RadioStatus, Car?.Driver == Me, KeyName( "RadioToggle" ), KeyName( "RadioPrevious" ), KeyName( "RadioNext" ) ) ),
			HashCode.Combine( Clock, Gps.Version, (int)Gps.RemainingMeters / 10, Gps.Target is null ? 0 : (int)(GpsArrowAngle / 4), (int)(Stamina.Value * 100), Stamina.Exhausted ) );
	}

	/// <summary>La flèche de navigation, tournée par rapport à la caméra : « tout droit » pointe vers le haut.</summary>
	float GpsArrowAngle => Me is null || Scene.Camera is null ? 0
		: Gps.NextBearing( Me.WorldPosition ) - GpsMap.Heading( Scene.Camera.WorldRotation.Yaw() );

	string GpsArrowStyle => $"transform: rotate( {GpsArrowAngle.ToString( "0", System.Globalization.CultureInfo.InvariantCulture )}deg );";
}