Props/PlacedProp.cs

A placed prop component in a roleplay gamemode. Syncs owner, model source, label and flags from the host, loads the model per machine and sets up either a model or box collider plus a rigidbody, and exposes an E-press interaction when the prop is the bank ATM model.

Networking
namespace HorizonRP;

/// <summary>
/// Un prop posé par un joueur. Créé et possédé par l'hôte (PropSystem), qui seul
/// en écrit les valeurs ; chaque machine charge le modèle elle-même. Le prop a la
/// physique de base (corps simulé par l'hôte), sauf figé au physgun. À la déconnexion de son propriétaire, un prop venu
/// de ses poches y retourne ; le matériel de service et ceux du staff disparaissent.
/// </summary>
[Title( "Horizon RP – Prop posé" )]
[Icon( "chair" )]
public sealed class PlacedProp : Component, Component.IPressable
{
	[Sync( SyncFlags.FromHost )] public ulong OwnerId { get; set; }
	[Sync( SyncFlags.FromHost )] public string OwnerName { get; set; }
	[Sync( SyncFlags.FromHost )] public long EntryId { get; set; }
	[Sync( SyncFlags.FromHost )] public string ModelSource { get; set; }
	[Sync( SyncFlags.FromHost )] public string Label { get; set; }

	/// <summary>Posé depuis les poches : ramassé, il y retourne.</summary>
	[Sync( SyncFlags.FromHost )] public bool FromItem { get; set; }

	/// <summary>Matériel de service : rangé quand son propriétaire quitte le métier.</summary>
	[Sync( SyncFlags.FromHost )] public bool Service { get; set; }

	/// <summary>Hôte : l'ordre de pose, pour « annuler le dernier » (touche Z).</summary>
	public int Order { get; set; }

	public static IEnumerable<PlacedProp> All =>
		Game.ActiveScene?.GetAllComponents<PlacedProp>() ?? Enumerable.Empty<PlacedProp>();

	/// <summary>
	/// Le prop « Distributeur HZ Bank » posé est un vrai distributeur, comme celui de la ville
	/// (Julien, 30/10/2026 : la touche E ne proposait rien). Il est réservé au staff au catalogue.
	/// </summary>
	public bool IsAtm => ModelSource == Atm.ModelPath;

	// Touche E : l'écran du distributeur (UI/AtmScreen.razor) ; l'hôte le compte dans Atm.All.

	public bool CanPress( IPressable.Event e ) => IsAtm && Atm.InPressReach( GameObject );

	public bool Press( IPressable.Event e )
	{
		if ( !IsAtm )
			return false;
		RoleplayPlayer.Local?.RequestAtmOpen( GameObject.Id );
		return true;
	}

	public IPressable.Tooltip? GetTooltip( IPressable.Event e ) =>
		IsAtm && Atm.InPressReach( GameObject ) ? new IPressable.Tooltip( "Distributeur HZ Bank", "local_atm", "Retrait, dépôt, solde" ) : null;

	string appliedSource;

	protected override void OnUpdate()
	{
		if ( appliedSource == ModelSource )
			return;

		appliedSource = ModelSource;
		_ = ApplyModel( ModelSource );
	}

	async Task ApplyModel( string source )
	{
		var model = await ModelSources.Load( source );
		if ( !IsValid || source != ModelSource )
			return;

		var renderer = GetOrAddComponent<ModelRenderer>();
		renderer.Model = model;

		// La physique de base (Julien, 30/10/2026) : un prop tombe, se renverse, se pousse, sauf
		// s'il a été figé au physgun (clic droit, AdminActions.FrozenTag). Il était en collision
		// statique : lâché en l'air au physgun, il y restait. Un corps dynamique veut des formes
		// convexes ; un modèle qui n'en a pas (box, mannequin…) reçoit une boîte à sa taille.
		var convex = model?.Physics?.Parts?.Any( p => p.Hulls.Any() || p.Spheres.Any() || p.Capsules.Any() ) == true;
		if ( convex )
		{
			Components.Get<BoxCollider>()?.Destroy();
			var collider = GetOrAddComponent<ModelCollider>();
			collider.Model = model;
			collider.Static = false;
		}
		else
		{
			Components.Get<ModelCollider>()?.Destroy();
			var box = GetOrAddComponent<BoxCollider>();
			box.Center = model?.Bounds.Center ?? Vector3.Up * 8;
			box.Scale = model?.Bounds.Size ?? 16;
		}

		// Simulée par l'hôte, à qui le prop appartient ; les autres reçoivent sa position.
		var body = GetOrAddComponent<Rigidbody>();
		body.MotionEnabled = !IsProxy && !GameObject.Tags.Has( AdminActions.FrozenTag );
	}
}