UI/PropMenu.razor

The prop management UI component. Manages props via tabs for the catalogue, inventory, job service equipment, and currently placed props. It allows the player to select and place objects in the game world using a visual ghost preview.

@using Sandbox;
@using Sandbox.UI;
@namespace HorizonRP
@inherits PanelComponent

@*
	Le menu des props, façon FiveM (touche P). Un joueur pose ce qu'il a en poche
	(« Mes objets ») et le matériel de service de son métier ; le staff fait
	apparaître tout le catalogue. On choisit un prop, un fantôme suit le regard
	(vert : posable, rouge : trop loin, limite, plus rien en poche), clic gauche
	pose (on enchaîne tant qu'il en reste), clic droit ou Échap arrête. « Posés »
	les liste pour les ramasser ; Z ramasse le dernier.
	Toute pose est une demande que l'hôte revérifie (PropSystem).
*@

<root>
	@if ( Placing is { } placing )
	{
		<div class="place-bar @(CanPlace ? "" : "blocked")">
			<div class="place-title">
				<i>chair</i> @placing.Name
				<span>@PlacingNote</span>
			</div>
			@if ( !CanPlace )
			{
				<div class="place-why">@BlockReason</div>
			}
			<div class="place-keys">
				<span><b>Clic gauche</b> poser</span>
				<span><b>Molette</b> tourner</span>
				<span><b>R</b> remettre droit</span>
				<span><b>Clic droit</b> arrêter</span>
			</div>
		</div>
	}
	else if ( HeldProp is { } held && !Open )
	{
		@* Un prop pris en main (sa poche) : rien ne disait quoi en faire (Julien, 30/10/2026). *@
		<div class="place-bar">
			<div class="place-title">
				<i>inventory_2</i> @held.Name
				<span>en main</span>
			</div>
			<div class="place-keys">
				<span><b>Clic gauche</b> poser</span>
				@if ( HeldPocketKey is { } key )
				{
					<span><b>@key</b> ranger</span>
				}
			</div>
		</div>
	}

	@if ( Open && Me is not null )
	{
		<div class="props-backdrop" onclick=@(() => SetOpen( false ))>
			<div class="props" onclick=@((PanelEvent e) => e.StopPropagation())>
				<div class="props-head">
					<div class="props-title"><i>chair</i> PROPS <span>@(Me.IsStaff ? "STAFF · TOUT LE CATALOGUE" : "UNION CITY")</span></div>
					<div class="props-tabs">
						@if ( Me.IsStaff )
						{
							<div class="props-tab @(Tab == "catalogue" ? "active" : "")" onclick=@(() => Tab = "catalogue")><i>grid_view</i> Catalogue</div>
						}
						<div class="props-tab @(Tab == "objets" ? "active" : "")" onclick=@(() => Tab = "objets")><i>backpack</i> @($"Mes objets · {(Pocket.Sum( x => x.Count ))}")</div>
						@if ( Service.Any() )
						{
							<div class="props-tab @(Tab == "service" ? "active" : "")" onclick=@(() => Tab = "service")><i>work</i> Matériel de service</div>
						}
						<div class="props-tab @(Tab == "poses" ? "active" : "")" onclick=@(() => Tab = "poses")><i>place</i> @($"Posés · {(MineCount)}{(Me.IsStaff ? "" : $" / {PropRules.MaxPerPlayer}")}")</div>
					</div>
					<div class="props-close" onclick=@(() => SetOpen( false ))><i>close</i></div>
				</div>

				@if ( Tab == "catalogue" && Me.IsStaff )
				{
					<div class="chips">
						@foreach ( var category in Categories )
						{
							<div class="chip @(Category == category ? "active" : "")" onclick=@(() => Category = category)>@category</div>
						}
					</div>
					<div class="tiles">
						@foreach ( var entry in Shown )
						{
							<div class="tile @(entry.Active ? "" : "off")" onclick=@(() => StartPlacing( entry ))>
								<div class="pic" style="background-image: url( @ModelThumbs.Of( entry.Model ) )"></div>
								<div class="tile-name">@entry.Name</div>
								<div class="tile-price">@entry.Category</div>
								@if ( entry.StaffOnly || !entry.Active )
								{
									<div class="tile-tag">@(entry.StaffOnly ? "staff" : "retiré")</div>
								}
							</div>
						}
					</div>
					<div class="hint">Le staff fait apparaître ce qu'il veut, sans rien avoir en poche. Les joueurs, eux, posent ce qu'ils ont dans leur inventaire.</div>
				}
				else if ( Tab == "service" )
				{
					<div class="tiles">
						@foreach ( var entry in Service )
						{
							<div class="tile" onclick=@(() => StartPlacing( entry ))>
								<div class="pic" style="background-image: url( @ModelThumbs.Of( entry.Model ) )"></div>
								<div class="tile-name">@entry.Name</div>
								<div class="tile-price">@Me.Job.Name</div>
							</div>
						}
					</div>
					<div class="hint">Le matériel de votre métier se sort sans l'avoir sur soi, et se range tout seul si vous quittez le service.</div>
				}
				else if ( Tab == "poses" )
				{
					<div class="rows">
						@if ( MineCount == 0 )
						{
							<div class="empty">Vous n'avez posé aucun prop.</div>
						}
						@foreach ( var prop in Mine )
						{
							<div class="row">
								<div class="row-pic" style="background-image: url( @ModelThumbs.Of( prop.ModelSource ) )"></div>
								<div class="row-main">
									<div class="row-title">@prop.Label</div>
									<div class="row-sub">@($"{(Meters( prop.WorldPosition ))} · {(prop.FromItem ? "revient dans vos poches" : prop.Service ? "matériel de service" : "apparu par le staff")}")</div>
								</div>
								<div class="btn" onclick=@(() => Props?.RequestRemove( prop.GameObject.Id ))><i>back_hand</i> Ramasser</div>
							</div>
						}
					</div>
					@if ( MineCount > 0 )
					{
						<div class="foot">
							<div class="hint">@($"{HorizonHud.KeyName( "PropUndo" )} ramasse le dernier prop posé, sans ouvrir ce menu. En quittant la ville, vos props retournent dans vos poches.")</div>
							<div class="btn" onclick=@(() => Props?.RequestRemoveAll())><i>select_all</i> Tout ramasser</div>
						</div>
					}
				}
				else
				{
					<div class="tiles">
						@if ( !Pocket.Any() )
						{
							<div class="empty">Aucun objet à poser sur vous. Les magasins de meubles arrivent bientôt ; en attendant, le staff peut vous en donner.</div>
						}
						@foreach ( var (entry, count) in Pocket )
						{
							<div class="tile" onclick=@(() => StartPlacing( entry ))>
								<div class="pic" style="background-image: url( @ModelThumbs.Of( entry.Model ) )"></div>
								<div class="tile-name">@entry.Name</div>
								<div class="tile-price">@($"×{(count)} en poche")</div>
							</div>
						}
					</div>
				}
			</div>
		</div>
	}
</root>

@code
{
	public bool Open { get; private set; }

	/// <summary>Une pose en cours : les armes ne tirent pas pendant ce temps.</summary>
	public bool IsPlacing => Placing is not null;

	string Tab { get; set; } = "objets";
	string Category { get; set; } = "Tout";

	// Pose
	PropEntry Placing { get; set; }
	float Yaw { get; set; }
	GameObject ghost;
	ModelRenderer ghostRenderer;
	RealTimeSince sincePlacingStart;
	bool CanPlace { get; set; }
	string BlockReason { get; set; }

	RoleplayPlayer Me => RoleplayPlayer.Local;
	PropSystem Props => PropSystem.Current;

	IReadOnlyList<PropEntry> Catalog => Props?.Catalog ?? (IReadOnlyList<PropEntry>)Array.Empty<PropEntry>();

	IEnumerable<string> Categories => new[] { "Tout" }.Concat( Catalog.Select( x => x.Category ).Distinct().OrderBy( x => x ) );

	/// <summary>Le catalogue du staff, filtré par catégorie.</summary>
	IEnumerable<PropEntry> Shown => Catalog.Where( x => Category == "Tout" || x.Category == Category ).OrderBy( x => x.Category ).ThenBy( x => x.Name );

	/// <summary>Les props que le joueur a en poche, avec leur nombre.</summary>
	IEnumerable<(PropEntry Entry, int Count)> Pocket => Me is null ? Enumerable.Empty<(PropEntry, int)>() :
		Me.PublishedInventory.Where( x => PropItems.EntryOf( x.Item ) > 0 )
			.GroupBy( x => PropItems.EntryOf( x.Item ) )
			.Select( g => (Props?.Get( g.Key ), g.Sum( x => x.Count )) )
			.Where( x => x.Item1 is not null )
			.OrderBy( x => x.Item1.Name );

	int InPocket( PropEntry entry ) => Me?.PublishedInventory.Where( x => x.Item == PropItems.IdOf( entry.Id ) ).Sum( x => x.Count ) ?? 0;

	/// <summary>Le prop tenu en main (sa poche prise par 1 à 5 ou la molette), ou null.</summary>
	PropEntry HeldProp => PropItems.EntryOf( Me?.HeldKey ) is var id && id > 0 ? Props?.Get( id ) : null;

	/// <summary>La touche de sa poche, qui le range (une poche au-delà de 5 n'a pas de touche).</summary>
	string HeldPocketKey => Me?.PublishedInventory.FirstOrDefault( x => x.Item == Me.HeldKey ) is { } slot && slot.Slot < InventoryRules.HotbarSlots
		? HorizonHud.KeyName( InventoryRules.KeyOf( slot.Slot ) ) : null;

	/// <summary>Le matériel de service du métier du joueur.</summary>
	IEnumerable<PropEntry> Service => Me is null || Me.IsStaff ? Enumerable.Empty<PropEntry>() : Catalog.Where( x => x.IsServiceFor( Me ) ).OrderBy( x => x.Name );

	/// <summary>Ce que coûte la pose en cours : rien pour le staff et le service, un objet sinon.</summary>
	string PlacingNote => Placing is null || Me is null ? "" :
		Me.IsStaff ? "staff" : Placing.IsServiceFor( Me ) ? "matériel de service" : $"×{InPocket( Placing )} en poche";

	/// <summary>Depuis le bouton Poser de l'inventaire.</summary>
	public void PlaceFromInventory( long entryId )
	{
		if ( Props?.Get( entryId ) is { } entry )
			StartPlacing( entry );
	}

	IEnumerable<PlacedProp> Mine => Me is null ? Enumerable.Empty<PlacedProp>() : PropSystem.OwnedBy( Me.SteamId ).OrderBy( x => Distance( x.WorldPosition ) );

	int MineCount => Mine.Count();

	protected override void OnUpdate()
	{
		var me = Me;
		if ( me is null || me.NeedsCharacter )
		{
			if ( Open ) SetOpen( false );
			if ( Placing is not null ) StopPlacing();
			return;
		}

		// Échap referme le menu, ou arrête la pose.
		if ( Input.EscapePressed && (Open || Placing is not null) )
		{
			Input.EscapePressed = false;
			if ( Open ) SetOpen( false );
			else StopPlacing();
			return;
		}

		if ( Input.Pressed( "PropMenu" ) && (Open || !UiLock.IsLocked) )
		{
			if ( Placing is not null ) StopPlacing();
			SetOpen( !Open );
		}

		// Z : retire le dernier prop posé, hors menus.
		if ( Input.Pressed( "PropUndo" ) && !UiLock.IsLocked && !Vehicle.RadioUsesKey( "PropUndo" ) )
			Props?.RequestUndo();

		if ( Placing is not null )
			UpdatePlacing( me );
	}

	protected override void OnDisabled()
	{
		StopPlacing();
		UiLock.Set( "props", false );
	}

	void SetOpen( bool open )
	{
		if ( open && (Tab == "catalogue" && Me?.IsStaff != true || Tab == "service" && !Service.Any()) )
			Tab = "objets";
		Open = open;
		UiLock.Set( "props", open );
	}

	// Pose

	void StartPlacing( PropEntry entry )
	{
		StopPlacing();
		Placing = entry;
		Yaw = Me?.WorldRotation.Angles().yaw ?? 0;
		sincePlacingStart = 0;
		SetOpen( false );

		ghost = new GameObject( true, "Horizon – fantôme de prop" );
		ghost.NetworkMode = NetworkMode.Never;
		ghostRenderer = ghost.Components.Create<ModelRenderer>();
		_ = LoadGhost( entry.Model );
	}

	async Task LoadGhost( string source )
	{
		var model = await ModelSources.Load( source );
		if ( ghostRenderer.IsValid() )
			ghostRenderer.Model = model;
	}

	void StopPlacing()
	{
		Placing = null;
		if ( ghost.IsValid() )
			ghost.Destroy();
		ghost = null;
		ghostRenderer = null;
	}

	void UpdatePlacing( RoleplayPlayer me )
	{
		var camera = Scene.Camera;
		if ( camera is null || !ghost.IsValid() )
			return;

		var start = camera.WorldPosition;
		var result = Scene.Trace.Ray( start, start + camera.WorldRotation.Forward * (PropRules.Reach + 300) )
			.IgnoreGameObjectHierarchy( me.GameObject )
			.IgnoreGameObjectHierarchy( ghost )
			.Run();

		Yaw += Input.MouseWheel.y * 15f;
		if ( Input.Pressed( "reload" ) )
			Yaw = me.WorldRotation.Angles().yaw;

		ghost.WorldPosition = result.EndPosition;
		ghost.WorldRotation = Rotation.FromYaw( Yaw );

		// Le dernier objet en poche est posé : la pose s'arrête d'elle-même.
		var free = me.IsStaff || Placing.IsServiceFor( me );
		if ( !free && InPocket( Placing ) == 0 )
		{
			StopPlacing();
			return;
		}

		// Vert : posable ; rouge : l'hôte refuserait (les mêmes règles que PropSystem).
		var tooFar = !result.Hit || result.EndPosition.Distance( me.WorldPosition ) > PropRules.Reach;
		var tooMany = !me.IsStaff && MineCount >= PropRules.MaxPerPlayer;
		CanPlace = !tooFar && !tooMany;
		BlockReason = tooMany ? $"Limite de {PropRules.MaxPerPlayer} props atteinte ({HorizonHud.KeyName( "PropUndo" )} en ramasse un)" : tooFar ? "Trop loin" : null;
		if ( ghostRenderer.IsValid() )
			ghostRenderer.Tint = CanPlace ? new Color( 0.45f, 1f, 0.6f, 0.6f ) : new Color( 1f, 0.35f, 0.35f, 0.6f );

		// Le clic qui a choisi le prop ne doit pas le poser aussitôt.
		if ( sincePlacingStart < 0.3f )
			return;

		if ( Input.Pressed( "attack1" ) && CanPlace )
			Props?.RequestPlace( Placing.Id, ghost.WorldPosition, ghost.WorldRotation );
		else if ( Input.Pressed( "attack2" ) )
			StopPlacing();
	}

	float Distance( Vector3 position ) => Me is null ? 0 : Me.WorldPosition.Distance( position );

	/// <summary>Les unités s&box sont des pouces : 39,37 par mètre.</summary>
	string Meters( Vector3 position ) => $"{(Distance( position ) / 39.37f):0} m";

	protected override int BuildHash()
	{
		var catalog = Props?.CatalogJson?.GetHashCode() ?? 0;
		var mine = Mine.Aggregate( 0, ( h, p ) => HashCode.Combine( h, p.GameObject.Id, (Distance( p.WorldPosition ) / 39.37f).FloorToInt() ) );
		return HashCode.Combine( HashCode.Combine( Open, Tab, Category, Placing?.Id, CanPlace, BlockReason, Me?.HeldKey ), Me?.InventoryJson, Me?.JobId, Me?.IsStaff, catalog, mine );
	}
}