Vehicles/Vehicle.cs

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;
			}
		}
	}
}