The vehicle lighting component. It constructs and manages all vehicle lights, including headlights, indicator flashes, and taillights, handling state synchronization and physical properties.
namespace HorizonRP;
/// <summary>
/// Les feux, posés sur les optiques du modèle (son matériau « lights » : VehicleRig.FrontLamps,
/// RearLamps), à défaut d'après la caisse :
/// <list type="bullet">
/// <item>avant : les optiques s'allument (matériau lumineux par lui-même) et deux projecteurs
/// éclairent la route, codes (penchés, ~25 m) ou phares (droits, ~55 m), touche J : éteints →
/// codes → phares → éteints. Les projecteurs partent devant le pare-chocs et sans ombre : à
/// l'intérieur de la caisse, elle les masquait, et l'on ne voyait aucun phare avant ;</item>
/// <item>arrière : feux de position avec les phares et en marche arrière (rouges, pas de
/// recul blanc : choix de Julien), stop (plus forts) dès que la touche freine. Leur reflet est un faisceau tourné vers l'arrière et le sol,
/// qui s'étire et s'estompe ; une lumière ponctuelle y dessinait des disques parfaits.</item>
/// </list>
/// Tous demandent le moteur en marche. Un phare dont le coin avant est enfoncé ne s'allume
/// plus (Vehicle.Damage). Le conducteur écrit l'état ; chaque machine allume ses lumières.
/// </summary>
public sealed partial class Vehicle
{
/// <summary>0 éteints, 1 codes, 2 phares.</summary>
[Sync] public int Headlights { get; set; }
[Sync] public bool BrakeLights { get; set; }
[Sync] public bool Reversing { get; set; }
/// <summary>0 aucun, 1 gauche, 2 droite, 3 feux de détresse (flèches du clavier).</summary>
[Sync] public int Indicators { get; set; }
/// <summary>
/// Le rythme d'un clignotant, mesuré sur l'enregistrement d'un vrai relais fourni par Julien
/// (indicators.mp3, douze déclics) : 0,33 s du « tic » au « tac », 0,316 s du « tac » au « tic »
/// suivant, soit ~93 éclats par minute. Le tic allume, le tac éteint : changer ces deux
/// valeurs décalerait l'optique de son bruit.
/// </summary>
const float BlinkPeriod = 0.646f, BlinkOnTime = 0.33f;
/// <summary>Entre le déclic de la commande (indicator_start.wav) et le premier éclat.</summary>
const float BlinkFirstDelay = 0.12f;
/// <summary>
/// Ce qui clignote (0 rien, 1 gauche, 2 droite, 3 détresse) et depuis quand. Le rythme part
/// de la mise en marche, comme un vrai relais : pris sur l'heure, le premier éclat tombait
/// n'importe où dans la période et pouvait ne durer qu'une image — allumé, éteint aussitôt.
/// </summary>
int blinkMode;
RealTimeSince blinkSince;
/// <summary>La phase du clignotant de ce véhicule : allumé ou éteint en ce moment.</summary>
public bool BlinkOn
{
get
{
var mode = Indicators == 3 ? 3 : EngineOn ? Indicators : 0;
if ( mode != blinkMode )
{
blinkMode = mode;
blinkSince = -BlinkFirstDelay;
}
return blinkMode != 0 && blinkSince >= 0 && blinkSince % BlinkPeriod < BlinkOnTime;
}
}
/// <summary>Le côté gauche (0) ou droit (1) clignote-t-il en ce moment ?</summary>
public bool IndicatorLit( int side )
{
var flash = flashes > 0 && flashSince < flashes * 0.5f && flashSince % 0.5f < 0.3f;
if ( flash )
return true;
var wanted = Indicators == 3 || Indicators == 1 && side == 0 && EngineOn || Indicators == 2 && side == 1 && EngineOn;
return wanted && BlinkOn;
}
sealed class Lamp
{
public SpotLight Beam;
/// <summary>L'ampoule : une petite lumière derrière la vitre, qui allume le réflecteur.</summary>
public PointLight Bulb;
public bool Left;
}
readonly List<Lamp> headlamps = new();
readonly List<SpotLight> tailLights = new();
readonly List<PointLight> reverseLights = new();
ModelRenderer frontGlass, rearGlass;
/// <summary>Par côté (gauche, droite) : les optiques en orange, et leur lueur sur la route.</summary>
readonly ModelRenderer[][] indicatorGlass = { new ModelRenderer[2], new ModelRenderer[2] };
readonly List<PointLight>[] indicatorGlow = { new(), new() };
/// <summary>Les éclats du verrou à distance : 1 pour fermer, 2 pour ouvrir (Vehicle.PlayRemoteLock).</summary>
int flashes;
RealTimeSince flashSince = 10;
bool tickWasOn;
int indicatorsHeard;
/// <summary>
/// Découpés dans les enregistrements de Julien (outils/sons/sources) : la commande qu'on
/// enclenche, puis un tic et un tac du relais, joués chacun au changement de l'optique —
/// jamais une boucle, qui dériverait du rythme. En .wav : un mp3 ajoute un silence au début.
/// </summary>
const string IndicatorStartSound = "sounds/vehicules/indicator_start.wav";
const string IndicatorTicSound = "sounds/vehicules/indicator_tic.wav";
const string IndicatorTacSound = "sounds/vehicules/indicator_tac.wav";
static readonly Color BeamColor = new( 1f, 0.95f, 0.86f );
static readonly Color TailColor = new( 1f, 0.04f, 0.02f );
/// <summary>Les feux, à la reconstruction du modèle (ApplyModel).</summary>
void BuildLights( GameObject holder, VehicleRig rig )
{
headlamps.Clear();
tailLights.Clear();
reverseLights.Clear();
lampKinds.Clear();
var bb = rig.BodyBounds;
var halfWidth = bb.Size.y * 0.34f;
Vector3 Guess( bool front, bool left ) => new( front ? bb.Maxs.x - 4f : bb.Mins.x + 4f, bb.Center.y + (left ? halfWidth : -halfWidth), bb.Mins.z + bb.Size.z * (front ? 0.42f : 0.5f) );
indicatorGlow[0].Clear();
indicatorGlow[1].Clear();
for ( var side = 0; side < 2; side++ )
{
indicatorGlass[side][0] = Glass( holder, rig.FrontLampSides?[side], side == 0 ? "Clignotant avant gauche" : "Clignotant avant droit", "materials/horizon/clignotant.vmat" );
indicatorGlass[side][1] = Glass( holder, rig.RearLampSides?[side], side == 0 ? "Clignotant arrière gauche" : "Clignotant arrière droit", "materials/horizon/clignotant.vmat" );
// Les clignotants gardent l'aplat orange vif (clignotant.vmat, lumière propre ×6, translucide :
// l'optique se voit au travers). Les variantes « _cligno » des textures (feux_allumes.py),
// posées avec les phares réalistes le 30/10/2026, n'éclairaient qu'en ambre pâle une texture
// souvent grise : on ne voyait plus que la lueur (Julien, 31/10/2026).
foreach ( var glass in indicatorGlass[side] )
if ( glass.IsValid() )
Light( glass, "materials/horizon/clignotant.vmat" );
// Clignotant arrière deviné (VehicleRig.RearIndicatorThin) : un aplat orange plein, pas les
// variantes des textures, qui ne l'éclairaient pas (la Polo ne clignotait pas derrière).
if ( rig.RearIndicatorThin && indicatorGlass[side][1] is { } rearGlass && rearGlass.IsValid() )
{
var flat = Material.Load( "materials/horizon/clignotant_plein.vmat" );
for ( var i = 0; i < rearGlass.Materials.Count; i++ )
rearGlass.Materials.SetOverride( i, flat );
rearGlass.MaterialOverride = flat;
rearGlass.Materials.Apply();
}
}
// Les optiques allumées : un calque du modèle, montré seulement quand elles éclairent.
frontGlass = Glass( holder, rig.FrontLamps, "Optiques avant", "materials/horizon/phare_allume.vmat" );
rearGlass = Glass( holder, rig.RearLamps, "Optiques arrière", "materials/horizon/feu_position.vmat", "ar_allume", "allume" );
for ( var side = 0; side < 2; side++ )
{
var left = side == 0;
var front = rig.FrontLampCenters?[side] ?? Guess( true, left );
var rear = rig.RearLampCenters?[side] ?? Guess( false, left );
// Avant : le projecteur, devant le pare-chocs.
var lamp = Child( holder, left ? "Phare gauche" : "Phare droit", new Vector3( MathF.Max( front.x, bb.Maxs.x ) + 6f, front.y, front.z ) );
var beam = lamp.Components.Create<SpotLight>();
beam.LightColor = BeamColor;
beam.Shadows = false;
beam.Enabled = false;
// L'ampoule, dans l'optique (un peu en retrait de la vitre) : c'est le chrome du
// réflecteur qui brille, comme un vrai phare. Le calque allumé du verre seul faisait un
// aplat uni — ces modèles n'ont pas de texture qui dessine l'intérieur de leurs phares.
var bulbGo = Child( holder, left ? "Ampoule gauche" : "Ampoule droite", front - Vector3.Forward * 3f );
var bulb = bulbGo.Components.Create<PointLight>();
bulb.LightColor = BeamColor * 1.6f;
bulb.Radius = 16;
bulb.Shadows = false;
bulb.Enabled = false;
headlamps.Add( new Lamp { Beam = beam, Bulb = bulb, Left = left } );
// Arrière : un faisceau rouge vers l'arrière et le sol, court et large.
var tail = Child( holder, left ? "Feu arrière gauche" : "Feu arrière droit", new Vector3( MathF.Min( rear.x, bb.Mins.x ) - 4f, rear.y, rear.z ) );
tail.LocalRotation = Rotation.FromYaw( 180 ) * Rotation.FromPitch( 32 );
var spot = tail.Components.Create<SpotLight>();
spot.LightColor = TailColor;
spot.Shadows = false;
spot.ConeOuter = 70;
spot.ConeInner = 10;
spot.Enabled = false;
tailLights.Add( spot );
// Clignotants : une lueur orange devant et derrière, un peu vers l'extérieur.
foreach ( var (at, outward) in new[] { (new Vector3( MathF.Max( front.x, bb.Maxs.x ) + 3f, front.y, front.z ), 1f), (new Vector3( MathF.Min( rear.x, bb.Mins.x ) - 3f, rear.y, rear.z ), 1f) } )
{
var go = Child( holder, left ? "Lueur clignotant gauche" : "Lueur clignotant droit", at + Vector3.Left * (left ? 4f : -4f) * outward );
var glow = go.Components.Create<PointLight>();
// Une lueur sur la route et le pare-chocs, pas sur toute la caisse : à 1,6 et 75 pouces,
// la Clio entière virait à l'orange à chaque éclat.
glow.LightColor = new Color( 1f, 0.5f, 0.05f ) * 0.45f;
glow.Radius = 40f;
glow.Shadows = false;
glow.Enabled = false;
indicatorGlow[side].Add( glow );
}
// Recul : une petite lueur blanche, plus vers le centre.
var back = Child( holder, left ? "Recul gauche" : "Recul droit", new Vector3( MathF.Min( rear.x, bb.Mins.x ) - 4f, bb.Center.y + (rear.y - bb.Center.y) * 0.6f, rear.z - 2f ) );
var reverse = back.Components.Create<PointLight>();
reverse.LightColor = new Color( 1f, 0.97f, 0.92f ) * 0.8f;
reverse.Radius = 55f;
reverse.Shadows = false;
reverse.Enabled = false;
reverseLights.Add( reverse );
}
}
static GameObject Child( GameObject holder, string name, Vector3 at )
{
var go = new GameObject( holder, true, name );
go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
go.LocalPosition = at;
return go;
}
/// <summary>Les calques d'optiques portent cette étiquette : l'épave carbonisée (Vehicle.Fire) ne les touche pas.</summary>
public const string LampTag = "optique";
static ModelRenderer Glass( GameObject holder, Model model, string name, string fallback, params string[] kinds )
{
if ( model is null )
return null;
var go = Child( holder, name, Vector3.Zero );
go.Tags.Add( LampTag );
var renderer = go.Components.Create<ModelRenderer>();
renderer.Model = model;
renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
renderer.Enabled = false;
Light( renderer, fallback, kinds.Length > 0 ? kinds : new[] { "allume" } );
return renderer;
}
/// <summary>Ce que montre chaque calque : « allume », « stop » ou « cligno » (la variante posée).</summary>
readonly Dictionary<ModelRenderer, string> lampKinds = new();
/// <summary>
/// Allume un calque d'optiques avec la variante lumineuse de ses propres matériaux
/// (« feux_avant » → « feux_avant_allume », « _stop », « _cligno » : outils/vehicules/feux_allumes.py) :
/// réflecteur, ampoule et verre gardent leur texture et brillent. Jusqu'au 30/10/2026, un calque
/// d'une seule couleur (phare_allume, feu_position, feu_stop, clignotant) les recouvrait : un aplat
/// que Julien jugeait « cheap ». Il ne sert plus que si le modèle n'a aucune variante.
/// Les sortes sont essayées dans l'ordre, matériau par matériau : l'arrière demande d'abord
/// « ar_allume » / « ar_stop », toujours rouges — un matériau commun à l'avant et à l'arrière
/// (« feux » de la GT3) allumait sinon son feu arrière en blanc.
/// </summary>
static void Light( ModelRenderer renderer, string fallback, params string[] kinds )
{
var any = false;
for ( var i = 0; i < renderer.Materials.Count; i++ )
{
var original = renderer.Materials.GetOriginal( i )?.ResourcePath;
if ( string.IsNullOrEmpty( original ) || !original.EndsWith( ".vmat" ) )
continue;
foreach ( var kind in kinds )
{
var variant = original[..^".vmat".Length] + "_" + kind + ".vmat";
if ( !FileSystem.Mounted.FileExists( variant + "_c" ) && !FileSystem.Mounted.FileExists( variant ) )
continue;
renderer.Materials.SetOverride( i, Material.Load( variant ) );
any = true;
break;
}
}
renderer.MaterialOverride = any ? null : Material.Load( fallback );
renderer.Materials.Apply();
}
/// <summary>Change la variante d'un calque seulement quand elle change (pas à chaque image).</summary>
void LightAs( ModelRenderer renderer, string fallback, params string[] kinds )
{
if ( !renderer.IsValid() || lampKinds.TryGetValue( renderer, out var shown ) && shown == kinds[0] )
return;
lampKinds[renderer] = kinds[0];
Light( renderer, fallback, kinds );
}
/// <summary>Partout, à chaque image : chaque feu selon l'état écrit par le conducteur.</summary>
void UpdateLights()
{
var powered = EngineOn && Ready;
// Police, gyrophares allumés : les phares battent en alternance, gauche puis droite, en
// pleins phares (« wig-wag », Vehicle.Police) — pas la nuit phares allumés.
var wigwag = PoliceWigWag;
var high = Headlights == 2 || wigwag != 0;
var anyFront = false;
foreach ( var lamp in headlamps )
{
if ( !lamp.Beam.IsValid() )
continue;
var on = wigwag != 0
? lamp.Left == (wigwag == 1) && !HeadlightBroken( lamp.Left )
: powered && Headlights > 0 && !HeadlightBroken( lamp.Left );
anyFront |= on;
lamp.Beam.Enabled = on;
if ( lamp.Bulb.IsValid() )
lamp.Bulb.Enabled = on;
if ( !on )
continue;
// Codes : penchés vers la route, faisceau large et court ; phares : droit devant, loin.
lamp.Beam.GameObject.LocalRotation = Rotation.FromPitch( high ? 1.5f : 7f ) * Rotation.FromYaw( lamp.Left ? 4f : -4f );
lamp.Beam.Radius = high ? 2200f : 1100f;
lamp.Beam.ConeOuter = high ? 30f : 44f;
lamp.Beam.ConeInner = high ? 10f : 16f;
lamp.Beam.LightColor = BeamColor * (high ? 3.2f : 2.2f);
}
// Le calque couvre les deux optiques d'un coup : pendant le wig-wag, ce sont l'ampoule et le
// faisceau de chaque côté qui battent, le calque resterait allumé des deux côtés.
if ( frontGlass.IsValid() )
frontGlass.Enabled = anyFront && wigwag == 0;
// En marche arrière aussi, en rouge (choix de Julien : pas de feux de recul blancs).
var tailOn = powered && (Headlights > 0 || BrakeLights || Reversing);
foreach ( var tail in tailLights )
{
if ( !tail.IsValid() )
continue;
tail.Enabled = tailOn;
tail.Radius = BrakeLights ? 150f : 90f;
tail.LightColor = TailColor * (BrakeLights ? 1.6f : 0.45f);
}
if ( rearGlass.IsValid() )
{
rearGlass.Enabled = tailOn;
// Le feu stop : la variante « _stop » du même feu, plus forte que la position.
if ( BrakeLights )
LightAs( rearGlass, "materials/horizon/feu_stop.vmat", "ar_stop", "stop" );
else
LightAs( rearGlass, "materials/horizon/feu_position.vmat", "ar_allume", "allume" );
}
foreach ( var reverse in reverseLights )
{
if ( reverse.IsValid() )
reverse.Enabled = false;
}
// Clignotants, détresse et éclats du verrou : les optiques de chaque côté en orange.
if ( flashes > 0 && flashSince >= flashes * 0.5f )
flashes = 0;
var anyLit = false;
for ( var side = 0; side < 2; side++ )
{
var lit = Ready && IndicatorLit( side );
anyLit |= lit;
foreach ( var glass in indicatorGlass[side] )
if ( glass.IsValid() )
glass.Enabled = lit;
foreach ( var glow in indicatorGlow[side] )
if ( glow.IsValid() )
glow.Enabled = lit;
}
// Le tic-tac du relais (signal_turn.mp3, fourni par Julien), pour qui est à bord seulement
// et pas pour les éclats du verrou : « tic » à l'allumage, « tac » plus doux à l'extinction.
// Dans les oreilles et non au centre de la voiture : en vue de derrière, la caméra est à
// trois mètres, le moteur couvre tout et le déclic ne s'entendait pas.
// La commande d'abord (mise, changée, coupée — à la main ou par le volant), puis le relais :
// tic à l'allumage, tac à l'extinction, y compris le dernier quand on coupe en plein éclat.
var aboard = RoleplayPlayer.Local?.GameObject.GetComponentInParent<Vehicle>() == this;
if ( Indicators != indicatorsHeard )
{
if ( aboard )
Sounds.PlayFile( IndicatorStartSound, Indicators != 0 ? 0.45f : 0.35f );
indicatorsHeard = Indicators;
}
var ticking = Indicators != 0 && anyLit && flashes == 0;
if ( ticking != tickWasOn && aboard )
// Volumes baissés de moitié (Julien, 30/10/2026 : trop forts).
Sounds.PlayFile( ticking ? IndicatorTicSound : IndicatorTacSound, 0.5f );
tickWasOn = ticking;
}
/// <summary>
/// Le conducteur, aux flèches : gauche / droite allument le clignotant (re-presser l'éteint,
/// l'autre flèche passe de l'autre côté), bas les feux de détresse. Un clignotant s'éteint
/// de lui-même quand le volant, tourné du côté du clignotant, revient droit — comme le
/// cliquet d'une vraie colonne de direction : un simple changement de file suffit.
/// </summary>
void IndicatorToggles()
{
var left = Keybinds.WasPressed( "IndicatorLeft" );
var right = Keybinds.WasPressed( "IndicatorRight" );
var hazard = Keybinds.WasPressed( "Hazards" );
if ( hazard )
Indicators = Indicators == 3 ? 0 : 3;
else if ( left )
Indicators = Indicators == 1 ? 0 : 1;
else if ( right )
Indicators = Indicators == 2 ? 0 : 2;
// Repartir de zéro à chaque changement, d'où qu'il vienne (touche, arrêt automatique,
// menu) : un braquage gardé d'un clignotant précédent éteignait le suivant aussitôt.
if ( Indicators != indicatorWatched )
{
indicatorWatched = Indicators;
indicatorYaw = WorldRotation.Yaw();
indicatorSteerPeak = 0;
}
if ( Indicators is 1 or 2 )
{
// Braquage et cap positifs = à gauche. Il faut un vrai virage du côté du clignotant :
// le cap tourné d'au moins 40° de ce côté, puis le volant revenu presque droit. (À 60°,
// un virage ordinaire ne l'éteignait pas ; sur le seul volant — 6° — un simple
// changement de file ou un écart l'enlevait.)
var sign = Indicators == 1 ? 1 : -1;
var toward = SteerAngleSync * sign;
indicatorSteerPeak = MathF.Max( indicatorSteerPeak, toward );
var turned = Angles.NormalizeAngle( WorldRotation.Yaw() - indicatorYaw ) * sign;
if ( turned > 40f && indicatorSteerPeak > 8f && toward < 3f )
Indicators = 0;
}
}
float indicatorYaw, indicatorSteerPeak;
int indicatorWatched;
/// <summary>Le conducteur, à la touche des phares : éteints → codes → phares → éteints.</summary>
void CycleHeadlights()
{
Headlights = (Headlights + 1) % 3;
PlayForOccupants( "sounds/vehicules/headlight_switch.mp3", 0.3f );
}
}