Server-authoritative roleplay door system. Scans the map's Engine Door components, tracks ownership, lock, co-owners, price and allowed jobs per door keyed by rounded world position, syncs the whole state to clients as one JSON string, and handles buy/sell/lock/co-owner/free-staff RPCs plus persistence through the backend API or a local portes.json file.
using Sandbox.Mapping;
namespace HorizonRP;
/// <summary>
/// Les portes RP : achat, verrou, copropriétaires, portes de métier, réglage
/// par le staff. S'appuie sur le composant Door du moteur (portes de la carte),
/// dont il pilote le verrou (Door.IsLocked bloque déjà l'ouverture).
///
/// L'hôte tient l'état et le publie en un seul texte JSON synchronisé
/// (<see cref="StatesJson"/>) : quelques centaines de portes tiennent en
/// quelques dizaines de ko, renvoyés seulement quand quelque chose change.
///
/// Une porte est reconnue par sa position arrondie au moment où on la voit pour
/// la première fois, fermée : la même sur toutes les machines et d'un démarrage
/// à l'autre, tant que la carte ne change pas. (Une porte coulissante vue pour
/// la première fois ouverte serait mal reconnue : cas rare, à surveiller.)
/// </summary>
[Title( "Horizon RP – Portes" )]
[Icon( "door_front" )]
public sealed class DoorSystem : Component, Component.INetworkListener
{
public static DoorSystem Current { get; private set; }
public const int DefaultPrice = 100;
/// <summary>Distance maximale pour agir sur une porte (unités s&box, ~3,8 m).</summary>
public const float Reach = 150f;
public const int MaxDoorsPerPlayer = 8;
public const int MaxCoOwners = 5;
const string LocalFile = "portes.json";
[Sync( SyncFlags.FromHost )] public string StatesJson { get; set; }
/// <summary>Côté hôte : l'état qui fait foi.</summary>
readonly Dictionary<string, DoorState> states = new();
/// <summary>Sur toutes les machines : la porte qui porte chaque clé, et l'inverse.</summary>
readonly Dictionary<string, Door> doors = new();
readonly Dictionary<Door, string> keys = new();
/// <summary>Côté client : l'état lu dans StatesJson.</summary>
Dictionary<string, DoorState> published = new();
string parsedJson;
RealTimeSince sinceScan = 10;
bool configLoaded;
bool loggedCount;
protected override void OnAwake() => Current = this;
protected override void OnDestroy()
{
if ( Current == this )
Current = null;
}
// Recensement des portes
public static string ComputeKey( Door door )
{
var p = door.WorldPosition;
return $"{p.x.FloorToInt()},{p.y.FloorToInt()},{p.z.FloorToInt()}";
}
public string KeyOf( Door door )
{
if ( !keys.TryGetValue( door, out var key ) )
{
key = ComputeKey( door );
keys[door] = key;
doors[key] = door;
}
return key;
}
void Scan()
{
foreach ( var door in Scene.GetAllComponents<Door>() )
{
var key = KeyOf( door );
if ( Networking.IsHost && !states.ContainsKey( key ) )
states[key] = new DoorState { Key = key };
}
}
protected override void OnUpdate()
{
if ( Scene.IsEditor )
return;
if ( sinceScan > 5 )
{
sinceScan = 0;
Scan();
if ( Networking.IsHost && !loggedCount && doors.Count > 0 )
{
loggedCount = true;
Log.Info( $"[Horizon] {doors.Count} porte(s) trouvée(s) sur la carte" );
Publish();
}
}
if ( Networking.IsHost && Networking.IsActive && !configLoaded )
{
configLoaded = true;
_ = LoadConfig();
}
}
async Task LoadConfig()
{
List<Backend.DoorConfig> configs;
if ( Backend.IsOnline )
{
var response = await Backend.LoadDoors();
configs = response?.Doors;
if ( configs is null )
{
Log.Warning( "[Horizon] Réglage des portes indisponible (API injoignable) : prix par défaut" );
return;
}
}
else
{
configs = FileSystem.Data.ReadJsonOrDefault<List<Backend.DoorConfig>>( LocalFile ) ?? new();
}
// Un réglage peut arriver avant que la porte n'ait été vue : il attend.
foreach ( var config in configs )
{
if ( !states.TryGetValue( config.Key, out var state ) )
states[config.Key] = state = new DoorState { Key = config.Key };
state.ApplyConfig( config );
}
Publish();
}
/// <summary>Hôte : renvoie l'état à tout le monde et applique les verrous.</summary>
void Publish()
{
foreach ( var (key, state) in states )
{
if ( doors.TryGetValue( key, out var door ) && door.IsValid() )
door.IsLocked = state.Locked;
}
// Seules les portes réellement présentes partent : un réglage orphelin
// (carte modifiée) n'intéresse pas les joueurs.
StatesJson = Json.Serialize( states.Values.Where( x => doors.ContainsKey( x.Key ) ).ToList() );
}
// Lecture, sur toutes les machines
public DoorState StateOf( string key )
{
if ( Networking.IsHost )
return states.GetValueOrDefault( key );
if ( parsedJson != StatesJson )
{
parsedJson = StatesJson;
var list = string.IsNullOrEmpty( StatesJson ) ? null : Json.Deserialize<List<DoorState>>( StatesJson );
var previous = published;
published = list?.ToDictionary( x => x.Key ) ?? new();
// Le déclic de la serrure, là où une porte vient d'être verrouillée ou ouverte à clé.
foreach ( var (k, state) in published )
{
if ( previous.TryGetValue( k, out var before ) && before.Locked != state.Locked && doors.TryGetValue( k, out var d ) && d.IsValid() )
Sounds.PlayAt( "porte", d.WorldPosition, 0.8f );
}
}
return published.GetValueOrDefault( key );
}
/// <summary>La porte que le joueur local regarde, à portée, ou null.</summary>
public (Door door, string key) LookedAt()
{
var camera = Scene.Camera;
var me = RoleplayPlayer.Local;
if ( camera is null || me is null )
return (null, null);
var start = camera.WorldPosition;
var result = Scene.Trace.Ray( start, start + camera.WorldRotation.Forward * 400 )
.IgnoreGameObjectHierarchy( me.GameObject )
.Run();
var door = result.GameObject?.GetComponentInParent<Door>( true );
if ( door is null || me.WorldPosition.Distance( door.WorldPosition ) > Reach * 1.5f )
return (null, null);
return (door, KeyOf( door ));
}
// Connexions
public void OnDisconnected( Connection channel )
{
ulong steamId = channel.SteamId;
var changed = false;
foreach ( var state in states.Values )
{
if ( state.OwnerId == steamId )
{
state.Release();
changed = true;
}
else if ( state.CoOwners.Remove( steamId ) )
{
changed = true;
}
}
if ( changed )
Publish();
}
// Demandes des joueurs (sur l'hôte)
/// <summary>Le joueur qui appelle, s'il est assez près de la porte.</summary>
bool Resolve( string key, out RoleplayPlayer player, out DoorState state )
{
player = RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id );
state = key is null ? null : states.GetValueOrDefault( key );
if ( player is null || state is null || !doors.TryGetValue( key, out var door ) || !door.IsValid() )
return false;
if ( player.WorldPosition.Distance( door.WorldPosition ) > Reach * 1.5f )
{
player.Notify( "Approchez-vous de la porte.", "erreur" );
return false;
}
return true;
}
[Rpc.Host]
public void RequestBuy( string key )
{
if ( !Resolve( key, out var player, out var state ) || !player.ProfileLoaded )
return;
if ( !state.CanBuy )
{
player.Notify( state.IsOwned ? $"Cette porte appartient à {state.OwnerName}." : "Cette porte ne s'achète pas.", "erreur" );
return;
}
if ( states.Values.Count( x => x.OwnerId == player.SteamId ) >= MaxDoorsPerPlayer )
{
player.Notify( $"Vous possédez déjà {MaxDoorsPerPlayer} portes.", "erreur" );
return;
}
if ( player.Wallet < state.Price )
{
player.Notify( $"Il vous manque {Format.Money( state.Price - player.Wallet )}.", "erreur" );
return;
}
player.Wallet -= state.Price;
state.OwnerId = player.SteamId;
state.OwnerName = player.RpName;
state.Locked = false;
Publish();
player.Notify( $"{state.DisplayTitle} achetée : -{Format.Money( state.Price )}", "argent" );
HorizonGame.Current?.AddLog( "porte", player.SteamId, $"Achat de {state.DisplayTitle} ({key}) pour {Format.Money( state.Price )}" );
}
[Rpc.Host]
public void RequestSell( string key )
{
if ( !Resolve( key, out var player, out var state ) )
return;
if ( state.OwnerId != player.SteamId )
{
player.Notify( "Seul le propriétaire peut vendre.", "erreur" );
return;
}
var refund = state.Price / 2;
state.Release();
player.Wallet += refund;
Publish();
player.Notify( $"{state.DisplayTitle} vendue : +{Format.Money( refund )}", "argent" );
HorizonGame.Current?.AddLog( "porte", player.SteamId, $"Vente de {state.DisplayTitle} ({key}), {Format.Money( refund )} rendus" );
}
[Rpc.Host]
public void RequestSetLocked( string key, bool locked )
{
if ( !Resolve( key, out var player, out var state ) )
return;
if ( !state.CanUse( player ) && !player.IsStaff )
{
player.Notify( "Vous n'avez pas la clé de cette porte.", "erreur" );
return;
}
state.Locked = locked;
if ( locked && doors.TryGetValue( key, out var door ) && door.IsValid() )
door.Close();
Publish();
player.Notify( locked ? "Porte verrouillée." : "Porte déverrouillée.", "info" );
}
[Rpc.Host]
public void RequestAddCoOwner( string key, ulong steamId )
{
if ( !Resolve( key, out var player, out var state ) )
return;
var target = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId );
if ( state.OwnerId != player.SteamId || target is null || target == player )
return;
if ( state.CoOwners.Count >= MaxCoOwners )
{
player.Notify( $"{MaxCoOwners} copropriétaires au maximum.", "erreur" );
return;
}
if ( !state.CoOwners.Contains( steamId ) )
state.CoOwners.Add( steamId );
Publish();
player.Notify( $"{target.RpName} a maintenant la clé.", "info" );
target.Notify( $"{player.RpName} vous a donné la clé de {state.DisplayTitle}.", "info" );
}
[Rpc.Host]
public void RequestRemoveCoOwner( string key, ulong steamId )
{
if ( !Resolve( key, out var player, out var state ) || state.OwnerId != player.SteamId )
return;
if ( state.CoOwners.Remove( steamId ) )
{
Publish();
player.Notify( "Clé retirée.", "info" );
}
}
// Réglage par le staff
[Rpc.Host]
public void StaffConfigure( string key, string title, int price, bool buyable, string jobs )
{
if ( !Resolve( key, out var player, out var state ) )
return;
if ( !player.IsStaff )
{
Log.Warning( $"[Horizon] Réglage de porte refusé à {player.RpName} ({player.SteamId})" );
return;
}
state.Title = (title ?? "").Trim();
if ( state.Title.Length > 80 )
state.Title = state.Title[..80];
state.Price = Math.Clamp( price, 0, 1000000 );
state.Buyable = buyable;
state.Jobs = string.Join( ",", (jobs ?? "").Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ).Where( Jobs.Exists ).Distinct() );
// Une porte devenue porte de métier ou non achetable perd son propriétaire.
if ( state.IsOwned && (state.IsJobDoor || !state.Buyable) )
state.Release();
Publish();
_ = SaveConfig( player, state );
}
async Task SaveConfig( RoleplayPlayer staff, DoorState state )
{
if ( Backend.IsOnline )
{
var response = await Backend.SaveDoor( state.ToConfig(), $"{staff.RpName} ({staff.SteamId})" );
staff.Notify( response?.Ok == true ? $"{state.DisplayTitle} réglée." : "Porte réglée en jeu, mais non enregistrée (API injoignable).", response?.Ok == true ? "info" : "erreur" );
return;
}
var configs = FileSystem.Data.ReadJsonOrDefault<List<Backend.DoorConfig>>( LocalFile ) ?? new();
configs.RemoveAll( x => x.Key == state.Key );
configs.Add( state.ToConfig() );
FileSystem.Data.WriteJson( LocalFile, configs );
staff.Notify( $"{state.DisplayTitle} réglée.", "info" );
}
[Rpc.Host]
public void StaffRelease( string key )
{
if ( !Resolve( key, out var player, out var state ) || !player.IsStaff )
return;
var owner = state.OwnerName;
state.Release();
Publish();
player.Notify( owner is null ? "La porte était libre." : $"Porte retirée à {owner}.", "info" );
}
// Administration : panneau web (ordres) et menu F6, sans condition de distance
/// <summary>Toutes les portes vues sur cette machine, avec leur clé.</summary>
public IEnumerable<(string key, Door door)> Known =>
doors.Where( x => x.Value.IsValid() ).Select( x => (x.Key, x.Value) );
/// <summary>Hôte : l'état de toutes les portes pour le panneau.</summary>
public List<Backend.DoorSnapshot> Snapshot() => states.Values
.Where( x => doors.ContainsKey( x.Key ) )
.OrderBy( x => x.Key )
.Select( x => new Backend.DoorSnapshot
{
Key = x.Key,
Title = x.Title,
Price = x.Price,
Buyable = x.Buyable,
Jobs = x.Jobs,
OwnerId = x.OwnerId,
OwnerName = x.OwnerName,
CoOwners = x.CoOwners.Count,
Locked = x.Locked,
} ).ToList();
/// <summary>Hôte : un réglage déjà enregistré en base par le panneau, appliqué en jeu.</summary>
public string ApplyStaffConfig( Backend.DoorConfig config )
{
if ( config?.Key is null || !states.TryGetValue( config.Key, out var state ) )
return "Porte introuvable sur la carte";
state.ApplyConfig( config );
if ( state.IsOwned && (state.IsJobDoor || !state.Buyable) )
state.Release();
Publish();
return null;
}
/// <summary>Hôte : reprend la porte à son propriétaire. Rend l'ancien propriétaire, ou null.</summary>
public string ReleaseByStaff( string key, out bool found )
{
found = states.TryGetValue( key ?? "", out var state );
if ( !found )
return null;
var owner = state.OwnerName;
state.Release();
Publish();
return owner;
}
/// <summary>Hôte : verrouille ou déverrouille, sans clé ni distance.</summary>
public bool LockByStaff( string key, bool locked )
{
if ( !states.TryGetValue( key ?? "", out var state ) )
return false;
state.Locked = locked;
if ( locked && doors.TryGetValue( key, out var door ) && door.IsValid() )
door.Close();
Publish();
return true;
}
}