Vehicles/Vehicle.Police.cs

Partial Vehicle class that adds police and EMS emergency equipment: synced beacon and siren state owned by the host, driver RPCs to request lights/tone, a procedural roof lightbar or grille/windshield beacons built from meshes, per-model LED overlays, a Whelen-style flash pattern with point lights, wig-wag headlights, and looping 3D siren/airhorn playback.

NetworkingFile Access
namespace HorizonRP;

/// <summary>
/// Les véhicules de police (chantier « Véhicules de police », 29/10/2026 ; règles dans
/// Police/PoliceRules.cs, garage dans Police/PoliceService.Garage.cs).
/// <list type="bullet">
/// <item><b>Gyrophares</b> (Y, <see cref="EmergencyLights"/>) : une rampe procédurale sur le toit,
/// mesurée sur la caisse du modèle (juste derrière le haut du pare-brise, centrée, à la largeur
/// du toit) — boîtier noir, six modules, rouges côté conducteur (gauche) et bleus à droite, à
/// l'américaine. Banalisée (<see cref="Lightbar"/> faux) : deux feux derrière la calandre et une
/// barre en haut du pare-brise et de la lunette arrière. Les modules allumés sont lumineux par
/// eux-mêmes (materials/horizon/gyro_*.vmat) et des lumières rouge et bleue, sans ombre,
/// éclairent la route et les façades autour, plus fort la nuit (<see cref="GameClock.Daylight"/>).
/// Motif à la Whelen / Code 3 : doubles éclats alternés gauche / droite, puis en croix
/// (extérieurs d'un côté avec l'intérieur de l'autre). Les phares battent en alternance
/// (« wig-wag », Vehicle.Lights), sauf la nuit phares allumés.</item>
/// <item><b>Sirène</b> (T, <see cref="SirenTone"/>) : aucune → wail → yelp → priorité → aucune,
/// gyrophares allumés seulement ; jouée en 3D sur chaque machine depuis l'avant du véhicule
/// (portée ~250 m), en boucle (sounds/police/*.wav, générés par outils/police/sirenes.py). Elle
/// se coupe quand le conducteur descend. <b>H</b>, gyrophares allumés : le klaxon deux-tons
/// (« airhorn ») à la place du klaxon, qui couvre la sirène tant qu'il est tenu.</item>
/// </list>
/// Tout est écrit par l'hôte (<c>SyncFlags.FromHost</c>) : le conducteur, agent en service,
/// le demande par RPC ; chaque machine allume ses lumières et joue sa sirène d'après l'état.
///
/// <b>Les secours</b> (29/10/2026) : une ambulance de l'UCFD (<see cref="IsEms"/>, garage de
/// l'hôpital) est un véhicule de service comme un autre (<see cref="IsPolice"/> vrai : flotte,
/// garage, retrait en fin de service), mais ses gyrophares sont <b>rouges et blancs</b> (le schéma
/// de couleurs par service, <see cref="RightLens"/>) et seul un secouriste en service les commande
/// (<see cref="IsResponder"/>). Mêmes motifs, mêmes sirènes. La police reste rouge et bleue.
/// </summary>
public sealed partial class Vehicle
{
	/// <summary>Un véhicule de service d'urgence (flotte de l'UCPD, ou ambulance de l'UCFD si <see cref="IsEms"/>).</summary>
	[Sync( SyncFlags.FromHost )] public bool IsPolice { get; set; }

	/// <summary>Une ambulance de l'UCFD EMS (garage de l'hôpital) : gyrophares rouges et blancs, conduite par les secours.</summary>
	[Sync( SyncFlags.FromHost )] public bool IsEms { get; set; }

	/// <summary>Ce joueur est-il l'agent (ou le secouriste pour une ambulance) en service à qui revient ce véhicule ?</summary>
	public bool IsResponder( RoleplayPlayer player ) =>
		player is not null && HasBeacons && (EmsColors ? player.IsEms : player.IsCop);

	/// <summary>
	/// Ce joueur peut-il commander gyrophares et sirène ? Il suffit d'être au volant d'un véhicule
	/// à gyrophares (Julien, 29/10/2026) : ni service ni métier exigés. Le retrait du véhicule de
	/// service à la fin du service, lui, reste lié à <see cref="IsResponder"/>.
	/// </summary>
	public bool CanUseBeacons( RoleplayPlayer player ) => player is not null && HasBeacons;

	/// <summary>
	/// Le service d'un modèle à livrée (« police », « ems ») : une fiche hors vente qui figure dans
	/// une flotte de service (PoliceRules.Fleet, EmsRules.Fleet) ; null pour tout autre modèle. Une
	/// Classe S ou un G 63 du commerce, même dans une flotte, n'en est pas un.
	/// </summary>
	public static string LiveryServiceOf( string catalogId )
	{
		if ( string.IsNullOrEmpty( catalogId ) || VehicleCatalog.Get( catalogId ) is not { Shop: false } )
			return null;
		if ( EmsRules.Fleet.Any( x => x.ModelId == catalogId ) )
			return "ems";
		return PoliceRules.Fleet.Any( x => x.ModelId == catalogId ) ? "police" : null;
	}

