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