The `Vehicle` component reads player input for driving. It assigns throttle from the debug object or the X axis of `Input.AnalogMove`, and steering from the Y axis when a player is driving. It defaults to zero when neither source is active.
namespace HorizonRP;
/// <summary>
/// Un véhicule sorti du garage (ou par le staff). Créé par l'hôte (VehicleManager), qui
/// écrit son identité (propriétaire, modèle, couleur, plaque, verrou).
///
/// **Il s'adapte au modèle** : <see cref="VehicleRig"/> trouve ses roues et les en détache,
/// la physique se cale sur elles (essieux, rayon, voie), la caméra sur sa taille, la plaque
/// sur l'emplacement prévu par le modèle. Un modèle d'une autre source roule sans retouche ;
/// s'il avance selon un autre axe que +X, sa fiche le tourne (<see cref="VehicleModel.YawOffset"/>).
///
/// La conduite est une physique « à rayons » : un rayon par roue fait la suspension (ressort
/// et amortisseur), l'adhérence latérale fait virer, le moteur pousse les roues motrices, les
/// freins les retiennent, et le tout reste dans le « cercle d'adhérence » du pneu : au-delà,
/// la roue glisse — dérapage, crissement, trace sur la route. Frein à main : l'arrière décroche.
/// Elle tourne chez le PROPRIÉTAIRE RÉSEAU du véhicule, que l'hôte donne au conducteur tant
/// qu'il est au volant : sa voiture répond sans latence. Sans conducteur, l'hôte la reprend.
/// Le reste (roues qui tournent, suspension visible, traces, sons) est calculé par chaque
/// machine d'après ce qu'elle voit.
/// </summary>
[Title( "Horizon RP – Véhicule" )]
[Icon( "directions_car" )]
public sealed partial class Vehicle : Component, Component.IPressable, Component.ICollisionListener
{
// Le joueur vise la carrosserie, pas un siège : on relaie la touche E au siège libre dont
// il touche la portière. Au volant, viser sa propre voiture échoue (siège occupé), ce que
// le mode « assis » du moteur lit comme « descendre ».
// Verrouillé, E répond quand même : la poignée ne s'ouvre pas, une notification le dit.
// Le staff vise tout le véhicule (sa carte propose G) : le moteur ne « vise » un objet que
// s'il accepte E, et hors portière la voiture le refusait — ni carte ni G de loin.
// Assis dans ce véhicule, jamais : le mode « assis » du moteur lit l'échec comme
// « descendre » — accepté pour le staff, E au volant ne faisait plus descendre.
public bool CanPress( IPressable.Event e ) => e.Source?.GetComponentInParent<Vehicle>() != this
&& (SeatFor( e ) is { } seat ? Locked || !Usable || seat.CanPress( e )
: AtTrunk( e.Source?.GameObject ) || e.Source?.GetComponent<RoleplayPlayer>()?.IsStaff == true && NearBody( e.Source?.GameObject ));
public bool Press( IPressable.Event e )
{
// En feu ou en épave : ni portières ni coffre (la notification le dit).
if ( !Usable && (SeatFor( e ) is not null || AtTrunk( e.Source?.GameObject )) )
{
if ( sinceHandle > 1f )
{
sinceHandle = 0;
HudNotifications.Push( OnFire ? "Le véhicule est en feu : impossible d'y monter." : "C'est une épave carbonisée : impossible d'y monter.", "erreur" );
}
return true;
}
// Derrière la voiture : le coffre, E maintenu comme pour monter (TickEnter l'ouvre).
if ( SeatFor( e ) is null && AtTrunk( e.Source?.GameObject ) )
{
if ( Locked )
{
HandleLocked();
return true;
}
entering = this;
enteringSeat = null;
enteringTrunk = true;
enteringEvent = e;
enteringFrom = e.Source.WorldPosition;
enteringSince = 0;
return true;
}
if ( SeatFor( e ) is not { } seat )
return false;
if ( Locked )
{
HandleLocked();
return true;
}
// On ne monte pas d'un clic : E se maintient (TickEnter).
entering = this;
enteringSeat = seat;
enteringTrunk = false;
enteringEvent = e;
enteringFrom = e.Source.WorldPosition;
enteringSince = 0;
return true;
}
/// <summary>La bulle d'un véhicule en feu ou d'une épave, à la place de celle de la portière.</summary>
public IPressable.Tooltip UnusableTooltip => OnFire
? new IPressable.Tooltip( "Véhicule en feu", "local_fire_department", "Éloignez-vous" )
: new IPressable.Tooltip( "Épave carbonisée", "car_crash", "Inutilisable" );
public IPressable.Tooltip? GetTooltip( IPressable.Event e ) => !Usable && (SeatFor( e ) is not null || AtTrunk( e.Source?.GameObject )) ? UnusableTooltip
: SeatFor( e ) is { } seat
? new IPressable.Tooltip( SeatLabel( seat ), Locked ? "lock" : "lock_open", Locked ? "Verrouillé" : "Déverrouillé" )
: AtTrunk( e.Source?.GameObject )
? new IPressable.Tooltip( "Coffre", Locked ? "lock" : "inventory_2", Locked ? "Verrouillé" : "Ouvrir le coffre" )
: null;
/// <summary>Une poignée tirée sur un véhicule verrouillé : la notification, pas plus d'une par seconde.</summary>
void HandleLocked()
{
if ( sinceHandle > 1f )
{
sinceHandle = 0;
HudNotifications.Push( "Le véhicule est verrouillé.", "erreur" );
}
}
/// <summary>« Monter conducteur », « Monter passager » : le titre de la carte (pas de nom de modèle ni de propriétaire).</summary>
public static string SeatLabel( VehicleSeat seat ) => seat.IsDriver ? "Monter conducteur" : "Monter passager";
/// <summary>Ce que la carte du HUD montre pour ce joueur, ou null s'il n'est pas contre une portière libre.</summary>
/// <summary>
/// La carte du HUD pour ce joueur : contre une portière (« door », E maintenu), au coffre
/// (« trunk », E), ailleurs rien — sauf pour le staff, qui a G sur tout véhicule visé.
/// </summary>
public (string Title, bool Locked, float Progress, string Mode)? CardFor( PlayerController player )
{
var e = new IPressable.Event( player );
if ( SeatFor( e ) is { } seat )
{
var progress = entering == this && enteringSeat == seat ? Math.Clamp( enteringSince / EnterHold, 0, 1 ) : 0f;
return (SeatLabel( seat ), Locked, progress, "door");
}
if ( AtTrunk( player.GameObject ) )
return ("Coffre", Locked, entering == this && enteringTrunk ? Math.Clamp( enteringSince / EnterHold, 0, 1 ) : 0f, "trunk");
if ( player.GetComponent<RoleplayPlayer>()?.IsStaff == true && NearBody( player.GameObject ) )
return ($"{(Info?.Name ?? "Véhicule")} · {Plate}", Locked, 0, "none");
return null;
}
/// <summary>
/// Le joueur touche-t-il la carrosserie (à plat, à <see cref="DoorReach"/> près, comme pour
/// monter) ? Le G du staff ne se propose que là : de loin, la carte s'affichait partout.
/// </summary>
public bool NearBody( GameObject who )
{
if ( !Ready || Rig is null || !who.IsValid() )
return false;
var p = WorldTransform.PointToLocal( who.WorldPosition );
var bb = Rig.BodyBounds;
var dx = MathF.Max( 0, MathF.Max( bb.Mins.x - p.x, p.x - bb.Maxs.x ) );
var dy = MathF.Max( 0, MathF.Max( bb.Mins.y - p.y, p.y - bb.Maxs.y ) );
return MathF.Sqrt( dx * dx + dy * dy ) <= DoorReach;
}
/// <summary>
/// La teinte de la peinture : la couleur choisie (« #c8102e », en sRGB comme sur l'écran)
/// ramenée en linéaire, l'espace où le moteur la multiplie à l'albédo. Prise telle quelle,
/// chaque composante était trop claire : le rouge sortait rose et le noir gris.
/// </summary>
public static Color PaintTint( string hex )
{
var c = Color.Parse( hex ?? "#ffffff" ) ?? Color.White;
static float Lin( float v ) => v <= 0.04045f ? v / 12.92f : MathF.Pow( (v + 0.055f) / 1.055f, 2.4f );
return new Color( Lin( c.r ), Lin( c.g ), Lin( c.b ), 1 );
}
/// <summary>E maintenu pour monter ou ouvrir le coffre, en secondes ; relâcher E ou bouger annule.</summary>
public const float EnterHold = 0.5f;
/// <summary>Place sous le toit pour la pose « chaise » (cheveux à 50 + 2 de marge), et « au sol ».</summary>
const float ChairHeadroom = 52f, GroundHeadroom = 39f;
/// <summary>
/// Distance à plat entre les pieds du joueur et la portière (le bord de caisse au droit du
/// siège) : collé contre elle. Le rayon du joueur fait déjà 16.
/// </summary>
public const float DoorReach = 36f;
// La montée en cours chez ce client (un seul joueur local : statique).
static Vehicle entering;
static VehicleSeat enteringSeat;
/// <summary>La « montée » en cours est l'ouverture du coffre (enteringSeat est alors null).</summary>
static bool enteringTrunk;
static IPressable.Event enteringEvent;
static Vector3 enteringFrom;
static RealTimeSince enteringSince;
RealTimeSince sinceHandle = 10;
/// <summary>Essais depuis l'éditeur : E compte comme maintenu (une touche simulée ne survit pas à l'image).</summary>
public static bool DebugHoldUse { get; set; }
/// <summary>Chez celui qui monte : la barre avance tant que E est tenu, sans bouger ; au bout, il s'assied.</summary>
void TickEnter()
{
var source = enteringEvent.Source;
var cancel = !(DebugHoldUse || Input.Down( "use" )) || !source.IsValid() || UiLock.IsLocked || Locked
|| Input.AnalogMove.Length > 0.1f || source.WorldPosition.WithZ( 0 ).Distance( enteringFrom.WithZ( 0 ) ) > 6
|| (enteringTrunk
? SeatFor( enteringEvent ) is not null || !AtTrunk( source.GameObject )
: !enteringSeat.IsValid() || enteringSeat.IsOccupied || SeatFor( enteringEvent ) != enteringSeat);
if ( cancel )
{
entering = null;
return;
}
if ( enteringSince < EnterHold )
return;
entering = null;
if ( enteringTrunk )
{
VehicleMenu.Show( this, "coffre" );
return;
}
if ( enteringSeat.CanPress( enteringEvent ) )
enteringSeat.Press( enteringEvent );
}
/// <summary>
/// La portière d'un siège, en coordonnées du monde : le bord de la caisse au droit du siège,
/// au niveau du plancher. Recalculée à chaque fois (une valeur gardée sur le siège se
/// perdait au rechargement à chaud).
/// </summary>
public Vector3 DoorOf( VehicleSeat seat )
{
var yaw = Rotation.Identity; // déjà dans le gréement
var bb = Rig.BodyBounds;
var p = yaw.Inverse * seat.LocalPosition;
return WorldTransform.PointToWorld( yaw * new Vector3( p.x, p.y < bb.Center.y ? bb.Mins.y : bb.Maxs.y, p.z ) );
}
/// <summary>Le siège libre dont le joueur touche la portière ; null s'il n'en touche aucune.</summary>
VehicleSeat SeatFor( IPressable.Event e )
{
// Un véhicule retiré peut encore être « visé » un instant : ne rien lui demander.
if ( !IsValid || !GameObject.IsValid() || !Ready || !e.Source.IsValid() )
return null;
// Déjà assis dans ce véhicule : rien à proposer, et le mode « assis » du moteur lit
// l'échec comme « descendre ». Sans ça, E au volant faisait passer sur le siège passager.
if ( e.Source.GetComponentInParent<Vehicle>() == this )
return null;
var from = e.Source.WorldPosition.WithZ( 0 );
return Seats.Where( x => !x.IsOccupied )
.Select( x => (Seat: x, Distance: DoorOf( x ).WithZ( 0 ).Distance( from )) )
.Where( x => x.Distance <= DoorReach )
.OrderBy( x => x.Distance )
.Select( x => x.Seat )
.FirstOrDefault();
}
[Sync( SyncFlags.FromHost )] public long VehicleId { get; set; }
[Sync( SyncFlags.FromHost )] public ulong OwnerId { get; set; }
[Sync( SyncFlags.FromHost )] public string OwnerName { get; set; }
[Sync( SyncFlags.FromHost )] public string CatalogId { get; set; }
[Sync( SyncFlags.FromHost )] public string ColorHex { get; set; }
[Sync( SyncFlags.FromHost )] public string Plate { get; set; }
[Sync( SyncFlags.FromHost )] public bool Locked { get; set; }
// Écrits par le conducteur (propriétaire réseau), lus par tous pour l'affichage.
[Sync] public float SteerAngleSync { get; set; }
[Sync] public bool HornOn { get; set; }
[Sync] public bool Handbrake { get; set; }
/// <summary>Le moteur tourne (touche Moteur, M). Coupé, la voiture ne pousse plus et se tait.</summary>
[Sync] public bool EngineOn { get; set; }
/// <summary>
/// Le frein de parking (Espace à l'arrêt, ou en descendant) : roues bloquées, la voiture ne
/// bouge plus. Au repos sans conducteur, elle se fige même tout à fait (un joueur à pied,
/// corps de 500 kg, la poussait en courant dessus) et ne se réveille que sous un choc.
/// </summary>
[Sync] public bool ParkingBrake { get; set; } = true;
public VehicleModel Info => VehicleCatalog.Get( CatalogId );
public static IEnumerable<Vehicle> All =>
Game.ActiveScene?.GetAllComponents<Vehicle>() ?? Enumerable.Empty<Vehicle>();
/// <summary>Le véhicule où est assis le joueur local, ou null.</summary>
public static Vehicle OfLocalPlayer => RoleplayPlayer.Local?.GetComponentInParent<Vehicle>();
/// <summary>Le siège du conducteur.</summary>
public VehicleSeat Seat => Seats.FirstOrDefault( x => x.IsDriver );
public IEnumerable<VehicleSeat> Seats => IsValid && GameObject.IsValid() ? GetComponentsInChildren<VehicleSeat>( true ) : Enumerable.Empty<VehicleSeat>();
public PlayerController DriverController => Seat?.GetOccupant();
public RoleplayPlayer Driver => DriverController?.GetComponent<RoleplayPlayer>();
/// <summary>Pouces par seconde → km/h (1 unité s&box = 1 pouce).</summary>
public const float ToKmh = 0.09144f;
/// <summary>La vitesse vue par cette machine (le conducteur la calcule, les autres la mesurent).</summary>
public float SpeedKmh => velocity.Length * ToKmh;
public bool Ready { get; private set; }
public VehicleRig Rig { get; private set; }
/// <summary>Le rapport affiché au compteur : R, N, ou 1 à 6 selon la vitesse.</summary>
public string Gear { get; private set; } = "N";
/// <summary>
/// Le régime, de 0 (ralenti) à 1 (zone rouge), calculé par chaque machine d'après la vitesse :
/// six rapports également répartis jusqu'à la vitesse maxi, le régime remonte dans chacun
/// et retombe au passage du suivant. Le compte-tours et le son du moteur le suivent.
/// </summary>
public float Rpm { get; private set; }
/// <summary>Essais depuis l'éditeur : remplace les touches du conducteur (null = les touches).</summary>
public (float Throttle, float Steer, bool Handbrake)? DebugInput { get; set; }
/// <summary>Essais : une poussée verticale en plus, en multiples du poids (0 = rien).</summary>
public float DebugLift { get; set; }
public string DebugState { get; private set; }
Rigidbody body;
ModelRenderer bodyRenderer;
string appliedKey;
SoundHandle engine, horn, squeal;
// Les portières (Julien, 31/10/2026) : quelqu'un monte ou descend, chaque machine l'entend
// là où est la voiture — la portière s'ouvre, puis se referme. Déduit de qui est assis (les
// joueurs assis sont des enfants des sièges, ce rattachement passe par le réseau) : rien de plus
// ne circule. Rien les premières secondes : un joueur qui arrive voit d'un coup qui est assis.
const string CarDoorOpenSound = "sounds/vehicules/car_door_open.mp3";
const string CarDoorCloseSound = "sounds/vehicules/car_door_close.mp3";
const float CarDoorCloseDelay = 0.75f;
RealTimeSince sinceSeen = 0, sinceSeatsChecked, sinceDoorOpened;
int seatedHeard = -1;
bool doorClosePending;
void UpdateDoorSounds()
{
if ( doorClosePending && sinceDoorOpened >= CarDoorCloseDelay )
{
doorClosePending = false;
Sounds.PlayFileAt( CarDoorCloseSound, WorldPosition + Vector3.Up * 40, 0.8f );
}
if ( sinceSeatsChecked < 0.1f )
return;
sinceSeatsChecked = 0;
var seated = GameObject.GetComponentsInChildren<RoleplayPlayer>().Aggregate( 0, ( h, p ) => h ^ p.GameObject.Id.GetHashCode() );
if ( seated == seatedHeard )
return;
var heard = seatedHeard != -1 && sinceSeen > 3f;
seatedHeard = seated;
if ( !heard )
return;
Sounds.PlayFileAt( CarDoorOpenSound, WorldPosition + Vector3.Up * 40, 0.8f );
sinceDoorOpened = 0;
doorClosePending = true;
}
const string HornSound = "sounds/vehicules/klaxon.wav";
RealTimeSince squealStarted;
const string SquealSound = "sounds/vehicules/crissement_boucle.wav";
bool heardEngineOn;
RealTimeSince engineSwitchedOn = 10;
/// <summary>Le fichier que joue <see cref="engine"/> (le ralenti, celui de la fiche ou le synthétisé).</summary>
string engineFile;
RealTimeSince engineStarted;
/// <summary>La lecture précédente du moteur, qui s'efface pendant que la suivante monte.</summary>
SoundHandle engineFading;
/// <summary>Le fondu entre deux lectures du son du moteur (secondes).</summary>
const float EngineOverlap = 0.25f;
/// <summary>Essais : le fichier du moteur en cours (vide = le synthétisé).</summary>
public string EngineSoundPlaying => engine is null ? null : engineFile;
float steer;
/// <summary>La vitesse de travers (pouces/s, ~25 km/h) au-delà de laquelle un pneu glisse et marque.</summary>
const float SkidLateral = 270f;
/// <summary>La pédale d'accélérateur : elle suit la touche en ~1 s, on ne part pas à fond d'un coup.</summary>
float pedal;
RealTimeSince sinceBrakeHint = 10;
Vector3 velocity, lastPosition;
bool hasLastPosition;
// Par roue : l'objet affiché, la compression de la suspension, la rotation, la glisse.
GameObject[] wheelObjects = Array.Empty<GameObject>();
float[] compression = Array.Empty<float>();
float[] spin = Array.Empty<float>();
float[] slip = Array.Empty<float>();
readonly List<GameObject> plates = new();
protected override void OnStart()
{
SoundFile.Load( IdleSound );
SoundFile.Load( "sounds/vehicules/engine_on.mp3" );
SoundFile.Load( "sounds/vehicules/engine_off.mp3" );
SoundFile.Load( SquealSound );
SoundFile.Load( IndicatorStartSound );
SoundFile.Load( IndicatorTicSound );
SoundFile.Load( IndicatorTacSound );
SoundFile.Load( TrunkOpenSound );
SoundFile.Load( TrunkCloseSound );
body = GetOrAddComponent<Rigidbody>();
body.LinearDamping = 0.02f;
body.AngularDamping = 0.6f;
}
protected override void OnUpdate()
{
var info = Info;
// La hauteur des sièges en fait partie : sans elle, une fiche retouchée (le G 63) ne
// replaçait pas les sièges des véhicules déjà sortis.
var key = $"23|{info?.Model}|{info?.SeatForward}|{info?.SeatHeight}|{info?.YawOffset}|{info?.Scale}|{ColorHex}|{Plate}";
if ( key != appliedKey )
{
appliedKey = key;
_ = ApplyModel( info );
}
if ( entering == this )
TickEnter();
DriverToggles();
MeasureVelocity();
UpdateRpm();
if ( Ready )
{
UpdateWheelsVisual();
UpdateCamera();
UpdateDriverHands();
UpdateDents();
UpdateGlass();
UpdateHoles();
}
UpdateLights();
UpdateDoorSounds();
UpdatePolice();
UpdateFire();
UpdateSmoke();
UpdateSounds();
UpdateRadio();
UpdateSaveState();
}
void UpdateRpm()
{
var max = MathF.Max( 1, Info?.MaxSpeedKmh ?? 150 );
var ratio = MathX.Clamp( SpeedKmh / max, 0, 1.05f );
var target = EngineOn ? 0.12f : 0f;
if ( EngineOn && SpeedKmh > 3 )
{
var g = MathF.Min( 5, MathF.Floor( ratio * 6 ) );
target = 0.3f + 0.65f * MathX.Clamp( ratio * 6 - g, 0, 1 );
if ( IsProxy ? false : Gear == "R" )
target = 0.3f + 0.6f * MathX.Clamp( SpeedKmh / 30f, 0, 1 );
}
Rpm = MathX.Lerp( Rpm, target, Time.Delta * 8 );
}
void MeasureVelocity()
{
// Chez le conducteur, la vitesse du corps ; ailleurs, le déplacement mesuré (le corps
// d'un véhicule piloté par un autre ne connaît pas sa vitesse).
if ( !IsProxy && body.IsValid() )
velocity = body.Velocity;
else if ( hasLastPosition && Time.Delta > 0 )
velocity = Vector3.Lerp( velocity, (WorldPosition - lastPosition) / Time.Delta, 0.3f );
lastPosition = WorldPosition;
hasLastPosition = true;
}
// ------------------------------------------------------------------ modèle
async Task ApplyModel( VehicleModel info )
{
var source = info?.Model;
var model = await ModelSources.Load( source );
if ( !IsValid || source != Info?.Model )
return;
var rig = VehicleRig.Build( model, null, info?.Scale ?? 1f, info?.YawOffset ?? 0f );
if ( rig is null )
return;
Rig = rig;
// Le modèle tourné vers +X s'il regarde ailleurs : tout ce qui suit vit dans un enfant.
var holder = GameObject.Children.FirstOrDefault( x => x.Name == "Carrosserie" ) ?? new GameObject( GameObject, true, "Carrosserie" );
// La rotation de la fiche est déjà dans les sommets du gréement (VehicleRig.Build).
holder.LocalRotation = Rotation.Identity;
holder.NetworkMode = NetworkMode.Never;
foreach ( var old in holder.Children.ToList() )
old.Destroy();
bodyRenderer = holder.GetOrAddComponent<ModelRenderer>();
BuildBody( rig );
bodyRenderer.Tint = PaintTint( ColorHex );
// Les roues détachées, chacune dans un objet placé sur son axe.
wheelObjects = new GameObject[rig.Wheels.Length];
compression = new float[rig.Wheels.Length];
spin = new float[rig.Wheels.Length];
slip = new float[rig.Wheels.Length];
for ( var i = 0; i < rig.Wheels.Length; i++ )
{
var go = new GameObject( holder, true, $"Roue {i}" );
go.LocalPosition = rig.Wheels[i].Center;
if ( rig.WheelModels[i] is { } wheelModel )
{
var r = go.Components.Create<ModelRenderer>();
r.Model = wheelModel;
}
wheelObjects[i] = go;
}
// La plaque, aux emplacements que le modèle prévoit : notre fond (le numéro d'origine du
// modèle est imprimé dans sa texture), puis le numéro par-dessus.
plates.Clear();
foreach ( var p in rig.Plates )
{
var width = Vector3.Cross( Vector3.Up, p.Normal ).Normal;
if ( width.Length < 0.5f )
width = Vector3.Left;
var back = new GameObject( holder, true, p.Front ? "Fond plaque avant" : "Fond plaque arrière" );
// Assez loin devant la plaque du modèle : une plaque bombée (la Clio) mangeait le fond
// et le bas des caractères, à 0,12 et 0,3 pouce.
back.LocalPosition = p.Center + p.Normal * 0.6f;
back.LocalRotation = Rotation.LookAt( width, p.Normal );
back.LocalScale = new Vector3( p.Width, p.Height, 1 );
var backRenderer = back.Components.Create<ModelRenderer>();
backRenderer.Model = FlatQuad.Get( "materials/horizon/plaque.vmat" );
var go = new GameObject( holder, true, p.Front ? "Plaque avant" : "Plaque arrière" );
go.LocalPosition = p.Center + p.Normal * 0.85f - Vector3.Up * p.Height * 0.08f;
// TextRenderer s'affiche vers l'arrière de son objet : l'objet regarde donc vers la
// caisse (vers l'extérieur, le numéro se lisait en miroir).
go.LocalRotation = Rotation.LookAt( -p.Normal, Vector3.Up );
var text = go.Components.Create<TextRenderer>();
text.Text = Plate ?? "";
// Plaque d'Union City : noire, caractères jaunes, police étroite (Roboto Condensed,
// fournie par s&box) comme les caractères emboutis d'une vraie plaque.
text.FontFamily = "Roboto Condensed";
text.FontWeight = 700;
text.Color = new Color( 0.5f, 0.52f, 0.55f ); // gris discret, sans éclat
text.FontSize = 64;
// Le texte prend 80 % de la largeur de la plaque.
// Un peu plus de la moitié de la largeur : à 70 %, sept caractères débordaient.
text.Scale = p.Width * 0.5f / MathF.Max( 1, (Plate?.Length ?? 7) * 28f );
plates.Add( go );
}
BuildLights( holder, rig );
// La caisse laisse la place des roues sous elle : c'est la suspension qui la porte,
// pas sa boîte de collision qui frotterait au sol.
var axleZ = rig.Wheels.Average( w => w.Center.z );
var bb = rig.BodyBounds;
var bottom = MathF.Max( bb.Mins.z, axleZ );
var box = GetOrAddComponent<BoxCollider>();
var size = new Vector3( bb.Size.x * 0.96f, bb.Size.y * 0.94f, MathF.Max( 10, bb.Maxs.z - bottom ) );
box.Center = new Vector3( bb.Center.x, bb.Center.y, bottom + size.z / 2 );
box.Scale = size;
// La tôle : choc de balle (bullet_hit_car.mp3) et étincelles, voir Vehicle.Bullets.cs.
box.Surface = ResourceLibrary.Get<Surface>( BodySurface ) ?? box.Surface;
// Centre de gravité bas, à hauteur d'essieu : une voiture qui ne se retourne pas au premier virage.
var mass = info?.Mass ?? 1400;
body.MassOverride = mass;
body.OverrideMassCenter = true;
body.MassCenterOverride = new Vector3( rig.Bounds.Center.x, rig.Bounds.Center.y, axleZ + 2 );
// Les places : conducteur à gauche, passager à droite, puis les rangées de derrière, sur
// le plancher de la caisse (assis à hauteur d'essieu, la tête sortait du toit de la Bullet).
var bounds = rig.Bounds;
var floor = MathF.Max( rig.BodyBounds.Mins.z, axleZ - rig.Wheels.Average( w => w.Radius ) * 0.75f ) + 1f;
// Assis « sur une chaise », l'os de la tête est à 42 au-dessus du siège et les cheveux à 50.
// Sous un toit trop bas pour ça (une sportive : la 911 a 47 pouces au-dessus du plancher),
// on s'assied « au sol », jambes tendues vers l'avant — la tête à 28, les cheveux à 37 —
// plutôt que d'enfoncer le siège : la 911 mettait les pieds sous la caisse et la tête dans
// le toit. Le siège ne descend que si même cette pose ne tient pas.
var roof = rig.BodyBounds.Maxs.z;
// Quelques pouces de siège abaissé passent (la E34 : 3) ; au-delà, c'est une sportive.
var low = roof - floor < ChairHeadroom - 4f;
floor = MathF.Min( floor, roof - (low ? GroundHeadroom : ChairHeadroom) );
// Une cabine avancée (camion) est haute : on s'assied ~22 pouces sous l'appui des vitres,
// pas sur le plancher calculé d'après les roues (le joueur était dans le châssis).
var bounds0 = rig.Bounds;
if ( rig.CabinX is { } cx && MathF.Abs( cx - bounds0.Center.x ) > bounds0.Size.x * 0.25f && rig.GlassSillZ is { } sill )
floor = MathF.Max( floor, sill - 22f );
floor += info?.SeatHeight ?? 0;
foreach ( var seat in Seats )
{
var row = seat.Index / 2;
var right = seat.Index % 2 == 1;
// Une voiture : un rien derrière le milieu (essayé sur les quatre modèles). Une cabine
// avancée (camion : les vitres à plus d'un quart de la longueur du milieu) : sur elle.
// Le pare-brise tire le milieu des vitres d'une voiture vers l'avant : s'y fier mettait
// le conducteur dans le tableau de bord.
var cabin = bounds.Center.x - bounds.Size.x * 0.04f;
if ( rig.CabinX is { } glassX && MathF.Abs( glassX - bounds.Center.x ) > bounds.Size.x * 0.25f )
cabin = glassX - bounds.Size.x * 0.08f; // les vitres sont devant les sièges (pare-brise compris)
var x = cabin - bounds.Size.x * row * 0.2f + (info?.SeatForward ?? 0);
var y = bounds.Center.y + bounds.Size.y * (right ? -0.2f : 0.2f);
seat.LocalPosition = new Vector3( x, y, floor );
seat.YawRange = new Vector2( -170, 170 );
// Les passagers assis comme le conducteur (Julien) : en « Chair », ils étaient affalés.
seat.SitPose = low ? BaseChair.AnimatorSitPose.Ground : BaseChair.AnimatorSitPose.ChairForward;
// On descend à côté de sa portière, au sol (sans point de sortie, le moteur posait le
// joueur dans la caisse : coincé, et la voiture bougeait).
var eye = seat.GameObject.Children.FirstOrDefault( x => x.Name == "Yeux" ) ?? new GameObject( seat.GameObject, true, "Yeux" );
eye.LocalPosition = new Vector3( 2f, 0, low ? 17f : 31f );
seat.EyePosition = eye;
var exit = seat.GameObject.Children.FirstOrDefault( x => x.Name == "Sortie" ) ?? new GameObject( seat.GameObject, true, "Sortie" );
exit.LocalPosition = new Vector3( 0, (right ? -1 : 1) * (bounds.Size.y * 0.3f + 34f), -floor + 4f );
seat.ExitPoints = new[] { exit };
}
Ready = true;
}
// ------------------------------------------------------------------ physique (conducteur)
protected override void OnFixedUpdate()
{
if ( Networking.IsHost )
UpdateOwnership();
if ( IsProxy || !Ready || !body.IsValid() || Rig is null )
return;
Simulate();
CheckPedestrians();
}
/// <summary>Hôte : le volant va au conducteur, et revient à l'hôte quand il descend.</summary>
void UpdateOwnership()
{
var driver = DriverController;
if ( driver.IsValid() && driver.Network.Owner is { } owner )
{
if ( Network.OwnerId != owner.Id )
Network.AssignOwnership( owner );
}
else if ( !Network.IsOwner )
{
Network.TakeOwnership();
}
}
/// <summary>
/// Le sol sous une roue : une sphère de la moitié du rayon du pneu, glissée du haut de la
/// course de la suspension vers le bas, centrée pour que son point bas soit celui du pneu.
/// Un rayon ne voyait pas l'arête d'un trottoir tant que le centre de la roue ne l'avait pas
/// dépassée ; la sphère la touche dès que le pneu y arrive, et la roue monte dessus.
/// Rend la descente de la roue depuis le haut de sa course (0 = suspension écrasée),
/// ou <paramref name="travel"/> si rien n'est touché. Ni joueurs ni déclencheurs.
/// </summary>
(bool Hit, float Drop, Vector3 Point, Vector3 Normal) WheelTrace( Vector3 top, Vector3 up, float radius, float travel )
{
// La sonde part d'un rayon de roue plus haut que le haut de la course (dans le passage
// de roue) : une roue passée sous la surface — un choc, une voiture agrandie — est vue et
// repoussée, au lieu d'une sphère partie dans le sol qui ne touche plus rien.
var rs = radius * 0.5f;
var margin = radius;
var from = top - up * (radius - rs) + up * margin;
var tr = Scene.Trace.Sphere( rs, from, from - up * (travel + margin) )
.IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "player", "trigger", "ragdoll" ).Run();
if ( !tr.Hit )
return (false, travel, default, Vector3.Up);
return (true, tr.Distance - margin, tr.HitPosition, tr.Normal);
}
void Simulate()
{
var info = Info ?? VehicleCatalog.All[0];
var rig = Rig;
var driving = DriverController?.Network.IsOwner == true;
var debug = DebugInput;
var throttle = debug?.Throttle ?? (driving ? Input.AnalogMove.x : 0);
var steerInput = debug?.Steer ?? (driving ? Input.AnalogMove.y : 0);
var occupied = driving || debug is not null;
if ( driving )
{
HornOn = Input.Down( "Horn" );
if ( ParkingBrake && EngineOn && MathF.Abs( throttle ) > 0.5f && sinceBrakeHint > 4f )
{
sinceBrakeHint = 0;
HudNotifications.Push( $"Frein de parking serré : {Keybinds.DisplayOf( "Jump" )} pour le desserrer.", "info" );
}
}
else if ( HornOn )
HornOn = false;
if ( debug is not null )
{
EngineOn = true;
ParkingBrake = false;
}
var handbrake = debug?.Handbrake ?? (driving && Input.Down( "Jump" ) && SpeedKmh >= 5);
Handbrake = handbrake;
if ( !EngineOn || ParkingBrake )
throttle = 0;
// La pédale suit la touche : ~1,1 s pour l'enfoncer, ~0,25 s pour la relâcher. Un
// clavier ne dose pas : sans elle, chaque appui était un plein gaz.
var pressing = MathF.Abs( throttle ) > MathF.Abs( pedal ) && (pedal == 0 || MathF.Sign( throttle ) == MathF.Sign( pedal ));
var pedalRate = (pressing ? 0.9f : 4f) * Time.Delta;
pedal += Math.Clamp( throttle - pedal, -pedalRate, pedalRate );
if ( debug is null )
throttle = pedal;
UpdateFreeze( driving || debug is not null );
if ( !body.MotionEnabled )
return;
var dt = Time.Delta;
var mass = info.Mass;
var gravity = MathF.Max( 100, Scene.PhysicsWorld.Gravity.Length );
var up = WorldRotation.Up;
var carForward = WorldRotation.Forward;
var speed = Vector3.Dot( body.Velocity, carForward );
var maxSpeed = info.MaxSpeedKmh / ToKmh;
var ratio = MathX.Clamp( MathF.Abs( speed ) / maxSpeed, 0, 1 );
// Moteur : « Acceleration » km/h gagnés par seconde EN MOYENNE de 0 à 100 (100 / valeur =
// le 0 à 100). Le démarrage est plus doux (embrayage, patinage : 70 % à l'arrêt, plein
// couple vers 25 km/h), puis le couple s'effondre vers la vitesse maxi. Freins : « BrakeG » g.
var launch = MathX.Lerp( 0.7f, 1.08f, MathX.Clamp( MathF.Abs( speed ) * ToKmh / 25f, 0, 1 ) );
var engineForce = mass * (info.Acceleration / ToKmh) * launch * (1 - ratio * ratio * 0.85f) * PowerFactor;
var brakeForce = mass * info.BrakeG * gravity;
// Direction : moins de braquage à haute vitesse, retour au centre plus rapide qu'à l'aller.
// (Plus maniable le 29/10/2026 : 42 % du braquage gardé à la vitesse maxi au lieu de 30 %,
// volant plus vif, pneus qui reprennent mieux la glisse latérale.)
var maxSteer = info.SteerAngle * MathX.Lerp( 1f, 0.42f, ratio );
var target = steerInput * maxSteer;
var rate = (MathF.Abs( target ) < MathF.Abs( steer ) ? 220f : 150f) * dt;
steer = steer + Math.Clamp( target - steer, -rate, rate );
SteerAngleSync = steer;
var wheels = rig.Wheels;
var driven = wheels.Count( w => IsDriven( w, info ) );
var perWheel = mass / wheels.Length;
var travel = info.SuspensionTravel;
var stiffness = perWheel * gravity / (travel * 0.5f) * info.Stiffness;
var damping = 2f * MathF.Sqrt( stiffness * perWheel ) * 0.38f * info.Damping;
var holderRotation = WorldRotation;
// Marche avant, arrière ou frein selon le sens de la vitesse et de la commande.
var braking = throttle < -0.1f && speed > 60 || throttle > 0.1f && speed < -60;
Gear = speed < -10 && throttle < 0 ? "R" : MathF.Abs( speed ) < 10 ? "N" : (1 + (int)MathF.Min( 5, ratio * 6 )).ToString();
// Les stops : la touche elle-même (pas la pédale, qui met une seconde à s'enfoncer), dès
// qu'elle s'oppose au mouvement — même un peu, même à petite vitesse.
var rawThrottle = debug?.Throttle ?? (driving ? Input.AnalogMove.x : 0);
BrakeLights = occupied && (rawThrottle < -0.05f && speed > 15 || rawThrottle > 0.05f && speed < -15);
Reversing = occupied && Gear == "R";
if ( DebugLift != 0 )
body.ApplyForce( Vector3.Up * mass * gravity * DebugLift );
DebugState = $"masse {body.Mass} raideur {stiffness:0} amort {damping:0} dt {dt:0.0000}";
var grounded = 0;
groundedWheels = 0;
for ( var i = 0; i < wheels.Length; i++ )
{
var w = wheels[i];
// Le haut de la course : la roue au repos est à mi-course.
var attach = WorldTransform.PointToWorld( (w.Center + Vector3.Up * travel * 0.5f) );
var tr = WheelTrace( attach, up, RadiusOf( i ), travel ); // crevé : la roue s'affaisse
if ( !tr.Hit )
{
compression[i] = 0;
slip[i] = 0;
continue;
}
grounded++;
groundedWheels++;
// Suspension : ressort + amortisseur, poussant le long de l'axe de la caisse ; au-delà
// de la course, la butée pousse cinq fois plus fort (une roue au sol ne s'y enfonce pas).
var comp = MathF.Max( 0, travel - tr.Drop );
var bump = MathF.Max( 0, comp - travel );
comp = MathF.Min( comp, travel );
var pointVelocity = body.GetVelocityAtPoint( attach );
var springForce = MathF.Max( 0, comp * stiffness + bump * stiffness * 5 - Vector3.Dot( pointVelocity, up ) * damping );
compression[i] = comp;
body.ApplyForceAt( attach, up * springForce );
// Repère de la roue (les roues avant braquent).
var wheelRotation = holderRotation * Rotation.FromYaw( w.Front ? steer : 0 );
var fwd = wheelRotation.Forward;
var right = wheelRotation.Right;
var contact = tr.Point;
var v = body.GetVelocityAtPoint( contact );
var vLong = Vector3.Dot( v, fwd );
var vLat = Vector3.Dot( v, right );
// Adhérence : de quoi annuler la glisse latérale, plafonnée par le cercle d'adhérence.
// Frein de parking : toutes les roues bloquées.
var rearLocked = handbrake && !w.Front || ParkingBrake;
var mu = info.Grip * (rearLocked && !ParkingBrake ? info.HandbrakeGrip : 1f);
// Un pneu crevé n'accroche presque plus : la voiture glisse et tire de son côté.
var flat = IsFlat( i );
if ( flat )
mu *= FlatGrip;
var maxFriction = mu * springForce;
var fy = -vLat * perWheel / dt * 0.55f;
// Ce qui pousse la roue de travers sans qu'elle bouge encore : la suspension, qui pousse
// selon la caisse penchée, et la part de poids de la roue. Un pneu le tient sans glisser
// (frottement statique) ; ne répondre qu'à la vitesse laissait une voiture à cheval sur
// le trottoir glisser doucement et retomber sur la route, deux roues en l'air.
var load = up * springForce + Vector3.Down * (perWheel * gravity);
fy -= Vector3.Dot( load, right );
// Moteur, freins, frein à main, frottement de roulement.
var fx = 0f;
if ( IsDriven( w, info ) && driven > 0 && !braking && MathF.Abs( throttle ) > 0.05f )
{
var reverseCap = throttle < 0 && speed < -(30 / ToKmh); // 30 km/h en marche arrière
var forwardCap = throttle > 0 && speed > maxSpeed;
if ( !reverseCap && !forwardCap )
fx += throttle * engineForce / driven * (throttle < 0 ? 0.55f : 1f);
}
if ( braking )
fx -= MathF.Sign( vLong ) * MathF.Min( brakeForce / wheels.Length, MathF.Abs( vLong ) * perWheel / dt );
if ( ParkingBrake )
fx = -vLong * perWheel / dt - Vector3.Dot( load, fwd ); // roues bloquées : glissement repris, pente tenue
else if ( rearLocked )
fx -= MathF.Sign( vLong ) * MathF.Min( brakeForce * 0.6f / wheels.Length, MathF.Abs( vLong ) * perWheel / dt );
if ( MathF.Abs( throttle ) < 0.05f && !braking )
fx -= vLong * perWheel * (occupied ? 0.25f : 1.5f); // frein moteur ; sans conducteur, la voiture s'arrête
if ( flat )
fx -= vLong * perWheel * 0.9f; // la jante roule sur le pneu à plat : ça freine
// Cercle d'adhérence : au-delà, la roue glisse.
var total = MathF.Sqrt( fx * fx + fy * fy );
var sliding = 0f;
if ( total > maxFriction && total > 0 )
{
sliding = 1 - maxFriction / total;
fx *= maxFriction / total;
fy *= maxFriction / total;
}
// Une vraie glisse seulement : ~25 km/h de travers, ou le pneu bien au-delà de son
// adhérence. (À 8 km/h de travers, le moindre virage marquait la route et crissait.)
slip[i] = MathF.Max( MathX.Clamp( (sliding - 0.3f) / 0.7f, 0, 1 ), MathX.Clamp( (MathF.Abs( vLat ) - SkidLateral) / 300, 0, 1 ) );
if ( rearLocked && MathF.Abs( vLong ) > 80 )
slip[i] = MathF.Max( slip[i], 0.6f );
body.ApplyForceAt( contact, fwd * fx + right * fy );
}
// Barres antiroulis : le côté qui s'enfonce est soutenu, l'autre retenu. (Dans l'autre
// sens, la barre poussait le côté déjà enfoncé : la voiture se couchait à l'arrêt.)
for ( var axle = 0; axle < rig.Axles; axle++ )
{
var l = Array.FindIndex( wheels, x => x.Axle == axle && x.Left );
var r = Array.FindIndex( wheels, x => x.Axle == axle && !x.Left );
if ( l < 0 || r < 0 )
continue;
var force = (compression[l] - compression[r]) * stiffness * 0.6f * info.AntiRoll;
var pl = WorldTransform.PointToWorld( wheels[l].Center );
var pr = WorldTransform.PointToWorld( wheels[r].Center );
body.ApplyForceAt( pl, up * force );
body.ApplyForceAt( pr, -up * force );
}
// Air : traînée, et en vol la caisse se remet à plat plutôt que de tourner sans fin.
body.ApplyForce( -body.Velocity * body.Velocity.Length * mass * 0.0000045f );
if ( grounded == 0 )
body.AngularVelocity *= 0.98f;
}
RealTimeSince stillSince;
/// <summary>
/// Réveille le corps figé. Le compte d'immobilité et les roues au sol repartent de zéro : ils
/// dataient d'avant le sommeil, et le véhicule se refigeait aussitôt (« remis sur ses roues »,
/// il restait en l'air).
/// </summary>
public void Wake()
{
if ( !body.IsValid() )
return;
body.MotionEnabled = true;
stillSince = 0;
groundedWheels = 0;
}
/// <summary>Les roues au sol à la dernière image physique.</summary>
int groundedWheels;
/// <summary>
/// Sans conducteur, frein serré et à l'arrêt depuis une seconde : le corps se fige (plus de
/// poussée possible à pied). Il reprend vie dès qu'un conducteur monte ou qu'un véhicule le
/// percute (<see cref="OnCollisionStart"/>).
/// </summary>
void UpdateFreeze( bool driven )
{
if ( driven )
{
Wake();
stillSince = 0;
return;
}
// Personne au volant et presque à l'arrêt : le frein de parking se serre tout seul.
if ( !ParkingBrake && body.Velocity.Length < 40 )
ParkingBrake = true;
// Jamais figé en l'air : toutes les roues au sol d'abord. Le compte commençait avant que le
// modèle soit chargé, et un véhicule apparu à 30 pouces du sol se figeait en pleine chute.
if ( !ParkingBrake || body.Velocity.Length > 8 || Rig is null || groundedWheels < Rig.Wheels.Length )
stillSince = 0;
if ( body.MotionEnabled && stillSince > 1f )
body.MotionEnabled = false;
}
/// <summary>
/// Chez le conducteur, à chaque image : moteur, phares, frein de parking. Lues ici et non
/// dans la physique : l'image fixe ne tombe pas sur chaque image, une pression y était vue
/// zéro fois (rien ne se passait) ou deux (le moteur se rallumait aussitôt), d'où « M
/// plusieurs fois ».
/// </summary>
void DriverToggles()
{
var driving = !IsProxy && Ready && DriverController?.Network.IsOwner == true;
// De nuit, les feux s'allument d'eux-mêmes quand on prend le volant (codes).
if ( driving && !wasDriving && Headlights == 0 && IsNight )
Headlights = 1;
wasDriving = driving;
if ( !driving || UiLock.IsLocked )
return;
if ( Keybinds.WasPressed( "Headlights" ) )
CycleHeadlights();
IndicatorToggles();
// Y les gyrophares, T la sirène : véhicule de police, agent en service (Vehicle.Police).
PoliceToggles();
if ( Keybinds.WasPressed( "Engine" ) )
SetEngine( !EngineOn );
// Espace : à l'arrêt il serre ou desserre le frein de parking, en roulant c'est le
// frein à main tenu (dérapage) — le même levier, le même bruit.
var jump = Keybinds.WasPressed( "Jump" );
if ( jump && SpeedKmh < 5 )
{
ParkingBrake = !ParkingBrake;
PlayForOccupants( ParkingBrake ? "sounds/vehicules/hand_brake_on.mp3" : "sounds/vehicules/hand_brake_off.mp3", 0.8f );
}
else if ( SpeedKmh >= 5 && (jump || Input.Released( "Jump" )) )
PlayForOccupants( jump ? "sounds/vehicules/hand_brake_on.mp3" : "sounds/vehicules/hand_brake_off.mp3", 0.6f );
}
bool wasDriving;
/// <summary>
/// La nuit, d'après l'heure réelle (20 h à 7 h) : la ville n'a pas encore de cycle jour/nuit.
/// À brancher sur lui quand il existera.
/// </summary>
public static bool IsNight => GameClock.IsNight;
/// <summary>Le conducteur démarre ou coupe le moteur : tout le monde l'entend, sur place.</summary>
RealTimeSince sinceEngineSwitch = 10;
void SetEngine( bool on )
{
if ( sinceEngineSwitch < 0.5f )
return;
sinceEngineSwitch = 0;
if ( on && EngineDead )
{
Sounds.PlayAt( "demarreur", WorldPosition, 0.6f );
HudNotifications.Push( "Le moteur ne démarre plus : il est trop abîmé.", "erreur" );
return;
}
EngineOn = on;
PlayEngineSwitch( on );
}
/// <summary>Partout : le démarrage ou l'arrêt du moteur (sounds/vehicules/engine_on|off.mp3, fournis par Julien).</summary>
[Rpc.Broadcast]
void PlayEngineSwitch( bool on )
{
// Ses fichiers seulement (préchargés dans OnStart) : plus de démarreur synthétisé, qui
// passait à sa place quand le fichier n'était pas encore chargé.
Sounds.PlayFileAt( on ? "sounds/vehicules/engine_on.mp3" : "sounds/vehicules/engine_off.mp3", WorldPosition, 0.85f );
}
/// <summary>Les connexions des occupants : les bruits de l'habitacle ne sont que pour eux.</summary>
IEnumerable<Connection> Occupants => Seats.Select( x => x.GetOccupant() ).Where( x => x.IsValid() )
.Select( x => x.Network.Owner ).Where( x => x is not null ).ToList();
/// <summary>Un bruit de l'habitacle (frein à main, verrou intérieur) : les occupants seuls l'entendent.</summary>
void PlayForOccupants( string path, float volume )
{
using ( Rpc.FilterInclude( Occupants ) )
PlayInside( path, volume );
}
[Rpc.Broadcast]
void PlayInside( string path, float volume ) => Sounds.PlayFileAt( path, WorldPosition, volume );
/// <summary>Hôte (touche L, assis) : le bouton de verrouillage de la portière, entendu des occupants.</summary>
public void PlayInteriorLock() => PlayForOccupants( "sounds/vehicules/car_lock_interior.wav", 0.8f );
/// <summary>
/// Hôte (clé en main) : le bip de la télécommande, de partout à la position du véhicule, et
/// les clignotants — un éclat pour verrouiller, deux pour ouvrir, comme les vraies.
/// </summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
public void PlayRemoteLock( bool locked )
{
Sounds.PlayFileAt( "sounds/vehicules/car_lock_remote.wav", WorldPosition, 0.75f );
flashes = locked ? 1 : 2;
flashSince = 0;
}
static bool IsDriven( VehicleRig.WheelInfo w, VehicleModel info ) => info.Drive switch
{
"AV" => w.Front,
"4x4" => true,
_ => !w.Front,
};
// ------------------------------------------------------------------ affichage (toutes les machines)
PlayerController handsOn;
/// <summary>
/// Le conducteur tient le volant : ses deux mains, par cinématique inverse, sur un volant
/// placé devant son siège et qui tourne avec le braquage. Rendues en descendant.
/// </summary>
/// <summary>Les joueurs dont ce code tient les mains sur un volant.</summary>
static readonly HashSet<PlayerController> steeringHands = new();
static void ReleaseHands( PlayerController player )
{
if ( player.IsValid() && player.Renderer is { } r )
{
r.ClearIk( "hand_left" );
r.ClearIk( "hand_right" );
}
steeringHands.Remove( player );
}
/// <summary>
/// À chaque image (VehicleManager) : un joueur qui n'est plus assis dans un véhicule lâche le
/// volant. Si le véhicule disparaissait sous lui (rangé, retiré), personne ne relâchait ses
/// mains : la gauche restait figée en l'air, avec ou sans clé.
/// </summary>
public static void ReleaseStrayHands()
{
foreach ( var player in steeringHands.ToList() )
{
if ( !player.IsValid() || player.GameObject.GetComponentInParent<Vehicle>() is null )
ReleaseHands( player );
}
}
void UpdateDriverHands()
{
var driver = DriverController;
if ( handsOn.IsValid() && handsOn != driver )
{
ReleaseHands( handsOn );
handsOn = null;
}
if ( !driver.IsValid() || driver.Renderer is not { } r || Seat is not { } seat )
return;
handsOn = driver;
steeringHands.Add( driver );
var steerShown = IsProxy ? SteerAngleSync : steer;
var rot = WorldRotation;
// Assis au sol (sportive basse), le volant est plus loin devant et un peu plus bas.
var ground = seat.SitPose == BaseChair.AnimatorSitPose.Ground;
var wheel = seat.WorldPosition + rot.Forward * (ground ? 22f : 17f) + rot.Up * (ground ? 19f : 21f);
var turn = -steerShown * 2.5f; // le volant tourne bien plus que les roues
// Les mains à 9 h et 3 h, à la même hauteur, puis le volant tourne avec le braquage
// (autour de l'axe de la colonne). Elles étaient à midi et 6 h : une en haut, une en bas.
var spin = Rotation.FromAxis( rot.Forward, turn );
foreach ( var (name, side) in new[] { ("hand_left", 1f), ("hand_right", -1f) } )
{
var offset = spin * (rot.Left * side * 7f + rot.Up * 1.5f);
var hand = new Transform( wheel + offset, spin * rot * Rotation.FromRoll( side * 90 ) );
r.SetIk( name, hand );
}
}
void UpdateWheelsVisual()
{
var rig = Rig;
var info = Info;
if ( rig is null || info is null || wheelObjects.Length != rig.Wheels.Length )
return;
var travel = info.SuspensionTravel;
var up = WorldRotation.Up;
var holderRotation = WorldRotation;
var steerShown = IsProxy ? SteerAngleSync : steer;
for ( var i = 0; i < rig.Wheels.Length; i++ )
{
var w = rig.Wheels[i];
var go = wheelObjects[i];
if ( !go.IsValid() )
continue;
// La roue descend jusqu'au sol, dans la limite du débattement.
var attach = WorldTransform.PointToWorld( (w.Center + Vector3.Up * travel * 0.5f) );
var tr = WheelTrace( attach, up, RadiusOf( i ), travel );
var drop = tr.Hit ? MathX.Clamp( tr.Drop, 0, travel ) : travel;
go.LocalPosition = w.Center + Vector3.Up * (travel * 0.5f - drop);
// Elle tourne avec la vitesse au sol (bloquée frein à main serré), et braque.
var fwd = (holderRotation * Rotation.FromYaw( w.Front ? steerShown : 0 )).Forward;
var vLong = Vector3.Dot( velocity, fwd );
if ( !(Handbrake && !w.Front) )
spin[i] = (spin[i] + vLong / MathF.Max( 1, w.Radius ) * Time.Delta * 57.2958f) % 360f;
go.LocalRotation = Rotation.FromYaw( w.Front ? steerShown : 0 ) * Rotation.FromPitch( spin[i] );
// Glisse vue d'ici : le conducteur la connaît, les autres la déduisent de la vitesse latérale.
var s = slip.Length > i ? slip[i] : 0;
if ( IsProxy )
{
var right = (holderRotation * Rotation.FromYaw( w.Front ? steerShown : 0 )).Right;
var vLat = Vector3.Dot( velocity, right );
s = MathX.Clamp( (MathF.Abs( vLat ) - SkidLateral) / 300, 0, 1 );
if ( Handbrake && !w.Front && velocity.Length > 80 )
s = MathF.Max( s, 0.6f );
slip[i] = s;
}
var skidding = tr.Hit && s > 0.35f && velocity.Length > 250; // ~23 km/h
VehicleSkids.Mark( this, i, skidding, tr.Point, tr.Normal, w.Width );
}
}
// ------------------------------------------------------------------ caméra (conducteur local)
bool cameraTaken;
bool savedThirdPerson;
Vector3 savedOffset;
Vector3 vehicleCameraOffset;
float vehicleCameraZoom = 1f;
float vehicleCameraZoomShown = 1f;
/// <summary>Zoom local, borné selon la taille de la voiture. La molette vers le haut rapproche.</summary>
internal void ZoomVehicleCamera( float wheel ) =>
vehicleCameraZoom = Math.Clamp( vehicleCameraZoom - wheel * 0.12f, 0.65f, 2.2f );
/// <summary>
/// À bord, conducteur comme passager, la vue passe en troisième personne, reculée selon la
/// taille du véhicule : le même écart derrière le pare-chocs pour une citadine et pour un
/// camion. Rendue en descendant.
/// </summary>
void UpdateCamera()
{
var local = RoleplayPlayer.Local;
var occupant = local?.GetComponent<PlayerController>();
var mine = occupant.IsValid() && local.GameObject.GetComponentInParent<Vehicle>() == this;
if ( mine && (!cameraTaken || vehicleCameraOffset.x <= 0) )
{
if ( !cameraTaken )
{
savedThirdPerson = occupant.ThirdPerson;
savedOffset = occupant.CameraOffset;
}
cameraTaken = true;
var b = Rig.Bounds;
occupant.ThirdPerson = true;
// Plus haut pour un véhicule haut (au-delà d'un mètre de caisse) : derrière un camion, à
// hauteur du toit du caisson, on ne voyait que lui.
// Un véhicule long (camion) : bien plus loin et un peu plus haut, pour le voir en entier.
var extra = MathF.Max( 0, b.Size.x - 230f );
vehicleCameraOffset = new Vector3( b.Size.x * 0.5f + 95f + extra * 1.1f, 0, b.Size.z * 0.42f + 12f + MathF.Max( 0, b.Size.z - 100f ) * 0.6f + extra * 0.2f );
vehicleCameraZoom = vehicleCameraZoomShown = 1;
occupant.CameraOffset = vehicleCameraOffset;
// La caméra ignore la caisse : le caisson d'un camion la ramenait contre la nuque.
GameObject.Tags.Add( "vehicule" );
occupant.CameraCollisionIgnore ??= new TagSet();
occupant.CameraCollisionIgnore.Add( "vehicule" );
}
else if ( !mine && cameraTaken )
{
cameraTaken = false;
var me = RoleplayPlayer.Local?.GetComponent<PlayerController>();
if ( me.IsValid() )
{
me.ThirdPerson = savedThirdPerson;
me.CameraOffset = savedOffset;
me.CameraCollisionIgnore?.Remove( "vehicule" );
}
}
if ( mine && occupant.ThirdPerson )
{
if ( !UiLock.IsLocked && Sandbox.UI.InputFocus.Current is null && Keybinds.Capturing is null )
{
var wheel = Input.MouseWheel.y;
if ( wheel != 0 )
{
ZoomVehicleCamera( wheel );
Input.MouseWheel = Vector2.Zero;
}
}
vehicleCameraZoomShown = MathX.Lerp( vehicleCameraZoomShown, vehicleCameraZoom, MathF.Min( 1, Time.Delta * 12 ) );
occupant.CameraOffset = new Vector3( vehicleCameraOffset.x * vehicleCameraZoomShown,
vehicleCameraOffset.y, vehicleCameraOffset.z + (vehicleCameraZoomShown - 1) * 12 );
}
}
// ------------------------------------------------------------------ sons
/// <summary>Sur toutes les machines : moteur, klaxon, crissement des pneus, bips du verrou.</summary>
void UpdateSounds()
{
var info = Info;
// Le ralenti attend la fin du démarrage (engine_on.mp3, 0,86 s) : partis ensemble, il le
// couvrait et l'on n'entendait pas le bruit de Julien.
if ( EngineOn && !heardEngineOn )
engineSwitchedOn = 0;
heardEngineOn = EngineOn;
if ( EngineOn && Ready && engineSwitchedOn > 0.8f )
{
// À l'arrêt, moteur tournant : le ralenti de Julien (car_engine.mp3, sans accélération).
// En roulant : le son de la fiche, sinon le moteur synthétisé, accordés au régime.
var idle = SpeedKmh < 4;
var file = idle ? IdleSound : string.IsNullOrEmpty( info?.EngineSound ) ? "" : info.EngineSound;
if ( engine is not null && engineFile != file )
{
engine.Stop( 0.2f );
engine = null;
}
// Un fichier ne boucle pas tout seul : la lecture suivante part EngineOverlap avant la
// fin de la précédente, et les deux se fondent l'une dans l'autre. (Relancer quand la
// lecture s'arrêtait laissait un trou : le moteur « s'arrêtait et repartait ».)
var length = DurationOf( file );
// En roulant, plus bas que le ralenti : le son qui accélère couvrait engine.mp3.
var volume = idle ? 0.55f : 0.34f;
if ( engine is null || !engine.IsPlaying || length > EngineOverlap * 2 && engineStarted > length - EngineOverlap )
{
var next = (!string.IsNullOrEmpty( file ) ? Sounds.PlayFileAt( file, WorldPosition, 0.01f ) : null) ?? Sounds.PlayAt( "moteur", WorldPosition, 0.5f );
if ( next is not null )
{
engineFading?.Stop( 0.05f );
engineFading = engine;
engine = next;
engineFile = file;
engineStarted = 0;
}
}
var fade = MathX.Clamp( engineStarted / EngineOverlap, 0, 1 );
var pitch = idle ? 1f : (0.65f + Rpm * 1.5f) * (info?.EnginePitch ?? 1f);
if ( engine is not null )
{
engine.Position = WorldPosition;
engine.Pitch = pitch;
engine.Volume = volume * MathF.Sqrt( fade );
}
if ( engineFading is not null )
{
engineFading.Position = WorldPosition;
engineFading.Pitch = pitch;
engineFading.Volume = volume * MathF.Sqrt( 1 - fade );
if ( fade >= 1 )
{
engineFading.Stop( 0 );
engineFading = null;
}
}
}
else if ( engine is not null && !EngineOn )
{
engine.Stop( 0.12f );
engineFading?.Stop( 0.12f );
engine = engineFading = null;
}
// Gyrophares allumés, H est le klaxon deux-tons de la police (Vehicle.Police) : pas les deux.
// Le klaxon est le fichier de Julien (31/10/2026, 0,7 s) : relancé tant que la touche est tenue.
if ( HornOn && Ready && !PoliceHornActive )
{
if ( horn is null || !horn.IsPlaying )
horn = Sounds.PlayFileAt( HornSound, WorldPosition, 0.85f );
if ( horn is not null )
horn.Position = WorldPosition;
}
else if ( horn is not null )
{
horn.Stop( 0.05f );
horn = null;
}
var skid = slip.Length == 0 ? 0 : slip.Max();
if ( skid > 0.35f && SpeedKmh > 23 )
{
// car_brake_tires.mp3 de Julien (0,6 s), rendu bouclable : relancé un peu avant sa fin,
// l'ancienne lecture s'efface pendant que la nouvelle part.
if ( squeal is null || !squeal.IsPlaying || squealStarted > DurationOf( SquealSound ) - 0.1f && DurationOf( SquealSound ) > 0.2f )
{
var next = Sounds.PlayFileAt( SquealSound, WorldPosition, 0.01f ) ?? Sounds.PlayAt( "crissement", WorldPosition, 0.01f );
if ( next is not null )
{
squeal?.Stop( 0.1f );
squeal = next;
squealStarted = 0;
}
}
if ( squeal is not null )
{
squeal.Position = WorldPosition;
squeal.Volume = MathX.Clamp( skid, 0, 1 ) * 0.55f;
}
}
else if ( squeal is not null )
{
squeal.Stop( 0.15f );
squeal = null;
}
}
/// <summary>
/// Hôte : fait descendre tout le monde, puis retire le véhicule. Un joueur assis est un
/// enfant de son siège : détruire le véhicule avec ses passagers les détruirait avec lui.
/// </summary>
public void Remove()
{
if ( Networking.IsHost && VehicleId > 0 )
VehicleManager.Current?.SaveState( this );
foreach ( var seat in Seats )
{
if ( seat.GetOccupant() is { } occupant && occupant.IsValid() )
seat.Eject( occupant );
}
foreach ( var player in GameObject.GetComponentsInChildren<RoleplayPlayer>( true ).ToList() )
player.GameObject.SetParent( null, true );
GameObject.Destroy();
}
// ------------------------------------------------------------------ chocs
RealTimeSince lastCrash;
/// <summary>Le nombre de chocs entendus depuis l'apparition (essais).</summary>
public int Crashes { get; private set; }
/// <summary>
/// Chez le conducteur (là où tourne la physique) : un choc assez fort est annoncé à tout le
/// monde, qui l'entend sur place, plus ou moins fort selon la vitesse d'impact.
/// </summary>
public void OnCollisionStart( Collision collision )
{
// Un véhicule figé (garé) reprend vie quand un autre le percute.
if ( !IsProxy && body.IsValid() && !body.MotionEnabled && collision.Other.GameObject?.GetComponentInParent<Vehicle>() is { } other && other != this )
Wake();
if ( IsProxy || !Ready || lastCrash < 0.3f )
return;
// Un joueur qui saute ou fonce sur la voiture n'est pas un accident.
if ( collision.Other.GameObject?.Root.GetComponent<PlayerController>() is not null )
return;
var impact = MathF.Abs( collision.Contact.NormalSpeed );
if ( impact < 120 ) // ~11 km/h
return;
lastCrash = 0;
Crashes++;
PlayCrash( collision.Contact.Point, MathX.Clamp( (impact - 120) / 900f, 0, 1 ), Random.Shared.Int( 1, CrashSounds ) );
TakeImpact( collision.Contact.Point, impact );
}
static readonly Dictionary<string, float> durations = new();
/// <summary>
/// La durée d'un fichier son, 0 tant qu'il n'est pas chargé. Lire Duration d'un son pas
/// encore chargé lève une exception : à chaque image, elle cassait tout l'Update du véhicule.
/// </summary>
static float DurationOf( string path )
{
if ( string.IsNullOrEmpty( path ) )
return 0;
if ( durations.TryGetValue( path, out var known ) )
return known;
var file = SoundFile.Load( path );
if ( file is null || !file.IsLoaded )
return 0;
try { return durations[path] = file.Duration; }
catch { return 0; }
}
/// <summary>
/// engine.mp3 de Julien, rendu bouclable par outils/sons/boucle.sh (ses 0,6 premières
/// secondes fondues dans sa fin : la fin rejoint le début sans à-coup).
/// </summary>
const string IdleSound = "sounds/vehicules/engine_ralenti.wav";
/// <summary>Les bruits d'accident fournis par Julien (sounds/vehicules/car_hit_N.mp3).</summary>
const int CrashSounds = 3;
/// <summary>
/// Partout : le bruit du choc, tiré parmi les fichiers (le même pour tous, choisi par le
/// conducteur), plus fort et plus grave selon la violence. Sans fichier, le son synthétisé.
/// </summary>
[Rpc.Broadcast]
void PlayCrash( Vector3 position, float strength, int variant )
{
var volume = 0.45f + strength * 0.55f;
var pitch = MathX.Lerp( 1.12f, 0.92f, strength );
if ( Sounds.PlayFileAt( $"sounds/vehicules/car_hit_{Math.Clamp( variant, 1, CrashSounds )}.mp3", position, volume, pitch ) is null )
Sounds.PlayAt( strength > 0.35f ? "choc" : "choc_leger", position, volume );
// À bord : l'écran tremble (un petit choc se sent quand même).
if ( RoleplayPlayer.Local?.GameObject.GetComponentInParent<Vehicle>() == this )
CameraShake.Add( 0.35f + strength * 0.65f );
}
protected override void OnDestroy()
{
StopRadio();
engine?.Stop( 0.1f );
engineFading?.Stop( 0.1f );
horn?.Stop( 0.05f );
squeal?.Stop( 0.05f );
engine = horn = squeal = null;
StopPoliceSounds();
for ( var i = 0; i < wheelObjects.Length; i++ )
VehicleSkids.Mark( this, i, false, default, default, 0 );
if ( cameraTaken )
{
var me = RoleplayPlayer.Local?.GetComponent<PlayerController>();
if ( me.IsValid() )
{
me.ThirdPerson = savedThirdPerson;
me.CameraOffset = savedOffset;
}
}
}
}