	/// <summary>
	/// Un modèle à livrée de service porte toujours sa rampe et ses gyrophares, d'où qu'il vienne
	/// (garage de service, apparition par le staff, véhicule d'un joueur) : demande de Julien, la
	/// Polo de police sortie par le menu admin n'avait rien sur le toit.
	/// </summary>
	public bool HasLivery => LiveryServiceOf( CatalogId ) is not null;

	/// <summary>Des gyrophares montés : véhicule de la flotte (garage) ou modèle à livrée.</summary>
	public bool HasBeacons => IsPolice || HasLivery;

	/// <summary>Le schéma des secours (rouge et blanc) : ambulance du garage, ou livrée des secours.</summary>
	public bool EmsColors => IsEms || LiveryServiceOf( CatalogId ) == "ems";

	/// <summary>Une rampe sur le toit (sinon : feux banalisés) ; une livrée a toujours la sienne.</summary>
	bool BarShown => Lightbar || HasLivery;

	/// <summary>Sa ligne de la flotte (PoliceRules.Fleet).</summary>
	[Sync( SyncFlags.FromHost )] public string FleetId { get; set; }

	/// <summary>Rampe de gyrophares sur le toit ; faux : banalisé (feux de calandre et de pare-brise).</summary>
	[Sync( SyncFlags.FromHost )] public bool Lightbar { get; set; }

	/// <summary>L'agent (SteamID) à qui ce véhicule de service a été confié, 0 sinon.</summary>
	[Sync( SyncFlags.FromHost )] public ulong ServiceOfficer { get; set; }

	/// <summary>Les gyrophares (et le wig-wag des phares).</summary>
	[Sync( SyncFlags.FromHost )] public bool EmergencyLights { get; set; }

	/// <summary>0 aucune, 1 wail, 2 yelp, 3 priorité.</summary>
	[Sync( SyncFlags.FromHost )] public int SirenTone { get; set; }

	public const int SirenNone = 0, SirenWail = 1, SirenYelp = 2, SirenPriority = 3;
	const int SirenTones = 4;

	/// <summary>Le nom d'une tonalité, pour le HUD.</summary>
	public static string SirenName( int tone ) => tone switch
	{
		SirenWail => "Wail",
		SirenYelp => "Yelp",
		// La troisième tonalité est un deux-tons hi-lo depuis les vrais enregistrements
		// (outils/police/sirenes_enregistrees.py, 29/10/2026), plus le « phaser » de synthèse.
		SirenPriority => "Hi-Lo",
		_ => "Aucune",
	};

	static string SirenFile( int tone ) => tone switch
	{
		SirenWail => "sounds/police/wail.wav",
		SirenYelp => "sounds/police/yelp.wav",
		SirenPriority => "sounds/police/priority.wav",
		_ => null,
	};

	const string AirhornSound = "sounds/police/airhorn.wav";

	/// <summary>H tenue, gyrophares allumés : le klaxon deux-tons remplace le klaxon (Vehicle.UpdateSounds).</summary>
	public bool PoliceHornActive => HasBeacons && EmergencyLights && HornOn && Ready;

	// ------------------------------------------------------------------ commandes (conducteur → hôte)

	/// <summary>Ce que le conducteur vient de demander, en attendant que l'hôte le confirme : deux pressions rapides de T ne repartent pas de la même tonalité.</summary>
	bool? wantedLights;
	int? wantedTone;
	RealTimeSince sinceWanted = 10;

	/// <summary>Pour le panneau du HUD : l'état tel que le conducteur vient de le demander (sans attendre l'hôte).</summary>
	public bool LightsShown => sinceWanted < 0.6f && wantedLights is { } l ? l : EmergencyLights;
	public int ToneShown => sinceWanted < 0.6f && wantedTone is { } t ? t : SirenTone;

	/// <summary>T maintenu au-delà de ce temps : le son se coupe, les gyrophares restent (s).</summary>
	public const float SirenHoldSeconds = 0.45f;

	bool sirenHolding, sirenHoldDone;
	RealTimeSince sirenHeld;

