World/TruenorthMap.cs
using System.Text.Json;
namespace HorizonRP;
/// <summary>
/// RP Truenorth, la map GMod de Statua (accord donné à Julien le 30/10/2026), convertie par
/// outils/truenorth : le monde en tuiles (truenorth/monde), ses 381 modèles de props, ses lumières.
/// Chaque machine la construit elle-même au démarrage, à partir de truenorth/truenorth.json
/// (donnees.py) — rien ne passe par le réseau, comme les objets de ville : c'est un décor statique.
/// La construction est étalée sur plusieurs images (6 751 props, 1 219 lumières).
///
/// Depuis le 30/10/2026, c'est la carte du jeu : Rp Union City (MapInstance) est retirée de la scène,
/// Truenorth est posée à l'origine (<see cref="Offset"/> nul). Elle a ses propres points d'apparition.
/// </summary>
public sealed class TruenorthMap : Component
{
public const string DataPath = "truenorth/truenorth.json";
/// <summary>Où poser l'origine de la map (le repère de Source est celui de s&box, en pouces).</summary>
[Property] public Vector3 Offset { get; set; }
/// <summary>Les lumières de la map (sans ombres : 1 219, la plupart des intérieurs et des lampadaires).</summary>
[Property] public bool Lights { get; set; } = true;
/// <summary>
/// Le décor lointain : le ciel 3D de la map (forêts, montagnes) remis à sa vraie taille, jusqu'à
/// 130 000 unités autour d'elle. Sans lui, le bout des routes donnait sur le vide (Julien, 30/10/2026).
/// </summary>
[Property] public bool FarScenery { get; set; } = true;
/// <summary>
/// Les côtés du décor lointain à ne pas construire (« nord », « sud », « est », « ouest », séparés
/// par des virgules) : celui qui recouvrirait une carte voisine. Posée contre Rp Union City, à
/// l'est d'Union, Truenorth coupe son « ouest ».
/// </summary>
[Property] public string HiddenFarSides { get; set; } = "";
/// <summary>
/// La portée de la caméra tant que le décor lointain est là (pouces) : celle de la scène, 10 000
/// (254 m), coupait tout au-delà, décor compris. Remise comme avant quand la map disparaît.
/// </summary>
[Property] public float ViewDistance { get; set; } = 140000f;
float? sceneZFar;
CameraComponent widenedCamera;
static readonly Dictionary<string, int> FarSideCodes = new() { ["nord"] = 11, ["sud"] = 12, ["est"] = 13, ["ouest"] = 14 };
bool FarHidden( string side ) =>
(HiddenFarSides ?? "").Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ).Contains( side );
bool FarHidden( float code ) => FarSideCodes.Any( x => x.Value == (int)code && FarHidden( x.Key ) );
/// <summary>
/// Le niveau haut de la map (forêt, mines, villa) : au-dessus de la ville, relié par la route du
/// coin nord-ouest, caché par les faces de ciel (« Ciel »). Il ne projette pas d'ombres : ses
/// montagnes cachaient le soleil à des rues entières de la ville, dessous. (outils/truenorth/monde.py
/// sait aussi le poser à côté, SEPARER_HAUT, s'il fallait un jour le voir de l'extérieur.)
/// </summary>
[Property] public bool UpperLevel { get; set; } = true;
/// <summary>Objets créés par image pendant la construction.</summary>
[Property] public int PerFrame { get; set; } = 300;
public bool Built { get; private set; }
public int Count { get; private set; }
GameObject root;
/// <summary>
/// Les faces de ciel de la map (tools/toolsskybox) : le plafond de la ville, à 2048, cache le niveau
/// haut empilé au-dessus. Source y peint le ciel ; ici shaders/ciel_portail.shader y dessine celui
/// de la scène (la texture et la teinte du SkyBox2D, que DayNight change), mises à jour à chaque image.
/// </summary>
readonly List<ModelRenderer> skyFaces = new();
Material skyMaterial;
/// <summary>
/// Les lampadaires de rue de la map : une lumière réaliste sous chaque tête, allumée la nuit
/// seulement (Julien, 30/10/2026 : leurs têtes brillaient sans rien éclairer). Têtes mesurées sur
/// les modèles (repère du prop, pouces) : le double du parking (14,3 m), le lampadaire de route à
/// deux bras, la lanterne ancienne (origine au milieu du mât). Une propriété, pas un champ : un
/// champ statique garderait son ancienne valeur au rechargement à chaud.
/// </summary>
static (string Model, Vector3[] Heads, float Radius, float Intensity)[] StreetLamps => new[]
{
("parkinglot_light001", new[] { new Vector3( 0, 47, 560 ), new Vector3( 0, -47, 560 ) }, 1500f, 27f ),
("streetlight001", new[] { new Vector3( 48, 0, 458 ), new Vector3( -47, 0, 492 ) }, 1250f, 14f ),
("streetlightoldfash01", new[] { new Vector3( -65, 0, 148 ) }, 700f, 10f ),
};
/// <summary>Blanc chaud des lampadaires (3 000 K environ).</summary>
static Color LampColor => new( 1f, 0.82f, 0.6f );
/// <summary>Le seuil de jour sous lequel ils s'allument, celui de DayNight pour Union. Leur puissance
/// suppose le clair de lune de DayNight : sous le soleil de nuit d'avant, ils ne se voyaient pas.</summary>
const float LampsOnBelow = 0.55f;
GameObject lamps;
readonly List<SpotLight> lampSpots = new();
/// <summary>
/// L'allègement du rendu (Julien, 30/10/2026 : jouable sur un PC moyen). Mesuré depuis un client sur
/// le serveur dédié : 11 600 appels de dessin de jour, 15 600 de nuit, pour 3 000 conseillés — les
/// 6 600 props de la map (11 millions de triangles) et les ombres des 138 lampadaires. Les fusionner
/// aurait recopié ces triangles dans le téléchargement ; on les affiche plutôt selon leur distance
/// et leur taille, et seuls les lampadaires les plus proches projettent des ombres.
/// </summary>
readonly record struct Culled( ModelRenderer Renderer, Vector3 Position, float Radius );
readonly List<Culled> culled = new();
RealTimeSince sinceCulling;
/// <summary>Distance d'affichage d'un prop : <see cref="DrawDistancePerUnit"/> fois son rayon, bornée.</summary>
[Property] public float DrawDistancePerUnit { get; set; } = 70f;
[Property] public float MinDrawDistance { get; set; } = 1500f;
[Property] public float MaxDrawDistance { get; set; } = 20000f;
/// <summary>Au-delà, un prop ne projette plus d'ombre (les ombres lointaines du soleil ne se voient guère).</summary>
[Property] public float ShadowDistance { get; set; } = 2500f;
/// <summary>En dessous de ce rayon (pouces), un prop ne projette jamais d'ombre : canettes, boutons, poignées.</summary>
[Property] public float MinShadowRadius { get; set; } = 24f;
/// <summary>Les lampadaires qui projettent des ombres : les plus proches de la caméra, à portée.</summary>
[Property] public int ShadowedLamps { get; set; } = 8;
[Property] public float LampShadowDistance { get; set; } = 3000f;
/// <summary>Chaque quart de seconde, chez le joueur : quels props afficher, avec ou sans ombre, et quels lampadaires ombrés.</summary>
void UpdateCulling()
{
if ( Application.IsDedicatedServer || sinceCulling < 0.25f || Scene.Camera is not { } camera )
return;
sinceCulling = 0;
var eye = camera.WorldPosition;
foreach ( var c in culled )
{
if ( !c.Renderer.IsValid() )
continue;
var distance = c.Position.Distance( eye ) - c.Radius;
// Bornes sûres : Math.Clamp lève une exception si le minimum dépasse le maximum (vu en essai).
var reach = MathF.Min( MathF.Max( c.Radius * DrawDistancePerUnit, MinDrawDistance ), MathF.Max( MinDrawDistance, MaxDrawDistance ) );
var shown = distance <= reach;
if ( c.Renderer.Enabled != shown )
c.Renderer.Enabled = shown;
// Le niveau haut n'a jamais d'ombres (Solid) : on ne les lui rend pas.
if ( !shown || c.Renderer.GameObject.Tags.Has( UpperTag ) )
continue;
var shadow = c.Radius >= MinShadowRadius && (distance <= ShadowDistance || c.Radius > 400);
var wanted = shadow ? ModelRenderer.ShadowRenderType.On : ModelRenderer.ShadowRenderType.Off;
if ( c.Renderer.RenderType != wanted )
c.Renderer.RenderType = wanted;
}
if ( lamps.IsValid() && lamps.Enabled )
{
var nearest = lampSpots.Where( x => x.IsValid() ).Select( x => (x, d: x.WorldPosition.Distance( eye )) )
.OrderBy( x => x.d ).Take( ShadowedLamps ).Where( x => x.d <= LampShadowDistance ).Select( x => x.x ).ToHashSet();
foreach ( var spot in lampSpots )
if ( spot.IsValid() && spot.Shadows != nearest.Contains( spot ) )
spot.Shadows = nearest.Contains( spot );
}
}
/// <summary>L'étiquette des rendus du niveau haut, qui ne projettent jamais d'ombre.</summary>
const string UpperTag = "tn_haut";
sealed class Sun
{
public float[] angles { get; set; }
public float[] couleur { get; set; }
public float luminosite { get; set; }
}
/// <summary>Une entité en blocs de la map (porte de garage ou de maison, bouton…), monde.py.</summary>
sealed class Bloc
{
public string m { get; set; }
public float[] o { get; set; }
public float[] a { get; set; }
public int s { get; set; }
public int e { get; set; }
public string n { get; set; }
public string c { get; set; }
/// <summary>Porte pivotante : axe (« x », « y », « z ») et degrés ; porte coulissante : glissement.</summary>
public string p { get; set; }
public float d { get; set; }
public float[] g { get; set; }
}
sealed class Data
{
public string[] tuiles { get; set; }
public string[] hautes { get; set; }
public string[] plafonds { get; set; }
public string[] bords { get; set; }
public string eau { get; set; }
public string[] ciel { get; set; }
public string[] modeles { get; set; }
public float[][] props { get; set; }
public float[][] dynamiques { get; set; }
public float[][] lumieres { get; set; }
public float[][] apparitions { get; set; }
public float[][] portes { get; set; }
public Bloc[] blocs { get; set; }
public Sun soleil { get; set; }
}
protected override void OnStart()
{
// Le ciel et le soleil : Union les apportait (World/HorizonSky.cs).
HorizonSky.Ensure( Scene, GameObject );
_ = Build();
}
protected override void OnUpdate()
{
WidenView();
UpdateCulling();
if ( lamps.IsValid() && lamps.Enabled != GameClock.Daylight < LampsOnBelow )
lamps.Enabled = GameClock.Daylight < LampsOnBelow;
if ( skyFaces.Count == 0 || Scene.GetAllComponents<SkyBox2D>().FirstOrDefault() is not { } sky )
return;
var texture = sky.SkyMaterial?.GetTexture( "g_tSkyTexture" );
if ( texture is null )
return;
var tint = new Vector3( sky.Tint.r, sky.Tint.g, sky.Tint.b );
foreach ( var face in skyFaces )
{
if ( !face.IsValid() )
continue;
face.Attributes.Set( "SkyTexture", texture );
face.Attributes.Set( "SkyTint", tint );
}
}
protected override void OnDestroy()
{
if ( root.IsValid() )
root.Destroy();
if ( widenedCamera.IsValid() && sceneZFar is { } zfar )
widenedCamera.ZFar = zfar;
}
/// <summary>La caméra du joueur voit jusqu'au décor lointain (elle peut changer : relevée à chaque image).</summary>
void WidenView()
{
if ( !FarScenery || !Built || Scene.Camera is not { } camera )
return;
if ( camera != widenedCamera )
{
if ( widenedCamera.IsValid() && sceneZFar is { } before )
widenedCamera.ZFar = before;
widenedCamera = camera;
sceneZFar = camera.ZFar;
}
if ( camera.ZFar < ViewDistance )
camera.ZFar = ViewDistance;
}
/// <summary>Les points d'apparition de la map, dans le monde.</summary>
public IEnumerable<Transform> Spawns()
{
var data = Load();
foreach ( var a in data?.apparitions ?? Array.Empty<float[]>() )
yield return new Transform( Offset + new Vector3( a[0], a[1], a[2] ), Rotation.From( a[3], a[4], a[5] ) );
}
static Data Load()
{
var text = FileSystem.Mounted.ReadAllText( DataPath );
return string.IsNullOrEmpty( text ) ? null : JsonSerializer.Deserialize<Data>( text );
}
async Task Build()
{
var data = Load();
if ( data is null )
{
Log.Warning( $"[Horizon] {DataPath} introuvable : Truenorth n'est pas construite" );
return;
}
root = new GameObject( true, "Truenorth (map de Statua)" );
root.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
root.WorldPosition = Offset;
var models = data.modeles.Select( x => Model.Load( x ) ).ToArray();
var made = 0;
async Task Pace()
{
if ( ++made % Math.Max( 1, PerFrame ) == 0 )
await Task.Yield();
}
// Le monde : rendu et collision du même maillage.
var world = Child( root, "Monde" );
foreach ( var tile in data.tuiles )
{
Solid( Child( world, System.IO.Path.GetFileNameWithoutExtension( tile ) ), Model.Load( tile ), true );
await Pace();
}
// Les points d'apparition de la map (ses 30 info_player_start) : ceux que prend
// HorizonGame.FindSpawn, avec ceux posés par le staff (objets de ville « apparition »).
var spawnRoot = Child( root, "Apparitions" );
foreach ( var a in data.apparitions ?? Array.Empty<float[]>() )
{
var go = Child( spawnRoot, "Apparition" );
go.LocalPosition = new Vector3( a[0], a[1], a[2] + 4 );
go.LocalRotation = Rotation.FromYaw( a.Length > 4 ? a[4] : 0 );
go.Components.Create<SpawnPoint>();
}
// Le ciel peint sur les faces de ciel, solides comme dans Source.
var skyRoot = Child( root, "Ciel" );
skyMaterial ??= Material.FromShader( "shaders/ciel_portail.shader" );
skyFaces.Clear();
foreach ( var tile in data.plafonds ?? Array.Empty<string>() )
{
var go = Child( skyRoot, System.IO.Path.GetFileNameWithoutExtension( tile ) );
Solid( go, Model.Load( tile ), true );
var renderer = go.Components.Get<ModelRenderer>();
renderer.MaterialOverride = skyMaterial;
renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
skyFaces.Add( renderer );
await Pace();
}
// Le mur invisible : les murs de ciel du bord, solides et jamais dessinés.
var walls = Child( root, "Bords" );
foreach ( var tile in data.bords ?? Array.Empty<string>() )
{
var collider = Child( walls, System.IO.Path.GetFileNameWithoutExtension( tile ) ).Components.Create<ModelCollider>();
collider.Model = Model.Load( tile );
collider.Static = true;
await Pace();
}
var upper = Child( root, "Niveau haut" );
foreach ( var tile in UpperLevel ? data.hautes ?? Array.Empty<string>() : Array.Empty<string>() )
{
Solid( Child( upper, System.IO.Path.GetFileNameWithoutExtension( tile ) ), Model.Load( tile ), true, false );
await Pace();
}
if ( !string.IsNullOrEmpty( data.eau ) )
Solid( Child( world, "Eau" ), Model.Load( data.eau ), false );
// Le décor lointain : le ciel 3D de la map remis à sa vraie taille (×16), sans collision.
// Il porte à plus de 100 000 unités : la caméra doit voir assez loin (ZFar).
var far = Child( root, "Décor lointain" );
foreach ( var tile in FarScenery ? data.ciel ?? Array.Empty<string>() : Array.Empty<string>() )
{
// ciel_<côté>_x_y (outils/truenorth/monde.py).
if ( FarSideCodes.Keys.Any( side => tile.Contains( $"/ciel_{side}_" ) && FarHidden( side ) ) )
continue;
Solid( Child( far, System.IO.Path.GetFileNameWithoutExtension( tile ) ), Model.Load( tile ), false );
await Pace();
}
// Les props : statiques ; « solide » à 0 dans la map = pas de collision ; ceux du décor
// lointain sont seize fois plus grands (p[9]) ; p[10] : 1 pour ceux du niveau haut, 11 à 14 pour
// le côté du décor lointain (nord, sud, est, ouest).
var props = Child( root, "Props" );
foreach ( var p in data.props )
{
if ( !FarScenery && p.Length > 9 && p[9] != 1 )
continue;
if ( !UpperLevel && p.Length > 10 && p[10] == 1 )
continue;
if ( p.Length > 10 && FarHidden( p[10] ) )
continue;
var model = models[(int)p[0]];
var go = Child( props, model.ResourceName );
go.LocalPosition = new Vector3( p[1], p[2], p[3] );
go.LocalRotation = Rotation.From( p[4], p[5], p[6] );
if ( p.Length > 9 && p[9] != 1 )
go.LocalScale = p[9];
Solid( go, model, p[8] != 0, p.Length <= 10 || p[10] != 1 );
// Le décor lointain reste toujours affiché ; le reste suit sa distance (UpdateCulling).
if ( p.Length <= 9 || p[9] == 1 )
culled.Add( new Culled( go.Components.Get<ModelRenderer>(), go.WorldPosition, model.Bounds.Size.Length * 0.5f ) );
await Pace();
}
// Les lampadaires : des projecteurs vers le bas sous leurs têtes, avec ombres, éteints le jour.
lamps = Child( root, "Lampadaires" );
var lampModels = StreetLamps;
foreach ( var p in data.props )
{
if ( p.Length > 9 && p[9] != 1 )
continue;
var name = System.IO.Path.GetFileNameWithoutExtension( data.modeles[(int)p[0]] );
var lamp = lampModels.FirstOrDefault( x => x.Model == name );
if ( lamp.Model is null )
continue;
var at = new Transform( new Vector3( p[1], p[2], p[3] ), Rotation.From( p[4], p[5], p[6] ) );
foreach ( var head in lamp.Heads )
{
var go = Child( lamps, "Lampadaire" );
go.LocalPosition = at.PointToWorld( head ) + Vector3.Down * 4;
go.LocalRotation = Rotation.LookAt( Vector3.Down, at.Rotation.Forward );
var spot = go.Components.Create<SpotLight>();
spot.Radius = lamp.Radius;
spot.LightColor = LampColor * lamp.Intensity;
spot.ConeInner = 38;
spot.ConeOuter = 68;
spot.Shadows = false;
lampSpots.Add( spot );
}
await Pace();
}
lamps.Enabled = GameClock.Daylight < LampsOnBelow;
// Les portes de la map (prop_door_rotating) : à leur place, fermées ; la touche E les ouvre
// (World/MapDoor.cs). Ni propriétaire ni verrou pour l'instant (Julien, 30/10/2026 : la gestion
// des portes et de l'immobilier viendra). portes : modèle, position, angles, ouverture (degrés),
// sens (0 les deux, 1 avant, 2 arrière), départ, skin, étage.
var doors = Child( root, "Portes" );
var doorIndex = 0;
foreach ( var p in data.portes ?? Array.Empty<float[]>() )
{
if ( !UpperLevel && p.Length > 11 && p[11] == 1 )
continue;
var model = models[(int)p[0]];
var go = Child( doors, model.ResourceName );
go.LocalPosition = new Vector3( p[1], p[2], p[3] );
go.LocalRotation = Rotation.From( p[4], p[5], p[6] );
Solid( go, model, true, p.Length <= 11 || p[11] != 1 );
MakeDoor( go, model, doorIndex++, Vector3.Up, p[7], null, (int)p[8] );
await Pace();
}
// Les entités en blocs : portes de garage, de maison et de prison, boutons, décors traversables
// (func_illusionary, sans collision). À leur place, fermées : la gestion des portes viendra.
var blocks = Child( root, "Blocs" );
foreach ( var b in data.blocs ?? Array.Empty<Bloc>() )
{
if ( !UpperLevel && b.e == 1 )
continue;
var go = Child( blocks, string.IsNullOrEmpty( b.n ) ? b.c : $"{b.c} {b.n}" );
go.LocalPosition = new Vector3( b.o[0], b.o[1], b.o[2] );
go.LocalRotation = Rotation.From( b.a[0], b.a[1], b.a[2] );
var blockModel = Model.Load( b.m );
Solid( go, blockModel, b.s == 1, b.e == 0 );
if ( b.p is { Length: > 0 } )
MakeDoor( go, blockModel, doorIndex++, b.p == "x" ? Vector3.Forward : b.p == "y" ? Vector3.Left : Vector3.Up, b.d, null, 1 );
else if ( b.g is { Length: 3 } )
MakeDoor( go, blockModel, doorIndex++, Vector3.Up, 0, new Vector3( b.g[0], b.g[1], b.g[2] ), 1 );
await Pace();
}
foreach ( var p in data.dynamiques ?? Array.Empty<float[]>() )
{
if ( !UpperLevel && p.Length > 9 && p[9] != 0 )
continue;
var model = models[(int)p[0]];
var go = Child( props, model.ResourceName );
go.LocalPosition = new Vector3( p[1], p[2], p[3] );
go.LocalRotation = Rotation.From( p[4], p[5], p[6] );
Solid( go, model, p[8] != 0, p.Length <= 9 || p[9] == 0 );
await Pace();
}
if ( Lights )
{
var lights = Child( root, "Lumières" );
foreach ( var l in data.lumieres )
{
if ( !UpperLevel && l.Length > 14 && l[14] != 0 )
continue;
var go = Child( lights, l[0] == 1 ? "Spot" : "Lumière" );
go.LocalPosition = new Vector3( l[1], l[2], l[3] );
go.LocalRotation = Rotation.From( l[4], l[5], l[6] );
// « _light » de Source : couleur et luminosité (200 par défaut) ; ramenée à l'échelle de s&box.
var color = new Color( l[7], l[8], l[9] ) * Math.Clamp( l[10] / 200f, 0.2f, 4f );
if ( l[0] == 1 )
{
var spot = go.Components.Create<SpotLight>();
spot.Radius = l[11];
spot.LightColor = color;
spot.ConeInner = l[12];
spot.ConeOuter = l[13];
spot.Shadows = false;
}
else
{
var point = go.Components.Create<PointLight>();
point.Radius = l[11];
point.LightColor = color;
point.Shadows = false;
}
await Pace();
}
}
Count = made;
BankFacade.Build( root );
Built = true;
Log.Info( $"[Horizon] Truenorth construite : {made} objets" );
}
/// <summary>
/// Une porte qui s'ouvre (World/MapDoor.cs) : sa collision devient cinématique, pour suivre le
/// battant ; le numéro est le même sur toutes les machines (ordre de construction).
/// </summary>
static void MakeDoor( GameObject go, Model model, int index, Vector3 axis, float degrees, Vector3? slide, int openDirection )
{
if ( go.Components.Get<ModelCollider>() is { } collider )
{
collider.Static = false;
go.Components.Create<Rigidbody>().MotionEnabled = false;
}
var door = go.Components.Create<MapDoor>();
door.Index = index;
door.Axis = axis;
door.Degrees = degrees;
door.Slide = slide;
door.OpenDirection = openDirection;
door.PanelCenter = model?.Bounds.Center ?? Vector3.Zero;
}
static GameObject Child( GameObject parent, string name )
{
var go = new GameObject( parent, true, name );
go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
return go;
}
static void Solid( GameObject go, Model model, bool collide, bool shadows = true )
{
var renderer = go.Components.Create<ModelRenderer>();
renderer.Model = model;
if ( !shadows )
{
renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
go.Tags.Add( UpperTag );
}
if ( !collide )
return;
var collider = go.Components.Create<ModelCollider>();
collider.Model = model;
collider.Static = true;
}
}