UI/PoliceGarageMenu.razor

Police and EMS service garage menu, a Razor panel opened at a garage point with the E key. Shows the fleet of the player's service as cards, a live showroom preview of the selected vehicle with orbit and zoom, gauges, plate and rank requirement, and sends spawn or store requests to PoliceService.

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

@*
	Garage de service, en « concession » façon Argus Carshop (Julien, 29/10/2026). Ouvert par E au
	point « police_garage » (agent en service) ou « ems_garage » (secouriste en service) : le
	service se déduit du joueur, la flotte aussi (PoliceRules.Fleet, EmsRules.Fleet).

	Le véhicule choisi est posé dans la ville, à la place où il sortira, en aperçu local
	(UI/PoliceShowroom.cs : jamais un vrai véhicule tant qu'on n'a pas validé), et la caméra du
	joueur le cadre. Glisser le fait tourner, la molette zoome, « Gyrophares » les allume sur
	l'aperçu. À gauche ses jauges (d'après la fiche du catalogue), en bas les catégories et les
	cartes de la flotte, à droite la plaque qu'il portera, le grade requis et les boutons.

	« Sortir le véhicule » passe la place et l'orientation de l'aperçu à l'hôte
	(PoliceService.GarageRequestSpawn), qui revérifie service, grade, distance et place libre ;
	l'aperçu reste jusqu'à ce que le vrai véhicule soit prêt à sa place, puis le menu se ferme.
	Fermer (Échap, fin de service, chute, éloignement) détruit l'aperçu et rend la caméra.
	Hors du menu, le bandeau « E · Garage de police » du HUD vient de Prompt.
*@

