Host-side vehicle manager for HorizonRP. Loads the vehicle catalog from the backend, handles purchases, spawning, storing, locking, keys, staff actions, garage persistence and vehicle snapshots.
namespace HorizonRP;
/// <summary>
/// La concession et les garages : acheter un véhicule, le sortir, le ranger, le
/// verrouiller. Tourne sur l'hôte. Les véhicules possédés vivent en base
/// (hz_vehicles) ; un seul peut être sorti à la fois par joueur, et il rentre
/// tout seul au garage quand son propriétaire se déconnecte.
///
/// Les achats et sorties se font depuis un point « Garage » posé en F6 (touche
/// E dessus). La liste des véhicules d'un joueur lui est envoyée dans
/// RoleplayPlayer.GarageJson.
/// </summary>
[Title( "Horizon RP – Véhicules" )]
[Icon( "garage" )]
public sealed class VehicleManager : Component, Component.INetworkListener
{
public static VehicleManager Current { get; private set; }
/// <summary>Distance maximale à un garage pour acheter, sortir ou ranger.</summary>
public const float GarageReach = 400f;
/// <summary>Distance maximale entre le joueur et son véhicule pour le ranger ou le verrouiller.</summary>
public const float VehicleReach = 600f;
public const int MaxVehiclesPerPlayer = 6;
const string LocalFile = "vehicules.json";
/// <summary>Hôte : les véhicules possédés, par joueur.</summary>
readonly Dictionary<ulong, List<OwnedVehicle>> garages = new();
public sealed class OwnedVehicle
{
public long Id { get; set; }
public ulong SteamId { get; set; }
public string ModelId { get; set; }
public string Color { get; set; }
public string Plate { get; set; }
/// <summary>Le coffre et les dégâts (Vehicle.SavedState, en JSON) ; vide pour un véhicule neuf.</summary>
public string State { get; set; }
}
protected override void OnAwake() => Current = this;
// ------------------------------------------------------------------ fiches (base)
/// <summary>Les fiches lues dans la base par l'hôte, partagées avec toutes les machines.</summary>
[Sync( SyncFlags.FromHost )] public string ModelsJson { get; set; }
string parsedJson;
List<VehicleModel> parsed;
string modelsVersion;
bool loadingModels;
/// <summary>Les fiches de la base (vide tant qu'elles ne sont pas lues : VehicleCatalog prend alors celles d'origine).</summary>
public List<VehicleModel> Models
{
get
{
if ( ModelsJson != parsedJson )
{
parsedJson = ModelsJson;
try { parsed = string.IsNullOrEmpty( ModelsJson ) ? null : Json.Deserialize<List<VehicleModel>>( ModelsJson ); }
catch ( Exception e ) { Log.Warning( $"[Horizon] Fiches des véhicules illisibles : {e.Message}" ); parsed = null; }
}
return parsed;
}
}
protected override void OnUpdate()
{
if ( Scene.IsEditor )
return;
// Le menu du coffre et du staff, ajouté au HUD à l'exécution (il n'est pas dans la scène).
if ( !Application.IsDedicatedServer && !menuAdded && Scene.GetAllComponents<DoorMenu>().FirstOrDefault() is { } doors )
{
doors.GameObject.Components.GetOrCreate<VehicleMenu>();
menuAdded = true;
}
// Partout : les clés tenues en main (modèle, pose, bras tendu, clic gauche), et les mains
// de ceux qui ne sont plus au volant.
Vehicle.ReleaseStrayHands();
VehicleKeySystem.Update();
// Partout (chez chaque tireur) : les balles reportées sur la tôle des véhicules. Reposé
// à chaque image : un champ statique de SWB survit mal au rechargement à chaud.
SWB.Base.Weapon.RefineBulletTrace = Vehicle.RefineBulletTrace;
if ( !Networking.IsHost )
return;
// Une clé tenue qui a quitté les poches (jetée, donnée) n'est plus en main. Les billets
// n'y sont jamais (c'est le portefeuille) : ils restent en main.
foreach ( var player in RoleplayPlayer.All )
{
if ( !string.IsNullOrEmpty( player.HeldKey ) && !Cash.IsHeld( player ) && !player.Inventory.Any( x => x.Item == player.HeldKey ) )
player.HeldKey = null;
}
if ( !Networking.IsActive || !Backend.IsOnline )
return;
if ( modelsVersion is null && !loadingModels )
_ = LoadModels();
}
async Task LoadModels()
{
loadingModels = true;
try
{
var response = await Backend.LoadVehicleModels();
if ( response?.Models is null )
{
Log.Warning( "[Horizon] Fiches des véhicules indisponibles (API injoignable)" );
modelsVersion = "";
return;
}
ModelsJson = Json.Serialize( response.Models );
modelsVersion = response.Version ?? "";
Log.Info( $"[Horizon] {response.Models.Count} fiche(s) de véhicule" );
}
finally
{
loadingModels = false;
}
}
/// <summary>Hôte, à chaque échange avec l'API : des fiches changées ailleurs (panneau web) sont relues.</summary>
public void CheckVersion( string apiVersion )
{
if ( !string.IsNullOrEmpty( apiVersion ) && modelsVersion is not null && apiVersion != modelsVersion && !loadingModels )
_ = LoadModels();
}
/// <summary>Une plaque à la californienne (1ABC234), sans I, O ni Q : comme l'API pour les achats.</summary>
public static string RandomPlate()
{
const string letters = "ABCDEFGHJKLMNPRSTUVWXYZ";
var r = Random.Shared;
return $"{r.Int( 1, 9 )}{letters[r.Int( 0, letters.Length - 1 )]}{letters[r.Int( 0, letters.Length - 1 )]}{letters[r.Int( 0, letters.Length - 1 )]}{r.Int( 100, 999 )}";
}
/// <summary>
/// Hôte : un véhicule apparaît devant un joueur, à la demande du staff (panneau web ou menu
/// en jeu). Il est à ce joueur (il le verrouille), ouvert, avec une plaque tirée au hasard,
/// et ne va dans aucun garage : il disparaît quand le joueur se déconnecte.
/// </summary>
public (bool, string) StaffSpawn( RoleplayPlayer player, string modelId, string color, string by )
{
var model = VehicleCatalog.Get( modelId );
if ( model is null )
return (false, $"Modèle inconnu : {modelId}");
if ( string.IsNullOrEmpty( color ) || !color.StartsWith( "#" ) )
color = VehicleCatalog.Colors[0].Hex;
var forward = player.WorldRotation.Forward.WithZ( 0 ).Normal;
// Juste devant le joueur, de travers (portière face à lui) : ~3,5 m, la demi-largeur d'une
// voiture et un pas. À 260, il naissait à une quinzaine de mètres en comptant sa longueur.
var spot = player.WorldPosition + forward * 140 + Vector3.Up * 60;
var ground = Scene.Trace.Ray( spot, spot + Vector3.Down * 400 ).WithoutTags( "player", "trigger" ).Run();
var go = new GameObject( true, "Véhicule (staff)" );
go.WorldPosition = (ground.Hit ? ground.EndPosition : player.WorldPosition) + Vector3.Up * 30;
go.WorldRotation = Rotation.FromYaw( player.WorldRotation.Angles().yaw + 90 );
var plate = RandomPlate();
var vehicle = go.Components.Create<Vehicle>();
vehicle.VehicleId = 0;
vehicle.OwnerId = player.SteamId;
vehicle.OwnerName = player.RpName;
vehicle.CatalogId = model.Id;
vehicle.ColorHex = color;
vehicle.Plate = plate;
// Verrouillé, comme tout véhicule garé : la clé est donnée avec.
vehicle.Locked = true;
AddSeats( go, model );
go.NetworkSpawn();
go.Network.SetOwnerTransfer( OwnerTransfer.Fixed );
player.Notify( $"Le staff vous a donné un véhicule : {model.Name} ({plate}).", "annonce" );
VehicleKeys.Give( player, plate );
HorizonGame.Current?.AddLog( "vehicule", player.SteamId, $"Apparition par {by} : {model.Name} {plate} ({color})" );
return (true, $"{model.Name} ({plate}) devant {player.RpName}");
}
protected override void OnDestroy()
{
if ( Current == this )
Current = null;
}
// Chargement des garages
/// <summary>Appelé par HorizonGame une fois le profil du joueur chargé.</summary>
public async Task LoadGarage( RoleplayPlayer player )
{
List<OwnedVehicle> owned;
if ( Backend.IsOnline )
{
var response = await Backend.LoadVehicles( player.SteamId );
if ( response?.Vehicles is null )
return;
owned = response.Vehicles;
}
else
{
owned = LocalAll().Where( x => x.SteamId == player.SteamId ).ToList();
}
if ( !player.IsValid() )
return;
garages[player.SteamId] = owned;
Publish( player );
}
List<OwnedVehicle> LocalAll() => FileSystem.Data.ReadJsonOrDefault<List<OwnedVehicle>>( LocalFile ) ?? new();
void Publish( RoleplayPlayer player )
{
player.GarageJson = Json.Serialize( garages.GetValueOrDefault( player.SteamId ) ?? new() );
}
// Connexions
public void OnDisconnected( Connection channel )
{
ulong steamId = channel.SteamId;
foreach ( var vehicle in Vehicle.All.Where( x => x.OwnerId == steamId ).ToList() )
vehicle.Remove();
garages.Remove( steamId );
}
// Demandes des joueurs (sur l'hôte)
RoleplayPlayer Caller() =>
RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id );
/// <summary>Le garage (point posé en F6) près duquel se trouve le joueur.</summary>
static CityObject NearGarage( RoleplayPlayer player ) =>
CityObject.All
.Where( x => x.Kind == CityKinds.Garage && x.WorldPosition.Distance( player.WorldPosition ) <= GarageReach )
.OrderBy( x => x.WorldPosition.Distance( player.WorldPosition ) )
.FirstOrDefault();
[Rpc.Host]
public void RequestBuy( string modelId, string color )
{
var player = Caller();
var model = VehicleCatalog.Get( modelId );
if ( player is null || model is null || !player.ProfileLoaded )
return;
// Une fiche hors vente (la flotte de police) ne s'achète pas, même par une requête forgée.
if ( !model.Shop )
{
player.Notify( $"{model.Name} n'est pas en vente.", "erreur" );
return;
}
if ( NearGarage( player ) is null )
{
player.Notify( "Rendez-vous à une concession pour acheter.", "erreur" );
return;
}
var owned = garages.GetValueOrDefault( player.SteamId ) ?? new();
if ( owned.Count >= MaxVehiclesPerPlayer )
{
player.Notify( $"Votre garage est plein ({MaxVehiclesPerPlayer} véhicules).", "erreur" );
return;
}
if ( player.Bank < model.Price )
{
player.Notify( $"Il vous manque {Format.Money( model.Price - player.Bank )} en banque.", "erreur" );
return;
}
if ( !VehicleCatalog.Colors.Any( x => x.Hex == color ) )
color = VehicleCatalog.Colors[0].Hex;
// Payé d'abord, rendu si l'enregistrement échoue : jamais de véhicule
// gratuit, jamais d'argent perdu.
Bank.Move( player, -model.Price, "carte", $"Concession · {model.Name}", "Concession Union City" );
_ = BuyAsync( player, model, color );
}
async Task BuyAsync( RoleplayPlayer player, VehicleModel model, string color )
{
OwnedVehicle vehicle;
if ( Backend.IsOnline )
{
var response = await Backend.AddVehicle( player.SteamId, model.Id, color );
vehicle = response?.Vehicle;
}
else
{
var all = LocalAll();
vehicle = new OwnedVehicle
{
Id = all.Count == 0 ? 1 : all.Max( x => x.Id ) + 1,
SteamId = player.SteamId,
ModelId = model.Id,
Color = color,
Plate = $"HZ-{Random.Shared.Int( 100, 999 )}-RP",
};
all.Add( vehicle );
FileSystem.Data.WriteJson( LocalFile, all );
}
if ( !player.IsValid() )
return;
if ( vehicle is null )
{
Bank.Move( player, model.Price, "remboursement", $"Remboursement · {model.Name}", "Concession Union City" );
player.Notify( "Achat impossible pour le moment, vous n'avez pas été débité.", "erreur" );
return;
}
if ( !garages.TryGetValue( player.SteamId, out var list ) )
garages[player.SteamId] = list = new();
list.Add( vehicle );
Publish( player );
player.Notify( $"{model.Name} achetée ({vehicle.Plate}) : -{Format.Money( model.Price )}", "argent" );
HorizonGame.Current?.AddLog( "vehicule", player.SteamId, $"Achat {model.Name} {vehicle.Plate} pour {Format.Money( model.Price )}" );
}
[Rpc.Host]
public void RequestSpawn( long vehicleId )
{
var player = Caller();
if ( player is null )
return;
var garage = NearGarage( player );
var owned = garages.GetValueOrDefault( player.SteamId )?.FirstOrDefault( x => x.Id == vehicleId );
if ( garage is null || owned is null )
{
player.Notify( "Sortez vos véhicules depuis un garage.", "erreur" );
return;
}
// Un seul véhicule dehors : sortir le second range le premier.
foreach ( var old in Vehicle.All.Where( x => x.OwnerId == player.SteamId ).ToList() )
old.Remove();
var model = VehicleCatalog.Get( owned.ModelId );
if ( model is null )
{
player.Notify( "Ce modèle n'est plus au catalogue.", "erreur" );
return;
}
// Devant le garage, dans son orientation, un peu au-dessus du sol.
var position = garage.WorldPosition + garage.WorldRotation.Forward * 220 + Vector3.Up * 24;
var go = new GameObject( true, $"Véhicule {owned.Plate}" );
go.WorldPosition = position;
go.WorldRotation = Rotation.FromYaw( garage.WorldRotation.Angles().yaw );
var vehicle = go.Components.Create<Vehicle>();
vehicle.VehicleId = owned.Id;
vehicle.OwnerId = player.SteamId;
vehicle.OwnerName = player.RpName;
vehicle.CatalogId = owned.ModelId;
vehicle.ColorHex = owned.Color;
vehicle.Plate = owned.Plate;
vehicle.Locked = true;
vehicle.ApplyState( owned.State );
AddSeats( go, model );
go.NetworkSpawn();
go.Network.SetOwnerTransfer( OwnerTransfer.Fixed );
player.Notify( $"{model.Name} ({owned.Plate}) sortie, verrouillée : clé en main, clic gauche pour l'ouvrir.", "info" );
// La clé reste avec l'objet : un joueur qui l'a donnée ou perdue en reçoit un double au garage.
VehicleKeys.Give( player, owned.Plate );
}
/// <summary>Les sièges d'un véhicule, autant que sa fiche en prévoit (le premier est celui du conducteur).</summary>
public static void AddSeats( GameObject vehicle, VehicleModel model )
{
var count = Math.Clamp( model?.Seats ?? 2, 1, 8 );
for ( var i = 0; i < count; i++ )
{
var seat = new GameObject( vehicle, true, i == 0 ? "Siège conducteur" : $"Siège passager {i}" );
var chair = seat.Components.Create<VehicleSeat>();
chair.Index = i;
// Tous assis comme le conducteur (Julien) : en « Chair », les passagers étaient affalés en arrière.
chair.SitPose = BaseChair.AnimatorSitPose.ChairForward;
}
}
bool menuAdded;
/// <summary>
/// Hôte : l'état d'un véhicule acheté (coffre, dégâts) est enregistré — en base, ou dans le
/// fichier local hors ligne — et gardé pour sa prochaine sortie.
/// </summary>
public void SaveState( Vehicle vehicle )
{
if ( vehicle is null || vehicle.VehicleId <= 0 )
return;
var json = vehicle.StateJson();
var owned = garages.GetValueOrDefault( vehicle.OwnerId )?.FirstOrDefault( x => x.Id == vehicle.VehicleId );
if ( owned is not null )
owned.State = json;
if ( Backend.IsOnline )
{
_ = Backend.SaveVehicleState( vehicle.VehicleId, vehicle.OwnerId, json );
return;
}
var all = LocalAll();
if ( all.FirstOrDefault( x => x.Id == vehicle.VehicleId ) is { } local )
{
local.State = json;
FileSystem.Data.WriteJson( LocalFile, all );
}
}
/// <summary>Le menu de gestion du staff (G) : une action sur un véhicule, vérifiée et journalisée.</summary>
[Rpc.Host]
public void RequestStaffAction( Guid vehicleObject, string action )
{
var player = Caller();
if ( player is null || !player.IsStaff )
return;
var vehicle = Vehicle.All.FirstOrDefault( x => x.GameObject.Id == vehicleObject );
if ( vehicle is null )
return;
var (ok, message) = StaffAction( vehicle, action, player.RpName, player );
player.Notify( message, ok ? "info" : "erreur" );
}
/// <summary>
/// Menu admin en jeu (onglet Véhicules) : une fiche réglée. L'API la vérifie et l'enregistre
/// (mêmes bornes que le panneau web), puis toutes les machines relisent les fiches.
/// </summary>
[Rpc.Host]
public void RequestSaveModel( string json )
{
var player = Caller();
if ( player is null || !player.IsStaff )
return;
VehicleSheet sheet;
try { sheet = Json.Deserialize<VehicleSheet>( json ?? "" ); }
catch { sheet = null; }
if ( sheet is null || VehicleCatalog.Get( sheet.Id ) is null )
return;
_ = SaveModel( player, sheet.ToModel() );
}
async Task SaveModel( RoleplayPlayer player, VehicleModel model )
{
if ( !Backend.IsOnline )
{
player.Notify( "Partie locale : les fiches se règlent quand le serveur parle à l'API.", "erreur" );
return;
}
var response = await Backend.SaveVehicleModel( model, $"{player.RpName} ({player.SteamId})" );
if ( response?.Ok != true )
{
player.Notify( response?.Error ?? "API injoignable : fiche non enregistrée.", "erreur" );
return;
}
player.Notify( $"{model.Name} enregistrée. Un véhicule déjà sorti garde ses anciennes valeurs jusqu'à être rangé.", "info" );
HorizonGame.Current?.AddLog( "vehicule_catalogue", player.SteamId, $"Fiche {model.Id} réglée en jeu par {player.RpName}" );
await LoadModels();
}
/// <summary>Menu admin en jeu (onglet Véhicules) : un véhicule devant soi, dans la couleur choisie.</summary>
[Rpc.Host]
public void RequestStaffSpawn( string modelId, string color )
{
var player = Caller();
if ( player is null || !player.IsStaff )
return;
// StaffSpawn donne déjà la clé.
var (ok, message) = StaffSpawn( player, modelId, color, player.RpName );
if ( !ok )
player.Notify( message, "erreur" );
}
/// <summary>Menu admin en jeu : un emplacement du coffre d'un véhicule, pris ou supprimé.</summary>
[Rpc.Host]
public void RequestStaffTrunk( Guid vehicleObject, int trunkSlot, string action )
{
var player = Caller();
if ( player is null || !player.IsStaff )
return;
var vehicle = Vehicle.All.FirstOrDefault( x => x.GameObject.Id == vehicleObject );
if ( vehicle is null )
return;
var (ok, message) = vehicle.StaffTrunk( trunkSlot, action, player.RpName, player );
player.Notify( message, ok ? "info" : "erreur" );
}
/// <summary>Hôte : la photo des véhicules sortis, pour le panneau (positions au mètre près).</summary>
public List<Backend.VehicleSnapshot> Snapshot() => Vehicle.All.Where( x => x.IsValid() ).OrderBy( x => x.Plate ).Select( v => new Backend.VehicleSnapshot
{
Plate = v.Plate,
Model = v.CatalogId,
Name = v.Info?.Name,
Color = v.ColorHex,
OwnerId = v.OwnerId,
OwnerName = v.OwnerName,
Bought = v.VehicleId > 0,
Driver = v.DriverController?.GetComponent<RoleplayPlayer>()?.RpName,
Passengers = v.Seats.Count( s => s.IsOccupied && !s.IsDriver ),
Locked = v.Locked,
EngineOn = v.EngineOn,
Engine = (int)MathF.Round( v.EngineHealth / Vehicle.MaxHealth * 100 ),
Body = (int)MathF.Round( v.BodyHealth / Vehicle.MaxHealth * 100 ),
Trunk = v.TrunkSlots.Count,
TrunkKg = MathF.Round( v.TrunkWeight, 1 ),
TrunkItems = v.TrunkSlots.OrderBy( s => s.Slot ).Select( s => new Backend.TrunkItem { Slot = s.Slot, Item = s.Item, Name = s.Definition?.Name ?? s.Item, Count = s.Count } ).ToList(),
X = (int)MathF.Round( v.WorldPosition.x / 40 ) * 40,
Y = (int)MathF.Round( v.WorldPosition.y / 40 ) * 40,
Z = (int)MathF.Round( v.WorldPosition.z / 40 ) * 40,
} ).ToList();
/// <summary>
/// Hôte : une action du staff sur un véhicule, depuis le menu G ou le panneau web (ordre
/// « vehicule_gestion »). Journalisée au nom du propriétaire.
/// </summary>
public (bool, string) StaffAction( Vehicle vehicle, string action, string by, RoleplayPlayer caller = null )
{
string done;
switch ( action )
{
case "reparer":
vehicle.Repair();
done = "réparé";
break;
case "retourner":
vehicle.Flip();
done = "remis sur ses roues";
break;
case "verrouiller":
case "ouvrir":
vehicle.Locked = action == "verrouiller";
done = vehicle.Locked ? "verrouillé" : "déverrouillé";
break;
case "moteur":
vehicle.StaffEngineOff();
done = "moteur coupé";
break;
case "cle":
if ( caller is null )
return (false, "Un double se donne depuis le jeu (menu G)");
VehicleKeys.Give( caller, vehicle.Plate );
done = "double de la clé donné";
break;
case "ranger":
vehicle.Remove();
done = "rangé";
break;
default:
return (false, $"Action inconnue : {action}");
}
HorizonGame.Current?.AddLog( "vehicule_staff", vehicle.OwnerId, $"{by} : {vehicle.Plate} {done}" );
return (true, $"{vehicle.Plate} : {done}.");
}
[Rpc.Host]
public void RequestStore()
{
var player = Caller();
if ( player is null )
return;
var vehicle = Vehicle.All.FirstOrDefault( x => x.OwnerId == player.SteamId );
if ( vehicle is null )
{
player.Notify( "Vous n'avez pas de véhicule dehors.", "erreur" );
return;
}
if ( NearGarage( player ) is null || vehicle.WorldPosition.Distance( player.WorldPosition ) > VehicleReach * 2 )
{
player.Notify( "Ramenez votre véhicule près d'un garage pour le ranger.", "erreur" );
return;
}
vehicle.Remove();
player.Notify( "Véhicule rangé.", "info" );
}
/// <summary>
/// L, assis dans un véhicule : le bouton de la portière, sans clé, que le conducteur
/// comme un passager peut presser. Seuls les occupants entendent le verrou. Dehors, il
/// faut la clé en main (<see cref="RequestRemoteLock"/>).
/// </summary>
[Rpc.Host]
public void RequestToggleLock()
{
var player = Caller();
if ( player is null )
return;
var vehicle = player.GameObject.GetComponentInParent<Vehicle>()
?? Vehicle.All.FirstOrDefault( v => v.Seats.Any( s => s.GetOccupant()?.GameObject == player.GameObject ) );
if ( vehicle is null )
{
player.Notify( "Prenez la clé du véhicule en main pour le verrouiller à distance (clic gauche).", "info" );
return;
}
vehicle.Locked = !vehicle.Locked;
vehicle.PlayInteriorLock();
}
/// <summary>
/// Clic gauche, la clé en main : verrouille ou déverrouille le véhicule de sa plaque, à
/// portée de télécommande. Le bip et les clignotants s'entendent et se voient de partout.
/// </summary>
[Rpc.Host]
public void RequestRemoteLock()
{
var player = Caller();
var plate = VehicleKeys.PlateOf( player?.HeldKey );
if ( plate is null || !player.Inventory.Any( x => x.Item == player.HeldKey ) )
return;
// Le bras se tend, qu'il y ait un véhicule au bout ou non.
PlayKeyPress( player.GameObject.Id );
var vehicle = Vehicle.All.FirstOrDefault( x => x.Plate == plate );
if ( vehicle is null || vehicle.WorldPosition.Distance( player.WorldPosition ) > VehicleKeys.Range )
return;
vehicle.Locked = !vehicle.Locked;
vehicle.PlayRemoteLock( vehicle.Locked );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void PlayKeyPress( Guid playerObject ) => VehicleKeySystem.Pressed( playerObject );
}