Vehicles/Vehicle.Lights.cs

The vehicle lighting component. It constructs and manages all vehicle lights, including headlights, indicator flashes, and taillights, handling state synchronization and physical properties.

NetworkingFile Access
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 );
	}
}