Host-only city manager for a roleplay gamemode. Loads saved staff placements from the backend API or a local JSON file, spawns them as networked CityObject components, and handles place, move and remove operations with rollback and local persistence.
namespace HorizonRP;
/// <summary>
/// La ville posée par le staff : relue au démarrage, recréée en objets réseau,
/// et tenue à jour à chaque pose, déplacement ou suppression.
///
/// Tourne sur l'hôte seul. Les objets vivent en base (hz_placements, via l'API)
/// quand le serveur est relié au site, sinon dans un fichier local — c'est le
/// cas d'une partie lancée depuis l'éditeur.
/// </summary>
[Title( "Horizon RP – Ville" )]
[Icon( "apartment" )]
public sealed class CityManager : Component
{
public static CityManager Current { get; private set; }
const string LocalFile = "placements.json";
readonly Dictionary<long, CityObject> objects = new();
bool loaded;
protected override void OnAwake() => Current = this;
protected override void OnDestroy()
{
if ( Current == this )
Current = null;
}
protected override void OnUpdate()
{
// On attend que le lobby existe : un objet créé avant ne serait pas
// envoyé aux joueurs.
if ( loaded || !Networking.IsHost || !Networking.IsActive || Scene.IsEditor )
return;
loaded = true;
_ = LoadAll();
}
async Task LoadAll()
{
List<Backend.Placement> placements;
if ( Backend.IsOnline )
{
var response = await Backend.LoadPlacements();
if ( response?.Placements is null )
{
// Une ville vide écrasée par-dessus la vraie serait pire qu'une
// ville absente : on réessaiera au prochain démarrage.
Log.Warning( "[Horizon] Objets de la ville indisponibles (API injoignable)" );
return;
}
placements = response.Placements;
}
else
{
placements = FileSystem.Data.ReadJsonOrDefault<List<Backend.Placement>>( LocalFile ) ?? new();
}
foreach ( var p in placements )
Spawn( p );
Log.Info( $"[Horizon] {placements.Count} objet(s) de la ville recréé(s)" );
}
void Spawn( Backend.Placement p )
{
var go = new GameObject( true, $"{CityKinds.Get( p.Kind ).Name} n°{p.Id}" );
go.WorldPosition = new Vector3( p.X, p.Y, p.Z );
go.WorldRotation = Rotation.From( p.Pitch, p.Yaw, p.Roll );
go.WorldScale = p.Scale;
var obj = go.Components.Create<CityObject>();
obj.PlacementId = p.Id;
obj.Kind = p.Kind;
obj.ModelSource = p.Model;
obj.Label = p.Label;
obj.Data = p.Data;
if ( p.Kind == CityKinds.Spawn )
go.Components.Create<SpawnPoint>();
go.NetworkSpawn();
objects[p.Id] = obj;
}
static Backend.Placement ToPlacement( CityObject obj )
{
var angles = obj.WorldRotation.Angles();
return new Backend.Placement
{
Id = obj.PlacementId,
Kind = obj.Kind,
Model = obj.ModelSource,
Label = obj.Label,
Data = obj.Data,
X = obj.WorldPosition.x,
Y = obj.WorldPosition.y,
Z = obj.WorldPosition.z,
Pitch = angles.pitch,
Yaw = angles.yaw,
Roll = angles.roll,
Scale = obj.WorldScale.x,
};
}
// Écritures, appelées par AdminActions après vérification du staff
public async Task<string> Place( Backend.Placement p, string by )
{
if ( Backend.IsOnline )
{
var response = await Backend.SavePlacement( p, by );
if ( response is null || !response.Ok )
return response?.Error ?? "API injoignable : objet non enregistré.";
p.Id = response.Id;
}
else
{
p.Id = objects.Count == 0 ? 1 : objects.Keys.Max() + 1;
}
Spawn( p );
SaveLocal();
return null;
}
public async Task<string> Move( long id, Vector3 position, Rotation rotation, float scale, string by )
{
if ( !objects.TryGetValue( id, out var obj ) || !obj.IsValid() )
return "Objet introuvable.";
var before = (obj.WorldPosition, obj.WorldRotation, obj.WorldScale);
obj.WorldPosition = position;
obj.WorldRotation = rotation;
obj.WorldScale = scale;
if ( Backend.IsOnline )
{
var response = await Backend.SavePlacement( ToPlacement( obj ), by );
if ( response is null || !response.Ok )
{
// Pas enregistré : on remet l'objet où il était, sinon il
// reviendrait tout seul à l'ancienne place au prochain démarrage.
(obj.WorldPosition, obj.WorldRotation, obj.WorldScale) = before;
return response?.Error ?? "API injoignable : déplacement annulé.";
}
}
SaveLocal();
return null;
}
public async Task<string> Remove( long id, string by )
{
if ( !objects.TryGetValue( id, out var obj ) )
return "Objet introuvable.";
if ( Backend.IsOnline )
{
var response = await Backend.DeletePlacement( id, by );
if ( response is null || !response.Ok )
return response?.Error ?? "API injoignable : objet conservé.";
}
objects.Remove( id );
if ( obj.IsValid() )
obj.GameObject.Destroy();
SaveLocal();
return null;
}
void SaveLocal()
{
if ( Backend.IsOnline )
return;
FileSystem.Data.WriteJson( LocalFile, objects.Values.Where( x => x.IsValid() ).Select( ToPlacement ).ToList() );
}
}