The garage showroom preview for the police service menu. Builds a local, non-networked preview of the selected vehicle with plates, lightbar and studio lights, borrows the player camera, and reports the preview position and yaw for the real spawn.
namespace HorizonRP;
/// <summary>
/// Le salon du garage de service, façon « Argus Carshop » (FiveM), demandé par Julien le
/// 29/10/2026 : le véhicule choisi dans le menu (UI/PoliceGarageMenu.razor) est posé dans la ville,
/// à la place où il sortira (PoliceService.FreeGarageSpot), et la caméra du joueur le cadre comme
/// un studio photo.
///
/// - <b>Un aperçu, jamais un vrai véhicule</b> : des objets locaux (ni réseau, ni sauvegarde, sans
/// collision), sans composant <see cref="Vehicle"/> — <c>Vehicle.All</c> lit toute la scène, et
/// un faux véhicule y aurait bloqué la place, pris une plaque, montré « E · Monter »… La caisse,
/// les roues et la plaque viennent du gréement (<see cref="VehicleRig"/>, le même que le vrai
/// véhicule), la rampe est refaite ici à l'identique.
/// - <b>On tourne la voiture, pas la caméra</b> (glisser, <see cref="Turn"/>) : la caméra reste de
/// trois quarts avant par rapport à la place ; la molette zoome (<see cref="Zoom"/>). Le véhicule
/// sort ensuite exactement là et ainsi tourné (<see cref="Origin"/>, <see cref="Yaw"/>, revérifiés
/// par l'hôte).
/// - <b>La caméra est empruntée, puis rendue telle quelle</b> : la caméra du jeu (celle du
/// contrôleur, qui ne la pilote plus le temps du salon), son champ de vision, sa place et ses
/// étiquettes exclues sont notés à l'entrée et remis à la sortie. <see cref="Framing"/> dit à
/// WeaponHolder de ne plus imposer son champ de vision (comme CharacterCreator.Framing).
/// - <b>Éclairage de studio</b> : quatre lampes locales autour de la voiture, plus fortes la nuit,
/// baissées quand les gyrophares de démonstration tournent (sinon on ne verrait pas les éclats).
///
/// Rien n'est en réseau : la rampe clignote chez le joueur seul. <see cref="End"/> détruit tout et
/// rend la caméra ; le menu l'appelle en se fermant, quelle qu'en soit la raison (Échap, fin de
/// service, chute, éloignement, menu détruit).
/// </summary>
public sealed class PoliceShowroom
{
/// <summary>Le salon tient la caméra : WeaponHolder ne lui impose plus son champ de vision.</summary>
public static bool Framing { get; private set; }
// Cadrage, réglable à chaud. Le champ est horizontal (CameraComponent.FieldOfView).
public static float Fov { get; set; } = 52f;
/// <summary>La caméra, de trois quarts avant : écart avec l'axe de la place (degrés) et plongée.</summary>
public static float CameraAngle { get; set; } = 38f;
public static float CameraPitch { get; set; } = 10f;
/// <summary>
/// La part de l'écran occupée par les panneaux du menu (en pixels d'une interface de 1080 de
/// haut) : la voiture est cadrée au milieu de ce qui reste.
/// </summary>
public static float FreeLeft { get; set; } = 470f;
public static float FreeRight { get; set; } = 390f;
public static float FreeTop { get; set; } = 70f;
public static float FreeBottom { get; set; } = 300f;
/// <summary>Éclairage de studio : force la nuit et le jour.</summary>
public static float StudioNight { get; set; } = 2.2f;
public static float StudioDay { get; set; } = 0.7f;
/// <summary>La rotation donnée par le joueur, en degrés, autour du milieu de la voiture.</summary>
public float Turn { get; set; }
/// <summary>Le zoom à la molette : -0,35 (plus large) à 1 (au plus près).</summary>
public float Zoom
{
get => zoom;
set => zoom = Math.Clamp( value, -0.35f, 1f );
}
/// <summary>Les gyrophares de démonstration (chez le joueur seul).</summary>
public bool Beacons { get; set; }
/// <summary>Le salon est ouvert (la caméra est tenue).</summary>
public bool Active => begun;
/// <summary>Le véhicule est construit et visible.</summary>
public bool Loaded => begun && car.IsValid() && rig is not null && builtKey == shownKey;
/// <summary>L'orientation du véhicule montré (degrés) : celle de la place, plus la rotation du joueur.</summary>
public float Yaw => baseYaw + Turn;
/// <summary>L'origine du véhicule montré : celle que prendra le vrai véhicule (à la hauteur près).</summary>
public Vector3 Origin => car.IsValid() ? car.WorldPosition : ground;
bool begun;
float zoom;
Vector3 anchor, ground;
float baseYaw;
GameObject car, studio;
PointLight[] lamps = Array.Empty<PointLight>();
VehicleRig rig;
string shownKey, builtKey;
float wheelBottom;
float shownDistance;
// La caméra empruntée et son état d'avant.
CameraComponent camera;
PlayerController controller;
bool savedCameraControls, savedViewerExcluded, savedLegsExcluded;
float savedFov;
Vector3 savedPosition;
Rotation savedRotation;
// ------------------------------------------------------------------ entrée et sortie
/// <summary>Ouvre le salon sur cette place (sa position au sol et son orientation).</summary>
public void Begin( Vector3 place, float yaw )
{
if ( begun )
End();
var scene = Game.ActiveScene;
camera = scene?.Camera;
if ( camera is null )
return;
begun = true;
Framing = true;
anchor = place;
ground = PoliceService.GroundAt( place );
baseYaw = yaw;
Turn = 0;
zoom = 0;
Beacons = false;
shownDistance = 0;
controller = RoleplayPlayer.Local?.GetComponent<PlayerController>();
savedCameraControls = controller?.UseCameraControls ?? true;
if ( controller.IsValid() )
controller.UseCameraControls = false;
savedFov = camera.FieldOfView;
savedPosition = camera.WorldPosition;
savedRotation = camera.WorldRotation;
savedViewerExcluded = camera.RenderExcludeTags.Has( "viewer" );
savedLegsExcluded = camera.RenderExcludeTags.Has( FirstPersonLegs.Tag );
studio = Local( null, "Studio du garage" );
studio.WorldPosition = ground;
lamps = new[] { Lamp( "Principale" ), Lamp( "Appoint" ), Lamp( "Contre-jour" ), Lamp( "Plafond" ) };
}
/// <summary>
/// Détruit l'aperçu et les lampes, et rend la caméra dans l'état d'avant. Sans effet si le
/// salon n'est pas ouvert ; ne lève jamais (appelé en sortie de tout).
/// </summary>
public void End()
{
var wasBegun = begun;
begun = false;
Framing = false;
try
{
HidePreview();
if ( studio.IsValid() )
studio.Destroy();
studio = null;
lamps = Array.Empty<PointLight>();
if ( wasBegun && camera.IsValid() )
{
camera.FieldOfView = savedFov;
camera.WorldPosition = savedPosition;
camera.WorldRotation = savedRotation;
SetTag( "viewer", savedViewerExcluded );
SetTag( FirstPersonLegs.Tag, savedLegsExcluded );
}
}
finally
{
// La caméra rendue au contrôleur quoi qu'il arrive : sans elle, la vue resterait figée sur le garage.
if ( wasBegun && controller.IsValid() )
controller.UseCameraControls = savedCameraControls;
camera = null;
controller = null;
}
}
/// <summary>Retire la voiture d'aperçu (le vrai véhicule prend sa place), la caméra restant en place.</summary>
public void HidePreview()
{
if ( car.IsValid() )
car.Destroy();
car = null;
builtKey = null;
shownKey = null;
beacons.Clear();
beaconLights.Clear();
}
void SetTag( string tag, bool excluded )
{
if ( excluded )
camera.RenderExcludeTags.Add( tag );
else
camera.RenderExcludeTags.Remove( tag );
}
/// <summary>Un objet du salon : local, jamais en réseau ni sauvegardé.</summary>
static GameObject Local( GameObject parent, string name )
{
var go = parent is null ? new GameObject( true, name ) : new GameObject( parent, true, name );
go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never;
return go;
}
PointLight Lamp( string name )
{
var light = Local( studio, name ).Components.Create<PointLight>();
light.Shadows = false;
return light;
}
// ------------------------------------------------------------------ le véhicule
/// <summary>Montre ce modèle de la flotte (sans effet s'il est déjà montré).</summary>
public void Show( VehicleModel info, string colorHex, bool lightbar, bool ems, string plate )
{
if ( !begun || info is null )
return;
var key = $"{info.Id}|{info.Model}|{info.Scale}|{info.YawOffset}|{colorHex}|{lightbar}|{ems}|{plate}";
if ( key == shownKey )
return;
shownKey = key;
_ = Load( info, colorHex, lightbar, ems, plate, key );
}
async Task Load( VehicleModel info, string colorHex, bool lightbar, bool ems, string plate, string key )
{
var model = await ModelSources.Load( info.Model );
if ( !begun || key != shownKey || model is null )
return;
var built = VehicleRig.Build( model, null, info.Scale, info.YawOffset );
if ( built is null )
return;
if ( car.IsValid() )
car.Destroy();
beacons.Clear();
beaconLights.Clear();
rig = built;
car = Local( null, "Aperçu du garage" );
// Le gréement porte déjà la rotation de la fiche : comme la « Carrosserie » d'un vrai véhicule.
var holder = Local( car, "Carrosserie" );
var body = holder.Components.Create<ModelRenderer>();
body.Model = rig.Body;
body.Tint = Vehicle.PaintTint( colorHex );
for ( var i = 0; i < rig.Wheels.Length; i++ )
{
if ( rig.WheelModels.ElementAtOrDefault( i ) is not { } wheelModel )
continue;
var wheel = Local( holder, $"Roue {i}" );
wheel.LocalPosition = rig.Wheels[i].Center;
wheel.Components.Create<ModelRenderer>().Model = wheelModel;
}
wheelBottom = rig.Wheels.Length > 0 ? rig.Wheels.Min( w => w.Center.z - w.Radius ) : rig.Bounds.Mins.z;
AddPlates( holder, plate );
var showBar = lightbar || Vehicle.LiveryServiceOf( info.Id ) is not null;
BuildKit( holder, showBar, ems || Vehicle.LiveryServiceOf( info.Id ) == "ems" );
builtKey = key;
PlaceCar();
}
/// <summary>
/// Le véhicule tourne autour de son milieu, posé sur ses roues : son origine bouge donc avec la
/// rotation (c'est elle que l'on passe à l'hôte, <see cref="Origin"/>).
/// </summary>
void PlaceCar()
{
if ( !car.IsValid() || rig is null )
return;
var rotation = Rotation.FromYaw( Yaw );
var center = rig.Bounds.Center.WithZ( 0 );
car.WorldRotation = rotation;
car.WorldPosition = ground.WithZ( ground.z - wheelBottom + 0.5f ) - rotation * center;
}
/// <summary>La plaque, comme sur le vrai véhicule (Vehicle.ApplyModel) : notre fond, le numéro par-dessus.</summary>
void AddPlates( GameObject holder, string plate )
{
foreach ( var p in rig.Plates )
{
var width = Vector3.Cross( Vector3.Up, p.Normal ).Normal;
if ( width.Length < 0.5f )
width = Vector3.Left;
var back = Local( holder, p.Front ? "Fond plaque avant" : "Fond plaque arrière" );
back.LocalPosition = p.Center + p.Normal * 0.6f;
back.LocalRotation = Rotation.LookAt( width, p.Normal );
back.LocalScale = new Vector3( p.Width, p.Height, 1 );
back.Components.Create<ModelRenderer>().Model = FlatQuad.Get( "materials/horizon/plaque.vmat" );
var go = Local( holder, p.Front ? "Plaque avant" : "Plaque arrière" );
go.LocalPosition = p.Center + p.Normal * 0.85f - Vector3.Up * p.Height * 0.08f;
go.LocalRotation = Rotation.LookAt( -p.Normal, Vector3.Up );
var text = go.Components.Create<TextRenderer>();
text.Text = plate ?? "";
text.FontFamily = "Roboto Condensed";
text.FontWeight = 700;
text.Color = new Color( 0.5f, 0.52f, 0.55f );
text.FontSize = 64;
text.Scale = p.Width * 0.5f / MathF.Max( 1, (plate?.Length ?? 7) * 28f );
}
}
// ------------------------------------------------------------------ chaque image
/// <summary>À chaque image, salon ouvert : la voiture, ses éclats, les lampes et la caméra.</summary>
public void Tick()
{
if ( !begun )
return;
if ( !camera.IsValid() )
{
End();
return;
}
PlaceCar();
UpdateBeacons();
PlaceCamera();
}
void PlaceCamera()
{
var size = rig?.Bounds.Size ?? new Vector3( 180, 80, 60 );
var height = rig is null ? 30f : rig.Bounds.Center.z - wheelBottom;
var target = ground + Vector3.Up * height;
// Recul : la voiture, vue sous n'importe quel angle (sa diagonale au sol), tient dans la partie
// libre de l'écran, en largeur comme en hauteur.
var aspect = Screen.Width / MathF.Max( 1f, Screen.Height );
var uiWidth = 1080f * aspect;
var freeWidth = MathF.Max( 0.2f, (uiWidth - FreeLeft - FreeRight) / uiWidth );
var freeHeight = MathF.Max( 0.2f, (1080f - FreeTop - FreeBottom) / 1080f );
var halfH = (Fov / 2f).DegreeToRadian();
var halfV = MathF.Atan( MathF.Tan( halfH ) / aspect );
var radius = size.WithZ( 0 ).Length / 2f * 1.04f;
var wanted = MathF.Max( radius / (MathF.Tan( halfH ) * freeWidth), size.z * 0.75f / (MathF.Tan( halfV ) * freeHeight) );
wanted *= 1f - zoom * 0.5f;
shownDistance = shownDistance <= 0 ? wanted : MathX.Lerp( shownDistance, wanted, MathF.Min( 1f, RealTime.Delta * 10f ) );
var distance = shownDistance;
var pitch = CameraPitch.DegreeToRadian();
var direction = Rotation.FromYaw( baseYaw + CameraAngle ).Forward * MathF.Cos( pitch ) + Vector3.Up * MathF.Sin( pitch );
var position = target + direction * distance;
var rotation = Rotation.LookAt( -direction, Vector3.Up );
// La voiture au milieu de la partie libre : la caméra glisse de côté (sans tourner), comme
// à la création du personnage. En demi-largeurs d'écran, le décalage vaut la différence des
// panneaux de gauche et de droite.
var shiftX = (FreeLeft - FreeRight) / 2f / (uiWidth / 2f) * distance * MathF.Tan( halfH );
var shiftY = (FreeBottom - FreeTop) / 2f / 540f * distance * MathF.Tan( halfV );
position += -rotation.Right * shiftX - rotation.Up * shiftY;
// Un mur, un pilier du garage entre la voiture et la caméra : elle s'avance devant.
var tr = Game.ActiveScene.Trace.Ray( target, position ).WithoutTags( "player", "trigger" ).Run();
if ( tr.Hit )
position = tr.EndPosition + (target - position).Normal * 6f;
camera.FieldOfView = Fov;
camera.WorldPosition = position;
camera.WorldRotation = rotation;
// Le corps du joueur (étiquette « viewer » à la 1re personne) se voit dans le salon, ses
// jambes de 1re personne (une copie reculée sous la caméra) non.
camera.RenderExcludeTags.Remove( "viewer" );
camera.RenderExcludeTags.Add( FirstPersonLegs.Tag );
PlaceStudio( target, size, direction );
}
void PlaceStudio( Vector3 target, Vector3 size, Vector3 toCamera )
{
if ( lamps.Length < 4 || lamps.Any( x => !x.IsValid() ) )
return;
var daylight = GameClock.Daylight;
var strength = MathX.Lerp( StudioNight, StudioDay, daylight );
// Gyrophares allumés, de nuit : le studio se baisse, ce sont les éclats qui éclairent.
if ( Beacons )
strength *= MathX.Lerp( 0.22f, 0.8f, daylight );
var warm = new Color( 1f, 0.95f, 0.88f );
var reach = MathF.Max( 260f, size.Length * 2.2f );
var facing = Rotation.LookAt( toCamera.WithZ( 0 ), Vector3.Up );
var side = size.Length * 0.55f;
Set( lamps[0], target + facing.Forward * side + facing.Left * side * 0.8f + Vector3.Up * size.z * 1.4f, warm * strength );
Set( lamps[1], target + facing.Forward * side + facing.Right * side + Vector3.Up * size.z * 0.6f, new Color( 0.85f, 0.9f, 1f ) * strength * 0.55f );
Set( lamps[2], target - facing.Forward * side * 1.1f + facing.Right * side * 0.3f + Vector3.Up * size.z * 1.2f, warm * strength * 0.7f );
Set( lamps[3], target + Vector3.Up * size.z * 2.4f, warm * strength * 0.6f );
void Set( PointLight light, Vector3 at, Color color )
{
light.WorldPosition = at;
light.LightColor = color;
light.Radius = reach;
}
}
// ------------------------------------------------------------------ la rampe (recopiée de Vehicle.Police.cs)
// La rampe d'un vrai véhicule se construit dans Vehicle.Police.cs (BuildPoliceKit), en privé :
// elle est refaite ici à l'identique — mêmes mesures du toit, mêmes matériaux, même motif. Une
// retouche de la rampe là-bas se reporte ici.
sealed class Beacon
{
public ModelRenderer Lens;
public bool Left;
public int Slot;
public bool Lit;
}
readonly List<Beacon> beacons = new();
readonly List<(PointLight Light, bool Left, bool Main)> beaconLights = new();
bool emsColors;
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 );
string RightLens( bool lit ) => emsColors ? (lit ? LensWhite : LensWhiteOff) : (lit ? LensBlue : LensBlueOff);
Color RightColor => emsColors ? BeaconWhite : BeaconBlue;
void UpdateBeacons()
{
var daylight = GameClock.Daylight;
foreach ( var beacon in beacons )
{
var lit = Beacons && BeaconOn( beacon.Left, beacon.Slot );
if ( lit == beacon.Lit || !beacon.Lens.IsValid() )
continue;
beacon.Lit = lit;
beacon.Lens.MaterialOverride = Material.Load( beacon.Left ? (lit ? LensRed : LensRedOff) : RightLens( lit ) );
}
foreach ( var (light, left, main) in beaconLights )
{
if ( !light.IsValid() )
continue;
var lit = Beacons && BeaconOn( left, -1 );
light.Enabled = lit;
if ( !lit )
continue;
var strength = MathX.Lerp( main ? 5.5f : 2.2f, main ? 1.6f : 0.8f, daylight );
light.LightColor = (left ? BeaconRed : RightColor) * strength;
light.Radius = MathX.Lerp( main ? 480f : 160f, main ? 260f : 90f, daylight );
}
}
/// <summary>Le motif Whelen / Code 3 de Vehicle.BeaconOn : doubles éclats alternés, puis en croix.</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;
return slot == 2 ? left != leftPhase : left == leftPhase;
}
sealed record RoofInfo( float FrontX, float BackX, float BarX, float Z, float Width );
/// <summary>Le dessus du toit, mesuré comme Vehicle.RoofOf (l'antenne de la Polo écartée).</summary>
static RoofInfo RoofOf( VehicleRig rig )
{
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();
if ( points.Count < 50 )
{
var front = (rig.CabinX ?? bb.Center.x) + bb.Size.x * 0.08f;
return new RoofInfo( front, front - bb.Size.x * 0.3f, front - bb.Size.x * 0.1f, bb.Maxs.z, bb.Size.y * 0.7f );
}
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 );
}
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 = MathF.Max( frontX - (frontX - backX) * 0.32f, 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;
return new RoofInfo( frontX, backX, barX, z, width );
}
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;
}
static Model cube;
/// <summary>Un cube d'un pouce, centré, faces éclairées : boîtier et modules (comme Vehicle.BeaconCube).</summary>
static Model Cube()
{
if ( cube is not null && !cube.IsError )
return cube;
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 } )
{
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 ) ) );
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.Load( HousingMaterial ), 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 );
cube = Model.Builder.WithName( "horizon/police/cube_apercu" ).AddMesh( mesh ).Create();
return cube;
}
static ModelRenderer Block( GameObject parent, string name, Vector3 center, Vector3 size, string material )
{
var go = Local( parent, name );
go.LocalPosition = center;
go.LocalScale = size;
var renderer = go.Components.Create<ModelRenderer>();
renderer.Model = Cube();
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 = Local( parent, name );
go.LocalPosition = at;
var light = go.Components.Create<PointLight>();
light.LightColor = left ? BeaconRed : RightColor;
light.Radius = main ? 400f : 140f;
light.Shadows = false;
light.Enabled = false;
beaconLights.Add( (light, left, main) );
}
/// <summary>La rampe sur le toit, ou les feux banalisés (calandre, haut du pare-brise et de la lunette).</summary>
void BuildKit( GameObject holder, bool bar, bool ems )
{
emsColors = ems;
var kit = Local( holder, bar ? "Rampe de gyrophares" : "Feux de police banalisés" );
var bb = rig.BodyBounds;
var cy = bb.Center.y;
var roof = RoofOf( rig );
if ( bar )
{
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( kit, "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( kit, "Boîtier de la rampe", new Vector3( x, cy, z0 + Feet + Base / 2 ), new Vector3( Depth, width, Base ), HousingMaterial );
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( kit, left ? $"Module rouge {slot}" : $"Module droit {slot}", new Vector3( x, y, z0 + Feet + Base + Lens / 2 ), new Vector3( Depth - 0.6f, moduleWidth - 0.35f, Lens ), left, slot );
}
Block( kit, "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( kit, "Lumière rouge", new Vector3( x, cy + width * 0.35f, lightZ ), true, true );
AddBeaconLight( kit, "Lumière droite", new Vector3( x, cy - width * 0.35f, lightZ ), false, true );
return;
}
var frontZ = rig.FrontLampCenters is { Length: > 0 } frontLamps ? frontLamps.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( kit, left ? "Feu de calandre rouge" : "Feu de calandre droit", new Vector3( faceX + 0.3f, y, frontZ ), new Vector3( 1.2f, 6f, 2f ), left, 0 );
AddBeaconLight( kit, left ? "Lueur de calandre rouge" : "Lueur de calandre droite", new Vector3( faceX + 8f, y, frontZ ), left, false );
}
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( kit, left ? $"Feu de {label} rouge" : $"Feu de {label} droit", new Vector3( atX, y, visorZ ), new Vector3( 2.4f, 11.5f, 2f ), left, 0 );
}
}
AddBeaconLight( kit, "Lueur de pare-brise rouge", new Vector3( roof.FrontX + 10f, cy + 10f, visorZ ), true, true );
AddBeaconLight( kit, "Lueur de pare-brise droite", new Vector3( roof.FrontX + 10f, cy - 10f, visorZ ), false, true );
}
}