<root>
	@if ( IsOpen && Me is not null )
	{
		<div class="ps @(Ems ? "ems" : "police") @(Leaving ? "leaving" : "")" onmouseup=@(() => orbiting = false)>
			@* Le calque qui reçoit la souris (glisser, molette) : sous les panneaux, qui restent cliquables. *@
			<WheelPanel class="ps-stage" Wheel=@((float y) => StageWheel( y )) onmousedown=@(() => StartOrbit())></WheelPanel>
			<div class="ps-shade-left"></div>
			<div class="ps-shade-bottom"></div>

			<div class="ps-top">
				<div class="ps-brand">
					<div class="ps-brand-icon"><i>@(Ems ? "emergency" : "local_police")</i></div>
					<div class="ps-brand-text">
						<div class="ps-brand-title">@Title</div>
						<div class="ps-brand-dept">@Department.ToUpperInvariant()</div>
					</div>
				</div>
				<div class="ps-quit" onclick=@(() => Close())>
					<div class="ps-quit-label">Quitter</div>
					<div class="keycap">Échap</div>
				</div>
			</div>

			@if ( Leaving )
			{
				<div class="ps-leaving"><i>key</i><div>@($"{SelectedName} : sortie du véhicule…")</div></div>
			}
			else
			{
				@if ( SelectedFleet is { } shown )
				{
					var model = VehicleCatalog.Get( shown.ModelId );
					var allowedShown = Allowed( shown );
					var required = GradeOfRank( shown.MinRank );
					<div class="ps-left">
						<div class="ps-category">@SelectedCategory.ToUpperInvariant()</div>
						<div class="ps-name">@SelectedName</div>
						<div class="ps-desc">@DescriptionOf( shown, model )</div>
						<div class="ps-gauges">
							@foreach ( var g in Gauges( model ) )
							{
								<div class="ps-gauge">
									<div class="ps-gauge-head">
										<i>@g.Icon</i>
										<div class="ps-gauge-label">@g.Label</div>
										<div class="ps-gauge-value">@g.Value</div>
									</div>
									<div class="ps-segments">
										@for ( var i = 0; i < Segments; i++ )
										{
											<div class="seg @(i < g.Lit ? "on" : "")"></div>
										}
									</div>
								</div>
							}
						</div>
						<div class="ps-kit">
							<i>@(shown.Lightbar ? "emergency" : "visibility_off")</i>
							<div>@(shown.Lightbar ? "Rampe de gyrophares sur le toit" : "Banalisée : feux derrière la calandre et le pare-brise")</div>
						</div>
					</div>

					<div class="ps-right">
						<div class="ps-section-label">Plaque</div>
						<div class="ps-plate">
							<div class="ps-plate-top">UNION CITY</div>
							<div class="ps-plate-number">@Plate</div>
							<div class="ps-plate-bottom">@(Ems ? "FIRE · EMS" : "POLICE")</div>
						</div>

						<div class="ps-section-label">Grade requis</div>
						<div class="ps-grade @(allowedShown ? "ok" : "ko")">
							<i>@required.Insignia</i>
							<div class="ps-grade-text">
								<div class="ps-grade-name">@($"{required.Name} · {required.English}")</div>
								<div class="ps-grade-mine">@($"Votre grade : {Grade.Name}")</div>
							</div>
							<i class="ps-grade-state">@(allowedShown ? "check_circle" : "lock")</i>
						</div>

						@if ( Spot is null )
						{
							<div class="ps-warning"><i>block</i><div>Toutes les places de sortie du garage sont occupées.</div></div>
						}

						<div class="ps-actions">
							@if ( showroom.Active )
							{
								<div class="ps-btn @(showroom.Beacons ? "on" : "")" onclick=@(() => ToggleBeacons())>
									<i>emergency</i> @(showroom.Beacons ? "Couper les gyrophares" : "Gyrophares")
								</div>
							}
							<div class="ps-btn primary @(CanSpawn ? "" : "disabled")" onclick=@(() => Spawn())>
								<i>key</i> Sortir le véhicule
							</div>
							@if ( OutVehicle is { } outside )
							{
								<div class="ps-btn" onclick=@(() => Store())>
									<i>garage</i> @($"Ranger mon véhicule ({outside.Plate})")
								</div>
							}
						</div>

						@if ( showroom.Active && !showroom.Loaded )
						{
							<div class="ps-loading">Préparation du véhicule…</div>
						}

						<div class="ps-hints">
							<div class="ps-hint"><i>pan_tool</i><div>Glisser : tourner le véhicule</div></div>
							<div class="ps-hint"><i>mouse</i><div>Molette : rapprocher, éloigner</div></div>
							<div class="ps-hint-title">Au volant</div>
							<div class="ps-hint"><div class="keycap">@Keybinds.DisplayOf( "SirenLights" )</div><div>Gyrophares (visuel seul)</div></div>
							<div class="ps-hint"><div class="keycap">@Keybinds.DisplayOf( "Siren" )</div><div>Sirène (maintenir : couper)</div></div>
							<div class="ps-hint"><div class="keycap">@Keybinds.DisplayOf( "Horn" )</div><div>Deux-tons</div></div>
						</div>
					</div>
				}

				<div class="ps-bottom">
					<div class="ps-categories">
						<div class="ps-arrow" onclick=@(() => Step( -1 ))><i>chevron_left</i></div>
						@foreach ( var category in Categories )
						{
							<div class="ps-cat @(category == SelectedCategory ? "active" : "")" onclick=@(() => SelectCategory( category ))>@category</div>
						}
						<div class="ps-arrow" onclick=@(() => Step( 1 ))><i>chevron_right</i></div>
					</div>
					<div class="ps-cards">
						@foreach ( var f in Fleet )
						{
							var allowed = Allowed( f );
							var m = VehicleCatalog.Get( f.ModelId );
							var grade = GradeOfRank( f.MinRank );
							<div class="ps-card @(f.Id == SelectedFleet?.Id ? "selected" : "") @(allowed ? "" : "locked")" onclick=@(() => Select( f.Id ))>
								<div class="ps-card-photo" style="background-image: url( @PhotoOf( f.ModelId ) );"></div>
								<div class="ps-card-name">@(m?.Name ?? f.ModelId)</div>
								<div class="ps-card-foot">
									<div class="ps-card-cat">@CategoryOf( f )</div>
									<div class="ps-card-tag @(allowed ? "ok" : "ko")"><i>@(allowed ? grade.Insignia : "lock")</i><div>@grade.Name</div></div>
								</div>
							</div>
						}
					</div>
				</div>
			}
		</div>
	}