	/// <summary>
	/// Chez le conducteur, à chaque image (DriverToggles), façon Luxart Vehicle Control : deux
	/// modes, <b>visuel seul</b> et <b>visuel + sonore</b>.
	/// - Y : le visuel, marche / arrêt (l'arrêt coupe aussi le son).
	/// - T (relâché) : la sirène — passe en visuel + sonore (wail) si rien n'est allumé, sinon la
	///   tonalité suivante ; après la dernière (priorité), retour au visuel seul.
	/// - T maintenu : coupe le son, le visuel reste.
	/// </summary>
	void PoliceToggles()
	{
		if ( !HasBeacons || RoleplayPlayer.Local is not { } me || Driver != me || !CanUseBeacons( me ) )
		{
			sirenHolding = false;
			return;
		}
		if ( Keybinds.WasPressed( "SirenLights" ) )
		{
			var on = !LightsShown;
			wantedLights = on;
			wantedTone = on ? ToneShown : SirenNone;
			sinceWanted = 0;
			RequestEmergencyLights( on );
			PlayForOccupants( "sounds/vehicules/car_lock_interior.wav", 0.18f );
		}
		if ( Keybinds.WasPressed( "Siren" ) )
		{
			sirenHolding = true;
			sirenHoldDone = false;
			sirenHeld = 0;
		}
		if ( !sirenHolding )
			return;
		if ( Input.Down( "Siren" ) )
		{
			// Maintenu : le son se coupe une fois, sans attendre qu'on relâche.
			if ( !sirenHoldDone && sirenHeld > SirenHoldSeconds )
			{
				sirenHoldDone = true;
				if ( LightsShown && ToneShown != SirenNone )
					SetTone( SirenNone, 0.1f );
			}
			return;
		}
		sirenHolding = false;
		if ( sirenHoldDone )
			return;
		// Relâché vite : la tonalité suivante, en allumant le visuel s'il le faut.
		SetTone( LightsShown ? (ToneShown + 1) % SirenTones : SirenWail, 0.12f );
	}

	void SetTone( int tone, float click )
	{
		wantedTone = tone;
		wantedLights = true;
		sinceWanted = 0;
		RequestSirenTone( tone );
		PlayForOccupants( "sounds/vehicules/car_lock_interior.wav", click );
	}

	/// <summary>Le conducteur de ce véhicule à gyrophares, qui appelle ; null sinon.</summary>
	RoleplayPlayer PoliceDriverCalling()
	{
		var driver = Driver;
		if ( !HasBeacons || Rpc.Caller is null || driver is null || driver.Network.OwnerId != Rpc.Caller.Id || !CanUseBeacons( driver ) )
			return null;
		return driver;
	}

	/// <summary>Hôte : les gyrophares, à la demande du conducteur (Y). Les éteindre coupe la sirène.</summary>
	[Rpc.Host]
	public void RequestEmergencyLights( bool on )
	{
		if ( PoliceDriverCalling() is null )
			return;
		EmergencyLights = on;
		if ( !on )
			SirenTone = SirenNone;
	}

	/// <summary>Hôte : la tonalité de la sirène (T). Une tonalité allume le visuel s'il était éteint (visuel + sonore).</summary>
	[Rpc.Host]
	public void RequestSirenTone( int tone )
	{
		if ( PoliceDriverCalling() is null )
			return;
		tone = Math.Clamp( tone, 0, SirenTones - 1 );
		if ( tone != SirenNone )
			EmergencyLights = true;
		if ( !EmergencyLights )
			return;
		SirenTone = tone;
	}

	// ------------------------------------------------------------------ à chaque image

	RealTimeSince sinceOfficerCheck;
	RealTimeSince officerAway;
	bool officerWasAway;

	/// <summary>Partout, à chaque image (Vehicle.OnUpdate) : l'hôte veille, chaque machine affiche.</summary>
	void UpdatePolice()
	{
		if ( Networking.IsHost )
			HostPolice();
		UpdatePoliceKit();
		UpdatePoliceSounds();
	}

