The Vehicle class manages damage, health, and structural deformation for vehicles. It tracks engine and body health, applies mesh deformation based on impact points, and handles state saving and particle effects.
namespace HorizonRP;
/// <summary>
/// Les dégâts, façon FiveM : un état du moteur et un état de la caisse (0 à 1000), et des
/// bosses dans la carrosserie.
///
/// Chaque véhicule a sa propre copie des sommets de la caisse (VehicleRig.BodyGroups) : un choc
/// y enfonce une bosse là où il a frappé, vers l'intérieur, d'autant plus profonde que l'impact
/// est fort. Les bosses sont gardées en texte (<see cref="Dents"/>), rejouées sur toutes les
/// machines et par celles qui arrivent ensuite, et enregistrées avec le véhicule.
///
/// Moteur abîmé : moins de puissance sous 400, fumée grise puis noire, il cale sous 100 et ne
/// redémarre plus. Le phare du côté enfoncé à l'avant s'éteint. Tout se répare d'un coup
/// (menu de gestion du staff).
/// </summary>
public sealed partial class Vehicle
{
public const float MaxHealth = 1000f;
/// <summary>
/// Le carburant, de 0 à 1. Rien ne le consomme encore (choix de Julien, 29/10/2026) : la
/// jauge du tableau de bord reste pleine en attendant les stations-service.
/// </summary>
[Sync] public float Fuel { get; set; } = 1f;
/// <summary>L'état du moteur et de la caisse, 0 à 1000 ; écrits là où tourne la physique (le propriétaire réseau).</summary>
[Sync] public float EngineHealth { get; set; } = MaxHealth;
[Sync] public float BodyHealth { get; set; } = MaxHealth;
/// <summary>
/// Les bosses, dans le repère de la carrosserie : « x y z dx dy dz profondeur rayon; » à la
/// suite. On n'y fait qu'ajouter, sauf réparation (vide) ou trop de bosses (les plus
/// anciennes partent) : chaque machine rejoue alors tout depuis la caisse d'origine.
/// </summary>
[Sync] public string Dents { get; set; } = "";
const int MaxDents = 48;
/// <summary>Sous ce seuil, le moteur perd de la puissance et fume ; sous MinEngine, il cale.</summary>
const float WeakEngine = 400f;
const float MinEngine = 100f;
Vertex[][] deformed;
string appliedDents = "";
/// <summary>La part de puissance que garde un moteur abîmé (1 tant qu'il est au-dessus de 400).</summary>
public float PowerFactor => EngineHealth >= WeakEngine ? 1f : MathX.Lerp( 0.35f, 1f, Math.Clamp( EngineHealth / WeakEngine, 0, 1 ) );
public bool EngineDead => EngineHealth <= MinEngine;
/// <summary>Sa propre carrosserie, copie de celle du gréement, prête à être cabossée.</summary>
void BuildBody( VehicleRig rig )
{
if ( rig.BodyGroups.Length == 0 )
{
bodyRenderer.Model = rig.Body;
deformed = null;
return;
}
deformed = rig.BodyGroups.Select( g => g.Vertices.ToArray() ).ToArray();
appliedHoles = ""; // les trous étaient sous l'ancienne caisse, détruite avec elle
appliedGlass = "";
broken.Clear();
sinceBodyBuilt = 0;
RebuildBody();
appliedDents = "";
}
int bodyVersion;
/// <summary>
/// Un modèle neuf fait des sommets déformés. Réécrire les sommets d'un modèle déjà construit
/// (SetVertexBufferData) ne changeait rien à l'écran : on le refait à chaque choc.
/// </summary>
void RebuildBody()
{
var builder = Model.Builder.WithName( $"{Rig.Source?.ResourcePath}#caisse#{GameObject.Id}#{++bodyVersion}" );
for ( var i = 0; i < deformed.Length; i++ )
{
var group = Rig.BodyGroups[i];
if ( broken.Count == 0 || GlassPieces()[i] is null )
{
builder.AddMesh( VehicleRig.MeshOf( group, deformed[i] ) );
continue;
}
// Une vitre brisée : ses triangles retirés (rien d'autre de la vitre ne reste).
var kept = new List<int>( group.Indices.Length );
for ( var t = 0; t + 2 < group.Indices.Length; t += 3 )
if ( !IsBrokenGlass( i, t / 3 ) )
kept.AddRange( new[] { group.Indices[t], group.Indices[t + 1], group.Indices[t + 2] } );
if ( kept.Count > 0 )
builder.AddMesh( VehicleRig.MeshOf( group, deformed[i], kept ) );
}
bodyRenderer.Model = builder.Create();
}
/// <summary>Partout, à chaque image : les nouvelles bosses sont enfoncées dans la caisse.</summary>
void UpdateDents()
{
if ( deformed is null || Rig is null || (Dents ?? "") == appliedDents )
return;
var dents = Dents ?? "";
if ( !dents.StartsWith( appliedDents ) )
{
// Réparé, ou des bosses anciennes retirées : on repart de la caisse neuve.
for ( var i = 0; i < deformed.Length; i++ )
Array.Copy( Rig.BodyGroups[i].Vertices, deformed[i], deformed[i].Length );
appliedDents = "";
}
foreach ( var dent in ParseDents( dents[appliedDents.Length..] ) )
Deform( dent.Position, dent.Direction, dent.Depth, dent.Radius );
RebuildBody();
appliedDents = dents;
}
/// <summary>
/// Enfonce les sommets proches du point, en cloche (plein au centre, rien au bord), puis
/// recalcule les normales de ce qui a bougé : sans elles, la lumière ne montrait pas le creux.
/// </summary>
void Deform( Vector3 at, Vector3 direction, float depth, float radius )
{
for ( var g = 0; g < deformed.Length; g++ )
{
var group = deformed[g];
var moved = new bool[group.Length];
var any = false;
for ( var v = 0; v < group.Length; v++ )
{
var d = group[v].Position.Distance( at );
if ( d >= radius )
continue;
var f = 1 - d / radius;
f = f * f * (3 - 2 * f);
// Froissée, pas emboutie : chaque point de tôle part un peu plus ou moins loin, et un
// peu de travers, d'après sa position d'origine (même résultat sur toutes les
// machines). Sans ça l'avant reculait d'un bloc et ne paraissait pas abîmé.
// Un bruit continu (plis d'environ 7 pouces) et non un tirage par sommet : sur un
// maillage fin (la 911 importée), deux sommets voisins partaient à 25 pouces d'écart
// et la tôle se déchirait en éclats au lieu de se froisser.
var o = Rig.BodyGroups[g].Vertices[v].Position;
var h1 = Crumple( o, 1 ); var h2 = Crumple( o, 2 ); var h3 = Crumple( o, 3 );
var crumple = 0.55f + 0.9f * h1;
var twist = new Vector3( h2 - 0.5f, h3 - 0.5f, Crumple( o, 4 ) - 0.5f ) * depth * 0.35f;
group[v].Position += (direction * depth * crumple + twist) * f;
moved[v] = any = true;
}
if ( !any )
continue;
var indices = Rig.BodyGroups[g].Indices;
var normals = new Dictionary<int, Vector3>();
for ( var t = 0; t + 2 < indices.Length; t += 3 )
{
int a = indices[t], b = indices[t + 1], c = indices[t + 2];
if ( !moved[a] && !moved[b] && !moved[c] )
continue;
var n = Vector3.Cross( group[b].Position - group[a].Position, group[c].Position - group[a].Position );
foreach ( var i in new[] { a, b, c } )
if ( moved[i] )
normals[i] = normals.GetValueOrDefault( i ) + n;
}
foreach ( var (i, n) in normals )
{
// Même sens que la normale d'origine (l'ordre des sommets varie d'un modèle à l'autre).
var normal = n.Normal;
if ( Vector3.Dot( normal, Rig.BodyGroups[g].Vertices[i].Normal ) < 0 )
normal = -normal;
group[i].Normal = normal;
}
}
}
/// <summary>Un nombre de 0 à 1 tiré d'une position, toujours le même pour elle (les plis de la tôle).</summary>
/// <summary>Un bruit de 0 à 1 qui varie doucement d'un point à l'autre (le même partout).</summary>
static float Crumple( Vector3 p, int salt )
{
var q = p / 7f + new Vector3( salt * 17.3f, salt * 5.1f, salt * 11.7f );
return Math.Clamp( (Sandbox.Utility.Noise.Perlin( q.x, q.y, q.z ) - 0.5f) * 2.2f + 0.5f, 0f, 1f );
}
static IEnumerable<(Vector3 Position, Vector3 Direction, float Depth, float Radius)> ParseDents( string text )
{
foreach ( var entry in text.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
{
var n = entry.Split( ' ', StringSplitOptions.RemoveEmptyEntries )
.Select( x => float.TryParse( x, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var f ) ? f : 0f ).ToArray();
if ( n.Length < 8 )
continue;
yield return (new Vector3( n[0], n[1], n[2] ), new Vector3( n[3], n[4], n[5] ).Normal, Math.Clamp( n[6], 0, 30 ), Math.Clamp( n[7], 2, 70 ));
}
}
static string F( float x ) => x.ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture );
/// <summary>
/// Là où tourne la physique, à chaque choc qui fait du bruit : bosse, caisse et moteur. Un
/// choc à 50 km/h de face enlève environ 200 au moteur et 330 à la caisse.
/// </summary>
void TakeImpact( Vector3 worldPoint, float impact )
{
if ( Rig is null || !bodyRenderer.IsValid() )
return;
var excess = impact - 120;
var bb = Rig.BodyBounds;
var local = bodyRenderer.GameObject.WorldTransform.PointToLocal( worldPoint );
// Le point de choc ramené sur la caisse, et poussé vers son centre (surtout à plat).
local = new Vector3( Math.Clamp( local.x, bb.Mins.x, bb.Maxs.x ), Math.Clamp( local.y, bb.Mins.y, bb.Maxs.y ), Math.Clamp( local.z, bb.Mins.z, bb.Maxs.z ) );
var toCenter = bb.Center - local;
var direction = toCenter.WithZ( toCenter.z * 0.3f ).Normal;
// La bosse suit la vitesse du choc : ~2 pouces à 20 km/h, ~9 à 50, ~21 à 100 km/h (un
// mur pris de face écrase l'avant), et s'élargit avec elle. (À 5 pouces sur 25 au plus, un
// mur à pleine vitesse ne laissait rien de visible.)
var depth = Math.Clamp( excess / 38f, 0.8f, 28f );
var radius = 14f + depth * 1.6f;
var dents = (Dents ?? "") + $"{F( local.x )} {F( local.y )} {F( local.z )} {F( direction.x )} {F( direction.y )} {F( direction.z )} {F( depth )} {F( radius )};";
var count = dents.Count( c => c == ';' );
if ( count > MaxDents )
dents = string.Join( ";", dents.Split( ';', StringSplitOptions.RemoveEmptyEntries ).Skip( count - MaxDents ) ) + ";";
Dents = dents;
var front = local.x > bb.Center.x + bb.Size.x * 0.25f;
BodyHealth = MathF.Max( 0, BodyHealth - excess * 0.6f );
EngineHealth = MathF.Max( 0, EngineHealth - excess * (front ? 0.45f : 0.12f) );
if ( EngineOn && EngineDead )
{
EngineOn = false;
PlayEngineSwitch( false );
if ( DriverController?.Network.IsOwner == true )
HudNotifications.Push( "Le moteur a calé : il est trop abîmé pour repartir.", "erreur" );
}
RequestSaveState();
}
/// <summary>
/// Le phare d'un côté est cassé quand l'avant de ce côté a pris assez de bosses (en
/// profondeur cumulée).
/// </summary>
bool HeadlightBroken( bool left )
{
if ( Rig is null || string.IsNullOrEmpty( Dents ) )
return false;
var bb = Rig.BodyBounds;
var total = ParseDents( Dents )
.Where( d => d.Position.x > bb.Maxs.x - bb.Size.x * 0.2f && (d.Position.y > bb.Center.y) == left )
.Sum( d => d.Depth );
return total >= 5f;
}
/// <summary>
/// Menu du staff : moteur, caisse et carrosserie comme neufs. Chez le propriétaire réseau
/// (le conducteur s'il y en a un) : c'est lui qui écrit ces valeurs.
/// </summary>
[Rpc.Owner( NetFlags.HostOnly )]
public void Repair()
{
EngineHealth = MaxHealth;
BodyHealth = MaxHealth;
Dents = "";
BulletHoles = "";
BrokenGlass = "";
FlatTyres = 0;
OnFire = false;
Wrecked = false;
RequestSaveState();
}
/// <summary>Menu du staff : remis sur ses roues, à l'arrêt, un peu au-dessus du sol (chez le propriétaire réseau).</summary>
[Rpc.Owner( NetFlags.HostOnly )]
public void Flip()
{
var rb = Components.Get<Rigidbody>();
WorldRotation = Rotation.FromYaw( WorldRotation.Yaw() );
WorldPosition += Vector3.Up * 40;
Wake();
if ( rb.IsValid() )
{
rb.Velocity = Vector3.Zero;
rb.AngularVelocity = Vector3.Zero;
}
}
/// <summary>Menu du staff : moteur coupé (chez le propriétaire réseau).</summary>
[Rpc.Owner( NetFlags.HostOnly )]
public void StaffEngineOff()
{
if ( !EngineOn )
return;
EngineOn = false;
PlayEngineSwitch( false );
}
// ------------------------------------------------------------------ fumée
ParticleEffect smoke;
/// <summary>Sous ce seuil (30 %), carrosserie ou moteur, le véhicule fume sans arrêt, moteur coupé ou non (Julien).</summary>
public const float SmokingHealth = 300f;
/// <summary>
/// Partout : un moteur sous 400 qui tourne fume au capot ; sous 30 % de carrosserie ou de moteur,
/// la fumée ne s'arrête plus. Grise, puis noire et plus épaisse à mesure qu'il lâche. En feu,
/// les flammes ont leur propre fumée ; l'épave, la sienne.
/// </summary>
void UpdateSmoke()
{
var worst = MathF.Min( EngineHealth, BodyHealth );
var want = Ready && Rig is not null && !OnFire && !Wrecked
&& (worst < SmokingHealth || EngineHealth < WeakEngine && (EngineOn || EngineDead));
if ( !want )
{
if ( smoke.IsValid() )
smoke.GameObject.Destroy();
smoke = null;
return;
}
if ( !smoke.IsValid() )
{
var bb = Rig.BodyBounds;
var go = new GameObject( bodyRenderer.GameObject, true, "Fumée moteur" );
go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
go.LocalPosition = new Vector3( bb.Maxs.x - bb.Size.x * 0.18f, bb.Center.y, bb.Maxs.z - bb.Size.z * 0.3f );
smoke = go.Components.Create<ParticleEffect>();
smoke.MaxParticles = 200;
smoke.Lifetime = 2.6f;
smoke.StartVelocity = 22f;
smoke.Damping = 0.6f;
smoke.ConstantMovement = new Vector3( 0, 0, 26 );
smoke.Space = ParticleEffect.SimulationSpace.World;
// La taille ne s'applique qu'avec ApplyShape (sans lui, des points de 5 unités).
smoke.ApplyShape = true;
smoke.Scale = 30f;
smoke.ApplyColor = true;
smoke.ApplyAlpha = true;
smoke.Alpha = 0.6f;
var emitter = go.Components.Create<ParticleSphereEmitter>();
emitter.Loop = true;
emitter.Radius = 8f;
emitter.Rate = 14f;
var sprite = go.Components.Create<ParticleSpriteRenderer>();
sprite.Sprite = Sprite.FromTexture( Texture.Load( "materials/horizon/fumee.png" ) );
sprite.Lighting = true;
sprite.Scale = 1f;
}
// Plus il est abîmé, plus elle est noire et abondante.
var t = Math.Clamp( MathF.Min( EngineHealth / WeakEngine, BodyHealth / WeakEngine ), 0, 1 );
var grey = MathX.Lerp( 0.12f, 0.75f, t );
smoke.Tint = new Color( grey, grey, grey );
if ( smoke.GameObject.Components.Get<ParticleSphereEmitter>() is { } e )
e.Rate = MathX.Lerp( 30f, 8f, t );
}
// ------------------------------------------------------------------ enregistrement
/// <summary>Ce qu'un véhicule acheté garde entre deux sorties : le coffre et ses dégâts.</summary>
public sealed class SavedState
{
public List<InventorySlot> Trunk { get; set; }
public float Engine { get; set; } = MaxHealth;
public float Body { get; set; } = MaxHealth;
public string Dents { get; set; } = "";
public string Holes { get; set; } = "";
public string Glass { get; set; } = "";
public int Flats { get; set; }
public bool Wrecked { get; set; }
public string RadioStation { get; set; } = "nrj";
public bool RadioEnabled { get; set; } = true;
}
public string StateJson() => Json.Serialize( new SavedState { Trunk = TrunkSlots, Engine = EngineHealth, Body = BodyHealth, Dents = Dents ?? "", Holes = BulletHoles ?? "", Glass = BrokenGlass ?? "", Flats = FlatTyres, Wrecked = Wrecked, RadioStation = RadioStation.Id, RadioEnabled = RadioEnabled } );
/// <summary>Hôte, à l'apparition : l'état enregistré (vide pour un véhicule neuf).</summary>
public void ApplyState( string json )
{
if ( string.IsNullOrWhiteSpace( json ) )
return;
SavedState state;
try { state = Json.Deserialize<SavedState>( json ); }
catch { return; }
if ( state is null )
return;
TrunkJson = Json.Serialize( state.Trunk ?? new List<InventorySlot>() );
EngineHealth = Math.Clamp( state.Engine, 0, MaxHealth );
BodyHealth = Math.Clamp( state.Body, 0, MaxHealth );
Dents = state.Dents ?? "";
BulletHoles = state.Holes ?? "";
BrokenGlass = state.Glass ?? "";
FlatTyres = state.Flats;
Wrecked = state.Wrecked;
RadioStationId = VehicleRadioStations.Get( state.RadioStation ).Id;
RadioEnabled = state.RadioEnabled;
}
RealTimeSince sinceStateChange;
bool stateDirty;
/// <summary>Un changement à enregistrer : l'hôte l'envoie quelques secondes plus tard (un seul envoi pour une série de chocs).</summary>
void RequestSaveState()
{
if ( VehicleId <= 0 )
return;
if ( Networking.IsHost )
{
stateDirty = true;
sinceStateChange = 0;
}
else
NotifyStateChanged();
}
[Rpc.Host]
void NotifyStateChanged()
{
stateDirty = true;
sinceStateChange = 0;
}
/// <summary>Hôte, à chaque image.</summary>
void UpdateSaveState()
{
if ( !Networking.IsHost || !stateDirty || sinceStateChange < 3f )
return;
stateDirty = false;
VehicleManager.Current?.SaveState( this );
}
}