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