	/// <summary>
	/// Hôte : la sirène se tait quand le conducteur descend (comme Luxart Vehicle Control) ; un
	/// véhicule de service dont l'agent est parti ou a quitté son service depuis trois secondes
	/// est rendu au garage (PoliceService.RetireServiceVehicle).
	/// </summary>
	void HostPolice()
	{
		if ( !HasBeacons )
			return;
		if ( SirenTone != SirenNone && (!EmergencyLights || DriverController is null) )
			SirenTone = SirenNone;
		if ( ServiceOfficer == 0 || sinceOfficerCheck < 1f )
			return;
		sinceOfficerCheck = 0;
		var officer = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == ServiceOfficer );
		var away = officer is null || !IsResponder( officer );
		if ( away && !officerWasAway )
			officerAway = 0;
		officerWasAway = away;
		if ( away && officerAway > 3f )
			PoliceService.RetireServiceVehicle( this, officer is null ? "agent déconnecté" : "fin de service" );
	}

	// ------------------------------------------------------------------ la rampe

	/// <summary>Un module lumineux : son verre, sa couleur, son côté et sa place (0 extérieur, 1 milieu, 2 intérieur).</summary>
	sealed class Beacon
	{
		public ModelRenderer Lens;
		public bool Left;
		public int Slot;
		public bool Lit;
		/// <summary>Un calque posé sur une LED du modèle : montré allumé, caché éteint (la LED du modèle se voit dessous).</summary>
		public bool Overlay;
	}

	sealed class BeaconLight
	{
		public PointLight Light;
		public bool Left;
		/// <summary>Une lumière « d'ambiance » (rampe sur le toit) éclaire plus loin qu'une lueur de calandre.</summary>
		public bool Main;
	}

	readonly List<Beacon> beacons = new();
	readonly List<BeaconLight> beaconLights = new();
	GameObject policeKit;
	string policeKitKey;
	static bool policeSoundsLoaded;

	const string LensRed = "materials/horizon/gyro_rouge.vmat";
	const string LensBlue = "materials/horizon/gyro_bleu.vmat";
	const string LensRedOff = "materials/horizon/gyro_rouge_eteint.vmat";
	const string LensBlueOff = "materials/horizon/gyro_bleu_eteint.vmat";
	const string LensWhite = "materials/horizon/gyro_blanc.vmat";
	const string LensWhiteOff = "materials/horizon/gyro_blanc_eteint.vmat";
	const string HousingMaterial = "materials/horizon/gyro_boitier.vmat";

	static readonly Color BeaconRed = new( 1f, 0.05f, 0.03f );
	static readonly Color BeaconBlue = new( 0.08f, 0.25f, 1f );
	static readonly Color BeaconWhite = new( 1f, 0.93f, 0.85f );

	/// <summary>
	/// Le schéma de couleurs du service : le côté conducteur (gauche) est toujours rouge ; l'autre
	/// est bleu pour la police, blanc pour les secours.
	/// </summary>
	string RightLens( bool lit ) => EmsColors ? (lit ? LensWhite : LensWhiteOff) : (lit ? LensBlue : LensBlueOff);

	Color RightColor => EmsColors ? BeaconWhite : BeaconBlue;

	/// <summary>La rampe (ou les feux banalisés), construite une fois le modèle prêt, refaite s'il change.</summary>
	void UpdatePoliceKit()
	{
		var key = HasBeacons && Ready && Rig is not null ? $"{Rig.GetHashCode()}|{BarShown}|{EmsColors}" : null;
		if ( key != policeKitKey || key is not null && !policeKit.IsValid() )
		{
			policeKitKey = key;
			if ( policeKit.IsValid() )
				policeKit.Destroy();
			policeKit = null;
			beacons.Clear();
			beaconLights.Clear();
			if ( key is not null )
				BuildPoliceKit();
		}
		if ( beacons.Count == 0 && beaconLights.Count == 0 )
			return;

		var on = EmergencyLights && Ready;
		var daylight = GameClock.Daylight;
		foreach ( var beacon in beacons )
		{
			var lit = on && BeaconOn( beacon.Left, beacon.Slot );
			if ( lit == beacon.Lit || !beacon.Lens.IsValid() )
				continue;
			beacon.Lit = lit;
			if ( beacon.Overlay )
			{
				beacon.Lens.Enabled = lit;
				continue;
			}
			beacon.Lens.MaterialOverride = Material.Load( beacon.Left ? (lit ? LensRed : LensRedOff) : RightLens( lit ) );
		}
		foreach ( var b in beaconLights )
		{
			if ( !b.Light.IsValid() )
				continue;
			var lit = on && BeaconOn( b.Left, -1 );
			b.Light.Enabled = lit;
			if ( !lit )
				continue;
			// La nuit, les éclats peignent les façades ; de jour, une lueur sur la carrosserie et le sol.
			var strength = MathX.Lerp( b.Main ? 5.5f : 2.2f, b.Main ? 1.6f : 0.8f, daylight );
			b.Light.LightColor = (b.Left ? BeaconRed : RightColor) * strength;
			b.Light.Radius = MathX.Lerp( b.Main ? 480f : 160f, b.Main ? 260f : 90f, daylight );
		}
	}

	/// <summary>
	/// Le motif des éclats, comme une rampe Whelen / Code 3 : chaque phase dure 0,3 s et fait un
	/// double éclat (70 ms allumé, 50 ms éteint, 70 ms allumé, puis un temps noir). Huit phases
	/// (2,4 s) : gauche, droite, gauche, droite, puis en croix — les deux modules extérieurs d'un
	/// côté avec l'intérieur de l'autre, et l'inverse. Slot -1 : « ce côté éclaire-t-il ? ».
	/// </summary>
	static bool BeaconOn( bool left, int slot )
	{
		const float Phase = 0.3f;
		var t = RealTime.Now % (Phase * 8);
		var index = (int)(t / Phase);
		var u = t - index * Phase;
		var flash = u < 0.07f || u >= 0.12f && u < 0.19f;
		if ( !flash )
			return false;
		var leftPhase = index % 2 == 0;
		if ( index < 4 || slot < 0 )
			return left == leftPhase;
		// En croix : extérieurs (0, 1) du côté de la phase, intérieur (2) de l'autre.
		return slot == 2 ? left != leftPhase : left == leftPhase;
	}

	/// <summary>Pour le HUD : le côté qui éclaire en ce moment (gauche rouge, sinon droite bleue, ou blanche pour les secours).</summary>
	public bool BeaconLeftPhase => (int)(RealTime.Now % 2.4f / 0.3f) % 2 == 0;

	/// <summary>
	/// Le wig-wag des phares, gyrophares allumés : 1 le phare gauche, 2 le droit, en alternance
	/// avec la rampe ; 0 sans gyrophares, ou la nuit phares allumés (on garde alors ses phares :
	/// battre en pleine nuit éblouit et aveugle le conducteur).
	/// </summary>
	public int PoliceWigWag
	{
		get
		{
			if ( !HasBeacons || !EmergencyLights || !Ready || IsNight && Headlights > 0 )
				return 0;
			var t = RealTime.Now % 0.6f;
			return t < 0.3f ? 1 : 2;
		}
	}

	/// <summary>Le toit, mesuré sur la caisse du modèle : sa partie haute et la place de la rampe.</summary>
	sealed record RoofInfo( float FrontX, float BackX, float BarX, float Z, float Width );

	static readonly Dictionary<VehicleRig, RoofInfo> roofs = new();

	/// <summary>
	/// Le dessus du toit : sur une bande au milieu de la largeur, la hauteur la plus haute par
	/// tranche de longueur ; le toit est ce qui monte à moins de 4 % de la hauteur du sommet (au
	/// moins 2,5 pouces). La rampe se pose juste derrière le haut du pare-brise — au tiers avant
	/// du toit, comme sur une vraie voiture de patrouille —, à la hauteur du toit à cet endroit,
	/// et prend presque toute sa largeur. Sans sommets (modèle non découpé) : d'après la boîte.
	/// </summary>
	static RoofInfo RoofOf( VehicleRig rig )
	{
		if ( roofs.TryGetValue( rig, out var known ) )
			return known;
		var bb = rig.BodyBounds;
		var cy = bb.Center.y;
		var half = bb.Size.y / 2;
		var points = rig.BodyGroups.Where( g => !VehicleRig.IsGlass( g.Material ) ).SelectMany( g => g.Vertices ).Select( v => v.Position ).ToList();
		RoofInfo roof;
		if ( points.Count < 50 )
		{
			var front = (rig.CabinX ?? bb.Center.x) + bb.Size.x * 0.08f;
			roof = new RoofInfo( front, front - bb.Size.x * 0.3f, front - bb.Size.x * 0.1f, bb.Maxs.z, bb.Size.y * 0.7f );
		}
		else
		{
			const int Bins = 48;
			var top = new float[Bins];
			Array.Fill( top, float.MinValue );
			int BinOf( float x ) => Math.Clamp( (int)((x - bb.Mins.x) / bb.Size.x * Bins), 0, Bins - 1 );
			foreach ( var p in points )
			{
				if ( MathF.Abs( p.y - cy ) > half * 0.3f )
					continue;
				var b = BinOf( p.x );
				top[b] = MathF.Max( top[b], p.z );
			}
			// Pas le point le plus haut : une antenne de toit (la Polo) faisait prendre ses une ou deux
			// tranches pour le toit, et la rampe flottait en l'air au-dessus du hayon. Le toit occupe
			// une douzaine de tranches : la quatrième plus haute en fait sûrement partie.
			var peak = top.Where( t => t > float.MinValue ).OrderByDescending( t => t ).Skip( 3 ).FirstOrDefault( top.Max() );
			var tolerance = MathF.Max( 2.5f, bb.Size.z * 0.04f );
			var roofBins = Enumerable.Range( 0, Bins ).Where( b => top[b] >= peak - tolerance ).ToList();
			var binLength = bb.Size.x / Bins;
			var frontX = bb.Mins.x + (roofBins.Max() + 1) * binLength;
			var backX = bb.Mins.x + roofBins.Min() * binLength;
			var barX = frontX - (frontX - backX) * 0.32f;
			barX = MathF.Max( barX, backX + 7f );
			var nearBar = points.Where( p => MathF.Abs( p.x - barX ) < 6f && MathF.Abs( p.y - cy ) < half * 0.5f ).ToList();
			var z = nearBar.Count > 0 ? nearBar.Max( p => p.z ) : peak;
			var across = points.Where( p => MathF.Abs( p.x - barX ) < 4f && p.z > z - 4f ).ToList();
			var width = across.Count > 4 ? across.Max( p => p.y ) - across.Min( p => p.y ) : bb.Size.y * 0.7f;
			roof = new RoofInfo( frontX, backX, barX, z, width );
		}
		roofs[rig] = roof;
		return roof;
	}

	/// <summary>Un cube d'un pouce, centré, faces éclairées (normales par face) : boîtier et modules de la rampe.</summary>
	static Model beaconCube;

	static Model BeaconCube()
	{
		if ( beaconCube is not null && !beaconCube.IsError )
			return beaconCube;
		var material = Material.Load( HousingMaterial );
		var verts = new List<Vertex>();
		var indices = new List<int>();
		foreach ( var n in new[] { Vector3.Forward, Vector3.Backward, Vector3.Left, Vector3.Right, Vector3.Up, Vector3.Down } )
		{
			// Deux axes perpendiculaires à la normale, dans le sens qui garde la face tournée vers l'extérieur.
			var u = MathF.Abs( n.z ) > 0.5f ? Vector3.Forward : Vector3.Up;
			var v = Vector3.Cross( n, u );
			var start = verts.Count;
			foreach ( var (a, b) in new[] { (-1f, -1f), (1f, -1f), (1f, 1f), (-1f, 1f) } )
				verts.Add( new Vertex( (n + u * a + v * b) * 0.5f, n, u, new Vector4( (a + 1) / 2, (b + 1) / 2, 0, 0 ) ) );
			// Les deux sens d'enroulement, comme FlatQuad : l'un des deux est toujours la face vue
			// (le cube étant fermé, la face arrière de l'autre côté est cachée par la profondeur).
			indices.AddRange( new[] { start, start + 2, start + 1, start, start + 3, start + 2, start, start + 1, start + 2, start, start + 2, start + 3 } );
		}
		var mesh = new Mesh( material, MeshPrimitiveType.Triangles );
		mesh.CreateVertexBuffer( verts.Count, Vertex.Layout, verts );
		mesh.CreateIndexBuffer( indices.Count, indices );
		mesh.Bounds = new BBox( -Vector3.One * 0.5f, Vector3.One * 0.5f );
		beaconCube = Model.Builder.WithName( "horizon/police/cube" ).AddMesh( mesh ).Create();
		return beaconCube;
	}

	/// <summary>Un pavé de la rampe (boîtier, pied, module), dans le repère du véhicule.</summary>
	ModelRenderer Block( GameObject parent, string name, Vector3 center, Vector3 size, string material )
	{
		var go = Child( parent, name, center );
		go.LocalScale = size;
		var renderer = go.Components.Create<ModelRenderer>();
		renderer.Model = BeaconCube();
		renderer.MaterialOverride = Material.Load( material );
		return renderer;
	}

	void AddBeacon( GameObject parent, string name, Vector3 center, Vector3 size, bool left, int slot )
	{
		var lens = Block( parent, name, center, size, left ? LensRedOff : RightLens( false ) );
		lens.RenderType = ModelRenderer.ShadowRenderType.Off;
		beacons.Add( new Beacon { Lens = lens, Left = left, Slot = slot } );
	}

	void AddBeaconLight( GameObject parent, string name, Vector3 at, bool left, bool main )
	{
		var go = Child( parent, name, at );
		var light = go.Components.Create<PointLight>();
		light.LightColor = left ? BeaconRed : RightColor;
		light.Radius = main ? 400f : 140f;
		// Ombre coupée : la caisse et la rampe la masqueraient (comme les phares, Vehicle.Lights).
		light.Shadows = false;
		light.Enabled = false;
		beaconLights.Add( new BeaconLight { Light = light, Left = left, Main = main } );
	}

	void BuildPoliceKit()
	{
		var holder = GameObject.Children.FirstOrDefault( x => x.Name == "Carrosserie" );
		if ( !holder.IsValid() || Rig is null )
			return;
		if ( !policeSoundsLoaded )
		{
			policeSoundsLoaded = true;
			for ( var tone = 1; tone < SirenTones; tone++ )
				SoundFile.Load( SirenFile( tone ) );
			SoundFile.Load( AirhornSound );
		}

		var own = Rig.Beacons.Length > 0;
		policeKit = new GameObject( holder, true, own ? "Feux de police du modèle" : BarShown ? "Rampe de gyrophares" : "Feux de police banalisés" );
		policeKit.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
		var bb = Rig.BodyBounds;
		var cy = bb.Center.y;

		if ( own )
		{
			BuildModelBeacons( bb );
			return;
		}

		var roof = RoofOf( Rig );
		if ( BarShown )
		{
			// Une rampe de 48 pouces sur une berline (à l'échelle 1,15 de la ville : jusqu'à 58),
			// 11 de profondeur, posée sur deux pieds à un pouce et demi du toit.
			var width = Math.Clamp( roof.Width * 0.94f, 30f, MathF.Min( 58f, bb.Size.y * 0.78f ) );
			const float Depth = 11f, Feet = 1.6f, Base = 1.4f, Lens = 2.8f;
			var x = roof.BarX;
			var z0 = roof.Z;
			foreach ( var side in new[] { 1f, -1f } )
				Block( policeKit, "Pied de la rampe", new Vector3( x, cy + side * (width / 2 - 5f), z0 + Feet / 2 - 0.4f ), new Vector3( 5f, 3.5f, Feet + 0.8f ), HousingMaterial );
			Block( policeKit, "Boîtier de la rampe", new Vector3( x, cy, z0 + Feet + Base / 2 ), new Vector3( Depth, width, Base ), HousingMaterial );
			// Six modules : rouges côté conducteur (+Y, à gauche), bleus à droite. Du bord vers le milieu : 0, 1, 2.
			var moduleWidth = (width - 1f) / 6f;
			for ( var i = 0; i < 6; i++ )
			{
				var left = i < 3;
				var slot = left ? i : 5 - i;
				var y = cy + width / 2 - 0.5f - moduleWidth * (i + 0.5f);
				AddBeacon( policeKit, left ? $"Module rouge {slot}" : $"Module bleu {slot}", new Vector3( x, y, z0 + Feet + Base + Lens / 2 ), new Vector3( Depth - 0.6f, moduleWidth - 0.35f, Lens ), left, slot );
			}
			// Un capot noir sur le dessus, comme le couvercle d'une Liberty : les modules se voient de côté et de face.
			Block( policeKit, "Couvercle de la rampe", new Vector3( x, cy, z0 + Feet + Base + Lens + 0.25f ), new Vector3( Depth - 2.5f, width - 4f, 0.5f ), HousingMaterial );

			var lightZ = z0 + Feet + Base + Lens + 6f;
			AddBeaconLight( policeKit, "Lumière rouge", new Vector3( x, cy + width * 0.35f, lightZ ), true, true );
			AddBeaconLight( policeKit, "Lumière bleue", new Vector3( x, cy - width * 0.35f, lightZ ), false, true );
		}
		else
		{
			// Banalisée : deux feux derrière la calandre, entre les phares, au ras de la face avant.
			var frontZ = Rig.FrontLampCenters is { } lamps ? lamps.Average( l => l.z ) - 3f : bb.Mins.z + bb.Size.z * 0.36f;
			var faceX = FrontFaceX( Rig, frontZ );
			foreach ( var left in new[] { true, false } )
			{
				var y = cy + (left ? 1 : -1) * bb.Size.y * 0.16f;
				AddBeacon( policeKit, left ? "Feu de calandre rouge" : "Feu de calandre bleu", new Vector3( faceX + 0.3f, y, frontZ ), new Vector3( 1.2f, 6f, 2f ), left, 0 );
				AddBeaconLight( policeKit, left ? "Lueur de calandre rouge" : "Lueur de calandre bleue", new Vector3( faceX + 8f, y, frontZ ), left, false );
			}
			// En haut du pare-brise et de la lunette arrière, derrière la vitre : la barre « visière ».
			var visorZ = roof.Z - 4.5f;
			foreach ( var (atX, label) in new[] { (roof.FrontX - 3f, "pare-brise"), (roof.BackX + 3f, "lunette") } )
			{
				foreach ( var left in new[] { true, false } )
				{
					var y = cy + (left ? 6.5f : -6.5f);
					AddBeacon( policeKit, left ? $"Feu de {label} rouge" : $"Feu de {label} bleu", new Vector3( atX, y, visorZ ), new Vector3( 2.4f, 11.5f, 2f ), left, 0 );
				}
			}
			AddBeaconLight( policeKit, "Lueur de pare-brise rouge", new Vector3( roof.FrontX + 10f, cy + 10f, visorZ ), true, true );
			AddBeaconLight( policeKit, "Lueur de pare-brise bleue", new Vector3( roof.FrontX + 10f, cy - 10f, visorZ ), false, true );
		}
	}

	/// <summary>
	/// Un véhicule livré équipé (la Charger de police) : ses propres LED (VehicleRig.Beacons)
	/// s'allument, chacune sous un calque rouge ou bleu qui ne se montre qu'à l'éclat, et la
	/// rampe du jeu n'est pas posée. Les lumières suivent les pièces : sur le toit, deux grandes
	/// lumières au-dessus de la rampe ; ailleurs (calandre, pare-brise), une lueur de chaque côté,
	/// poussée vers l'avant ou l'arrière pour éclairer la route plutôt que l'intérieur de la caisse.
	/// </summary>
	void BuildModelBeacons( BBox bb )
	{
		foreach ( var piece in Rig.Beacons )
		{
			var go = Child( policeKit, $"{piece.Group} {(piece.Left ? "rouge" : EmsColors ? "blanc" : "bleu")} {piece.Slot}", Vector3.Zero );
			var renderer = go.Components.Create<ModelRenderer>();
			renderer.Model = piece.Model;
			renderer.MaterialOverride = Material.Load( piece.Left ? LensRed : RightLens( true ) );
			renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
			renderer.Enabled = false;
			beacons.Add( new Beacon { Lens = renderer, Left = piece.Left, Slot = piece.Slot, Overlay = true } );
		}
		foreach ( var group in Rig.Beacons.GroupBy( p => p.Group ) )
		{
			foreach ( var left in new[] { true, false } )
			{
				var side = group.Where( p => p.Left == left ).ToList();
				if ( side.Count == 0 )
					continue;
				var at = side.Select( p => p.Center ).Aggregate( Vector3.Zero, ( a, b ) => a + b ) / side.Count;
				var top = side[0].Top;
				if ( top )
					at += Vector3.Up * 6f;
				else
					at.x += at.x > bb.Center.x ? 8f : -8f;
				AddBeaconLight( policeKit, $"Lumière {group.Key} {(left ? "rouge" : "bleue")}", at, left, top );
			}
		}
	}

	/// <summary>Le devant de la caisse à cette hauteur, au milieu de la largeur (là où passe la calandre).</summary>
	static float FrontFaceX( VehicleRig rig, float z )
	{
		var bb = rig.BodyBounds;
		var near = rig.BodyGroups.SelectMany( g => g.Vertices ).Select( v => v.Position )
			.Where( p => MathF.Abs( p.z - z ) < 3f && MathF.Abs( p.y - bb.Center.y ) < bb.Size.y * 0.25f ).ToList();
		return near.Count > 0 ? near.Max( p => p.x ) : bb.Maxs.x - 2f;
	}

	// ------------------------------------------------------------------ sirène

	SoundHandle siren, airhorn;
	int sirenPlaying;

	/// <summary>
	/// La portée d'une sirène : ~250 m (10 000 pouces), avec une décroissance bien plus douce que
	/// celle des sons ordinaires (qui tombe au cinquième à 12 m) — 100 W dans un pavillon
	/// s'entendent à plusieurs rues.
	/// </summary>
	const float SirenRange = 10000f;

	static Curve SirenFalloff => new( new Curve.Frame( 0f, 1f ), new Curve.Frame( 0.04f, 0.6f ), new Curve.Frame( 0.15f, 0.3f ), new Curve.Frame( 0.45f, 0.1f ), new Curve.Frame( 1f, 0f ) );

	/// <summary>Le haut-parleur de la sirène : derrière la calandre, au milieu.</summary>
	Vector3 SirenPosition => Rig is null ? WorldPosition
		: WorldTransform.PointToWorld( new Vector3( Rig.BodyBounds.Maxs.x - 4f, Rig.BodyBounds.Center.y, Rig.BodyBounds.Mins.z + Rig.BodyBounds.Size.z * 0.35f ) );

	/// <summary>
	/// Partout : la tonalité choisie, en boucle, à l'avant du véhicule. Le fichier porte son repère
	/// de boucle (le moteur boucle seul) ; s'il s'arrête quand même, il repart aussitôt. À bord,
	/// moins fort : l'habitacle protège un peu. Le klaxon deux-tons la couvre tant qu'il est tenu.
	/// </summary>
	void UpdatePoliceSounds()
	{
		if ( Application.IsHeadless )
			return;
		var tone = HasBeacons && EmergencyLights && Ready ? SirenTone : SirenNone;
		var horn = PoliceHornActive;
		var aboard = RoleplayPlayer.Local?.GameObject.GetComponentInParent<Vehicle>() == this;
		var at = SirenPosition;

		if ( tone != sirenPlaying || siren is not null && !siren.IsPlaying )
		{
			siren?.Stop( tone == sirenPlaying ? 0f : 0.06f );
			siren = null;
			sirenPlaying = tone;
			if ( SirenFile( tone ) is { } file )
				siren = StartPoliceSound( file, at );
		}
		if ( siren is not null )
		{
			siren.Position = at;
			siren.Volume = horn ? 0f : aboard ? 0.5f : 1f;
		}

		if ( horn )
		{
			if ( airhorn is null || !airhorn.IsPlaying )
				airhorn = StartPoliceSound( AirhornSound, at );
			if ( airhorn is not null )
			{
				airhorn.Position = at;
				airhorn.Volume = aboard ? 0.6f : 1f;
			}
		}
		else if ( airhorn is not null )
		{
			airhorn.Stop( 0.08f );
			airhorn = null;
		}
	}

	static readonly Dictionary<string, SoundFile> loopedFiles = new();

	/// <summary>
	/// Le fichier d'une sirène, bouclé à coup sûr : le .wav brut relu et déclaré « en boucle »
	/// (SoundFile.FromWav) quand il est monté ; sinon le son compilé, qui porte son repère de boucle
	/// (chunk « cue » écrit par sirenes.py). Un fichier qui s'arrêterait quand même repart aussitôt.
	/// </summary>
	static SoundFile LoopedFile( string path )
	{
		if ( loopedFiles.TryGetValue( path, out var known ) && known is not null )
			return known;
		SoundFile file = null;
		try
		{
			if ( FileSystem.Mounted.FileExists( path ) )
				file = SoundFile.FromWav( $"horizon/police/{System.IO.Path.GetFileNameWithoutExtension( path )}", FileSystem.Mounted.ReadAllBytes( path ), new SoundFile.LoadOptions { Loop = true } );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Horizon] Sirène {path} : lecture du .wav impossible ({e.Message}), son compilé à la place" );
		}
		file ??= SoundFile.Load( path );
		loopedFiles[path] = file;
		return file;
	}

	static SoundHandle StartPoliceSound( string path, Vector3 at )
	{
		var file = LoopedFile( path );
		if ( file is null )
			return null;
		var handle = Sound.PlayFile( file, 1f );
		if ( handle is null )
			return null;
		handle.Position = at;
		handle.Distance = SirenRange;
		handle.Falloff = SirenFalloff;
		return handle;
	}

	/// <summary>Le véhicule disparaît : la sirène et le klaxon se taisent avec lui.</summary>
	void StopPoliceSounds()
	{
		siren?.Stop( 0.05f );
		airhorn?.Stop( 0.05f );
		siren = airhorn = null;
		sirenPlaying = SirenNone;
	}
}