</root>

@code
{
	public bool IsOpen { get; private set; }

	/// <summary>
	/// Le bandeau du HUD au point du garage (« E · Garage de police »), pour un agent en service à
	/// pied, menu fermé ; null sinon. Recalculé à chaque image.
	/// </summary>
	public static IPressable.Tooltip? Prompt { get; private set; }

	RoleplayPlayer Me => RoleplayPlayer.Local;

	/// <summary>Le garage des secours (secouriste en service) plutôt que celui de la police.</summary>
	bool Ems => Me?.IsEms == true;

	PoliceRules.FleetVehicle[] Fleet => Ems ? EmsRules.Fleet : PoliceRules.Fleet;
	int Rank => Ems ? Me.EmsGrade : Me?.PoliceGrade ?? 0;
	PoliceRules.Grade GradeOfRank( int rank ) => Ems ? EmsRules.GradeOf( rank ) : PoliceRules.GradeOf( rank );
	PoliceRules.Grade Grade => GradeOfRank( Rank );
	bool Allowed( PoliceRules.FleetVehicle fleet ) => Rank >= fleet.MinRank;
	string Title => Ems ? "GARAGE DES SECOURS" : "GARAGE DE POLICE";
	string Department => Ems ? $"{EmsRules.Department} · {EmsRules.Service}" : PoliceRules.Department;
	Vehicle OutVehicle => Me is null ? null : PoliceService.ServiceVehicleOf( Me.SteamId );

	/// <summary>Le véhicule montré (un id de la flotte).</summary>
	string Selected { get; set; }
	PoliceRules.FleetVehicle SelectedFleet => Fleet.FirstOrDefault( x => x.Id == Selected ) ?? Fleet.FirstOrDefault();
	string SelectedName => SelectedFleet is { } f ? VehicleCatalog.Get( f.ModelId )?.Name ?? f.Label : "";
	string SelectedCategory => SelectedFleet is { } f ? CategoryOf( f ) : "";

	/// <summary>La place de sortie où l'aperçu est posé (null : toutes sont prises).</summary>
	ServerSettings.Point Spot { get; set; }

	/// <summary>La plaque qui sera posée (« UCPD1042 », « UCFD2001 »), calculée à l'ouverture.</summary>
	string Plate { get; set; } = "";

	readonly PoliceShowroom showroom = new();

	/// <summary>« Sortir » demandé : on attend le vrai véhicule (3 s au plus) avant de fermer.</summary>
	bool Leaving { get; set; }
	RealTimeSince sinceLeaving;
	Vehicle leavingFrom;

	/// <summary>La catégorie d'un véhicule : son intitulé sans la parenthèse (« Patrouille »).</summary>
	static string CategoryOf( PoliceRules.FleetVehicle fleet )
	{
		var cut = fleet.Label.IndexOf( " (", StringComparison.Ordinal );
		return cut > 0 ? fleet.Label[..cut] : fleet.Label;
	}

	IEnumerable<string> Categories => Fleet.Select( CategoryOf ).Distinct();

	/// <summary>Ce qu'on dit de chaque véhicule de la flotte ; à défaut, la description de la fiche.</summary>
	static string DescriptionOf( PoliceRules.FleetVehicle fleet, VehicleModel model ) => fleet.Id switch
	{
		"patrouille" => "La voiture de patrouille de l'UCPD, livrée noire et jaune. Vive et maniable en ville : c'est elle qu'on croise à chaque carrefour.",
		"interception" => "Pour les poursuites : la grande berline, son V8 et ses gros freins, avec la rampe sur le toit.",
		"banalisee" => "Sans marquage ni rampe : les feux sont cachés derrière la calandre et le pare-brise. Pour les filatures et les enquêtes.",
		"intervention" => "Le 4×4 de l'unité d'intervention : lourd, haut sur pattes, il passe partout. Pour les opérations et les renforts.",
		"ambulance" => "L'ambulance de l'UCFD : un G 63 aux couleurs des secours, rampe rouge et blanche, pour arriver vite et repartir avec le patient.",
		_ => model?.Description ?? "",
	};

	// Les jauges, d'après la fiche du catalogue, sur des échelles fixes (pour comparer d'un véhicule à l'autre).
	const int Segments = 12;

	sealed record Gauge( string Icon, string Label, string Value, int Lit );

	static List<Gauge> Gauges( VehicleModel model )
	{
		if ( model is null )
			return new();
		static int Lit( float ratio ) => Math.Clamp( (int)MathF.Round( ratio * Segments ), 1, Segments );
		var zeroToHundred = 100f / MathF.Max( 1f, model.Acceleration );
		return new()
		{
			new( "speed", "Vitesse max", $"{model.MaxSpeedKmh:0} km/h", Lit( model.MaxSpeedKmh / 320f ) ),
			new( "bolt", "Accélération", $"0 à 100 en {zeroToHundred:0.0} s", Lit( model.Acceleration / 30f ) ),
			new( "do_not_disturb_on", "Freinage", $"{model.BrakeG:0.00} g", Lit( model.BrakeG / 1.4f ) ),
			new( "tire_repair", "Adhérence", $"{model.Grip:0.00}", Lit( model.Grip / 1.4f ) ),
		};
	}

	static readonly Dictionary<string, string> photos = new();

	/// <summary>
	/// La photo d'un modèle (ui/vehicules/&lt;id&gt;.png, rendue dans l'éditeur) ; sans photo à
	/// lui, celle du modèle dont il dérive (« g63_ems » : celle du « g63 »).
	/// </summary>
	static string PhotoOf( string modelId )
	{
		if ( photos.TryGetValue( modelId, out var known ) )
			return known;
		var path = $"ui/vehicules/{modelId}.png";
		var cut = modelId.IndexOf( '_' );
		if ( cut > 0 && !FileSystem.Mounted.FileExists( path ) )
			path = $"ui/vehicules/{modelId[..cut]}.png";
		photos[modelId] = path;
		return path;
	}

	/// <summary>Un agent (ou un secouriste) en service, à pied, au point de son garage.</summary>
	bool AtPoint => Me is { } me && (me.IsCop || me.IsEms) && me.GameObject.GetComponentInParent<Vehicle>() is null
		&& PoliceService.AtGarage( me.WorldPosition, ems: me.IsEms );

	bool CanSpawn => !Leaving && SelectedFleet is { } f && Allowed( f ) && Spot is not null && showroom.Loaded;

	public void Open()
	{
		var me = Me;
		if ( me is null )
			return;
		IsOpen = true;
		Leaving = false;
		UiLock.Set( "police_garage", true );

		// Le véhicule montré d'abord : le plus haut que permet le grade (celui qu'on vient chercher).
		Selected = Fleet.Where( Allowed ).OrderByDescending( x => x.MinRank ).FirstOrDefault()?.Id ?? Fleet.FirstOrDefault()?.Id;
		Plate = PoliceService.ExpectedPlate( me );

		// L'aperçu se pose à la place où sortira le véhicule (le véhicule de service du joueur, qui
		// rentrera au garage, ne la bloque pas) ; toutes prises : le menu sans aperçu.
		Spot = PoliceService.FreeGarageSpot( Ems, OutVehicle );
		if ( Spot is not null )
			showroom.Begin( Spot.Position, Spot.Yaw );
	}

	void Close()
	{
		IsOpen = false;
		Leaving = false;
		orbiting = false;
		leavingFrom = null;
		showroom.End();
		UiLock.Set( "police_garage", false );
	}

	protected override void OnDisabled()
	{
		Prompt = null;
		if ( IsOpen )
			Close();
		showroom.End();
		UiLock.Set( "police_garage", false );
	}

	protected override void OnDestroy()
	{
		showroom.End();
		base.OnDestroy();
	}

	protected override void OnUpdate()
	{
		if ( IsOpen && Input.EscapePressed )
		{
			Input.EscapePressed = false;
			Close();
			return;
		}

		Prompt = AtPoint && !IsOpen && !UiLock.IsLocked
			? (Ems ? new IPressable.Tooltip( "Garage des secours", "emergency", "Ambulances" ) : new IPressable.Tooltip( "Garage de police", "local_police", "Véhicules de service" ))
			: null;

		// E au point, sans rien viser d'autre (une portière, un objet) : c'est lui qui répondrait.
		if ( Prompt is not null && Input.Pressed( "use" ) && Me.GetComponent<PlayerController>()?.Hovered is null )
		{
			Open();
			return;
		}

		if ( !IsOpen )
			return;

		// Plus en service, à terre, monté dans un véhicule ou parti du garage : le menu se referme,
		// l'aperçu disparaît et la caméra revient (l'hôte refuserait de toute façon).
		var me = Me;
		if ( me is null || !(me.IsCop || me.IsEms) || me.OnTheGround || me.GameObject.GetComponentInParent<Vehicle>() is not null
			|| !PoliceService.AtGarage( me.WorldPosition, PoliceService.GarageUseReach, me.IsEms ) )
		{
			Close();
			return;
		}

		// Le vrai véhicule est là et prêt : il remplace l'aperçu, le menu se ferme (refus de l'hôte :
		// sa notification s'affiche, et le menu se ferme au bout de 3 s).
		if ( Leaving )
		{
			var now = OutVehicle;
			if ( (now is not null && now != leavingFrom && now.Ready) || sinceLeaving > 3f )
			{
				Close();
				return;
			}
		}

		if ( showroom.Active )
		{
			UpdateOrbit();
			if ( SelectedFleet is { } fleet && VehicleCatalog.Get( fleet.ModelId ) is { } model )
				showroom.Show( model, fleet.Color, fleet.Lightbar, Ems, Plate );
			showroom.Tick();
		}
	}

	// Glisser sur la scène fait tourner le véhicule, la molette zoome. L'interface garde la souris
	// pour elle (Input.MouseWheel et « attack1 » restent à zéro sous un menu) : c'est le calque
	// .ps-stage qui la reçoit.

	bool orbiting;
	Vector2 orbitLast;

	void StartOrbit()
	{
		orbiting = true;
		orbitLast = Mouse.Position;
	}

	void UpdateOrbit()
	{
		if ( !orbiting )
			return;
		// Vers la droite, le côté vu de la voiture part vers la droite : on la tient comme un plateau.
		showroom.Turn += (Mouse.Position.x - orbitLast.x) * 0.35f;
		orbitLast = Mouse.Position;
	}

	void StageWheel( float y ) => showroom.Zoom -= y * 0.12f;

	void ToggleBeacons() => showroom.Beacons = !showroom.Beacons;

	void Select( string fleetId )
	{
		if ( !Leaving && fleetId is not null )
			Selected = fleetId;
	}

	void SelectCategory( string category ) => Select( Fleet.FirstOrDefault( x => CategoryOf( x ) == category )?.Id );

	/// <summary>Les flèches : le véhicule précédent ou suivant de la flotte (et donc sa catégorie).</summary>
	void Step( int direction )
	{
		var list = Fleet;
		if ( list.Length == 0 )
			return;
		var index = Array.FindIndex( list, x => x.Id == SelectedFleet?.Id );
		Select( list[((index < 0 ? 0 : index) + direction + list.Length) % list.Length].Id );
	}

	void Spawn()
	{
		if ( !CanSpawn )
			return;
		Leaving = true;
		sinceLeaving = 0;
		leavingFrom = OutVehicle;
		orbiting = false;
		PoliceService.GarageRequestSpawn( SelectedFleet.Id, showroom.Origin, showroom.Yaw );
	}

	void Store()
	{
		PoliceService.GarageRequestStore();
		Close();
	}

	protected override int BuildHash() => HashCode.Combine(
		HashCode.Combine( IsOpen, Selected, Leaving, Spot, Plate ),
		HashCode.Combine( showroom.Active, showroom.Loaded, showroom.Beacons ),
		HashCode.Combine( Me?.PoliceGrade, Me?.OnDuty, OutVehicle?.Plate, Me?.EmsGrade, Me?.EmsOnDuty ) );
}