UI/InventoryMenu.razor

The roleplay inventory screen. It renders pockets, an item detail panel, nearby dropped items and nearby players, and sends use, move, drop, give, pickup and prop-placement requests through InventoryActions. It also handles drag-and-drop between slots, mouse-wheel hotbar switching, and hotkeys 1 to 5.

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

@*
	L'inventaire (touche I), façon FiveM : les poches (5 cases, toutes à portée de
	main aux touches 1 à 5 ; un sac à dos ajoutera des places plus tard), ce qui est
	autour à droite (objets au sol, joueurs à qui donner). On clique une case pour la choisir, puis une
	action ; « Déplacer » attend un second clic sur la case d'arrivée. On peut aussi prendre un objet
	en restant cliqué dessus et le lâcher sur une autre case (glisser-déposer).
*@

<root>
	@if ( IsOpen && Me is not null )
	{
		<div class="inv-backdrop" onmouseup=@(() => ReleaseDrag())>
			<div class="inv">
				<div class="avatar">
					<ScenePanel @ref="Avatar" class="avatar-3d"></ScenePanel>
					<div class="avatar-name">@Me.RpName</div>
					<div class="avatar-sub">@Me.Job.Name</div>
				</div>
				<div class="pockets">
					<div class="inv-head">
						<div class="inv-title"><i>back_hand</i> Poches <span>à portée de main</span></div>
						<div class="weight">@($"{(Format.Kilos( Weight ))} / {(Format.Kilos( InventoryRules.MaxWeight ))}")</div>
					</div>
					<div class="weight-bar"><div class="weight-fill @(Weight > InventoryRules.MaxWeight * 0.85f ? "heavy" : "")" style="width: @Percent( Weight / InventoryRules.MaxWeight * 100 )"></div></div>

					<div class="grid">
						@for ( var i = 0; i < InventoryRules.Slots; i++ )
						{
							var index = i;
							var slot = Slots.FirstOrDefault( x => x.Slot == index );
							<div class="slot @(Selected == index ? "selected" : "") @(Moving && Selected != index ? "target" : "") @(slot is null ? "empty" : "") @(Dragging && pressed == index ? "drag-from" : "") @(Dragging && hover == index && pressed != index ? "drop-on" : "")"
								onclick=@(() => Click( index )) onmousedown=@(() => PressSlot( index ))
								onmouseenter=@(() => hover = index) onmouseleave=@(() => { if ( hover == index ) hover = -1; })>
								@if ( index < InventoryRules.HotbarSlots )
								{
									<div class="hotkey">@HorizonHud.KeyName( InventoryRules.KeyOf( index ) )</div>
								}
								@if ( slot?.Definition is { } item )
								{
									@if ( item.Picture is { } picture )
									{
										<div class="slot-pic" style="background-image: url( @picture )"></div>
									}
									else
									{
										<i class="cat-@Category( item )">@item.Icon</i>
									}
									<div class="slot-name">@item.Name</div>
									<div class="slot-foot">
										<span>@($"×{(slot.Count)}")</span>
										<span>@Format.Kilos( slot.Weight )</span>
									</div>
								}
							</div>
						}
					</div>

					@if ( SelectedSlot?.Definition is { } chosen )
					{
						<div class="detail">
							<div class="detail-head">
								@if ( chosen.Picture is { } picture )
								{
									<div class="detail-pic" style="background-image: url( @picture )"></div>
								}
								else
								{
									<i class="cat-@Category( chosen )">@chosen.Icon</i>
								}
								<div class="detail-text">
									<div class="detail-name">@chosen.Name</div>
									<div class="detail-sub">@($"{(chosen.Category)} · {(Format.Kilos( chosen.Weight ))} l'unité · ×{(SelectedSlot.Count)}")</div>
								</div>
							</div>
							<div class="detail-desc">@chosen.Description</div>

							<div class="amounts">
								<span>Quantité</span>
								@foreach ( var n in AmountChoices( SelectedSlot.Count ) )
								{
									<div class="chip @(Amount == n ? "on" : "")" onclick=@(() => Amount = n)>@(n == SelectedSlot.Count && n > 1 ? $"Tout ({n})" : n.ToString())</div>
								}
							</div>

							<div class="actions">
								@if ( chosen.Usable )
								{
									<div class="action primary" onclick=@(() => InventoryActions.RequestUse( Selected ))><i>check_circle</i> Utiliser</div>
								}
								@if ( WeaponItems.WeaponOf( chosen.Id ) is not null )
								{
									var drawn = WeaponHolder.Local?.ActiveSerial is { } serial && WeaponData.Read( SelectedSlot.Data )?.Serial == serial;
									<div class="action primary" onclick=@(() => InventoryActions.RequestUse( Selected ))><i>@(drawn ? "back_hand" : "my_location")</i> @(drawn ? "Ranger" : "Sortir")</div>
								}
								@if ( PropItems.EntryOf( chosen.Id ) is var propId && propId > 0 )
								{
									<div class="action primary" onclick=@(() => PlaceProp( propId ))><i>chair</i> Poser</div>
								}
								<div class="action @(Moving ? "on" : "")" onclick=@(() => Moving = !Moving)><i>open_with</i> @(Moving ? "Cliquez la case d'arrivée" : "Déplacer")</div>
								<div class="action danger" onclick=@(() => Drop())><i>delete</i> @($"Jeter ×{(Amount)}")</div>
							</div>
						</div>
					}
					else
					{
						<div class="detail muted">Cliquez un objet pour l'utiliser, le donner, le jeter ou le déplacer. Les touches 1 à 5 sortent ce qu'il y a dans chaque poche, sans ouvrir l'inventaire.</div>
					}

					@* La place du sac à dos : il ajoutera des cases, plus tard. *@
					<div class="backpack">
						<i>backpack</i>
						<div class="backpack-text">
							<div class="backpack-title">Pas de sac à dos</div>
							<div class="backpack-sub">Un sac ajoutera des places. En attendant, tout tient dans vos cinq poches.</div>
						</div>
					</div>
				</div>

				<div class="side">
					<div class="side-block">
						<div class="side-title"><i>pan_tool</i> Au sol, à portée</div>
						@if ( !NearbyDropped.Any() )
						{
							<div class="muted">Rien par terre.</div>
						}
						@foreach ( var dropped in NearbyDropped )
						{
							<div class="side-row" onclick=@(() => InventoryActions.RequestPickup( dropped.GameObject.Id ))>
								@if ( dropped.Definition.Picture is { } picture )
								{
									<div class="side-pic" style="background-image: url( @picture )"></div>
								}
								else
								{
									<i>@dropped.Definition.Icon</i>
								}
								<div class="side-name">@($"{(dropped.Definition.Name)} ×{(dropped.Count)}")</div>
								<div class="side-action">Ramasser</div>
							</div>
						}
					</div>

					<div class="side-block">
						<div class="side-title"><i>group</i> Joueurs proches</div>
						@if ( !NearbyPlayers.Any() )
						{
							<div class="muted">Personne à côté de vous.</div>
						}
						@foreach ( var player in NearbyPlayers )
						{
							<div class="side-row @(SelectedSlot is null ? "disabled" : "")" onclick=@(() => Give( player ))>
								<i>person</i>
								<div class="side-name">@player.RpName</div>
								<div class="side-action">@(SelectedSlot is null ? "Choisir un objet" : "Donner ×" + Amount)</div>
							</div>
						}
					</div>


				</div>

				<div class="inv-close" onclick=@(() => SetOpen( false ))><i>close</i></div>
			</div>
		</div>

		@* L'objet pris, qui suit la souris jusqu'à ce qu'on le lâche. *@
		@if ( DragSlot?.Definition is { } dragged )
		{
			<div class="drag-ghost" style="left: @Px( GhostPosition.x ); top: @Px( GhostPosition.y );">
				@if ( dragged.Picture is { } ghostPicture )
				{
					<div class="slot-pic" style="background-image: url( @ghostPicture )"></div>
				}
				else
				{
					<i class="cat-@Category( dragged )">@dragged.Icon</i>
				}
			</div>
		}
	}
</root>

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

	/// <summary>Le personnage en entier, à gauche des poches, comme sur FiveM.</summary>
	ScenePanel Avatar { get; set; }
	readonly Preview3D avatar = new() { TurnSpeed = 0, BodyYaw = 12f, BodyDistance = 84f, BodyHeight = 28f };

	int Selected { get; set; } = -1;
	bool Moving { get; set; }
	int Amount { get; set; } = 1;

	RoleplayPlayer Me => RoleplayPlayer.Local;
	List<InventorySlot> Slots => Me?.PublishedInventory ?? new();
	InventorySlot SelectedSlot => Slots.FirstOrDefault( x => x.Slot == Selected );
	float Weight => InventoryRules.WeightOf( Slots );

	IEnumerable<DroppedItem> NearbyDropped => Me is null ? Enumerable.Empty<DroppedItem>() :
		DroppedItem.All.Where( x => x.Definition is not null && x.WorldPosition.Distance( Me.WorldPosition ) <= InventoryActions.Reach * 2 ).Take( 8 );

	IEnumerable<RoleplayPlayer> NearbyPlayers => Me is null ? Enumerable.Empty<RoleplayPlayer>() :
		RoleplayPlayer.All.Where( x => x != Me && x.WorldPosition.Distance( Me.WorldPosition ) <= InventoryActions.Reach * 1.5f ).Take( 6 );

	protected override void OnUpdate()
	{
		if ( Me is null || Me.NeedsCharacter )
		{
			if ( IsOpen ) SetOpen( false );
			return;
		}


		// Échap referme ce menu (et ne laisse pas s'ouvrir le menu pause en même temps).
		if ( IsOpen && Input.EscapePressed )
		{
			Input.EscapePressed = false;
			SetOpen( false );
			return;
		}

		// Au volant, pas d'inventaire : ni le menu, ni les cases rapides, ni la molette.
		var driving = Vehicle.OfLocalPlayer is { } car && car.Driver == Me;
		if ( driving )
		{
			if ( IsOpen )
				SetOpen( false );
			if ( Input.Pressed( "Inventory" ) && !Vehicle.RadioUsesKey( "Inventory" ) )
				HudNotifications.Push( "Pas d'inventaire en conduisant.", "info" );
			return;
		}

		if ( Input.Pressed( "Inventory" ) )
			SetOpen( !IsOpen );

		if ( IsOpen && Avatar is not null )
		{
			avatar.Attach( Avatar );
			avatar.ShowCharacter( Me.AppearanceJson, Me.Sex, headOnly: false );
			avatar.Tick();
		}

		if ( IsOpen )
			UpdateDrag();
		else
			pressed = -1;

		// Cases rapides : 1 à 5, sans ouvrir l'inventaire (pas pendant un autre menu).
		if ( !UiLock.IsLocked )
		{
			// 1 à 5 : les poches.
			for ( var i = 0; i < InventoryRules.HotbarSlots; i++ )
			{
				if ( Input.Pressed( InventoryRules.KeyOf( i ) ) && Slots.Any( x => x.Slot == i ) )
					InventoryActions.RequestUse( i );
			}

			// À bord en troisième personne, la molette est réservée au zoom de la caméra.
			if ( Vehicle.OfLocalPlayer is null || Me.GetComponent<PlayerController>()?.ThirdPerson != true )
				SwitchWithWheel();
		}

		// La case choisie a pu être vidée (utilisée, donnée) : on la relâche.
		if ( Selected >= 0 && SelectedSlot is null && !Moving )
			Selected = -1;
	}

	RealTimeSince sinceWheel;

	/// <summary>Range l'arme en main : la case « Mains » de la barre (molette).</summary>
	void PutAway()
	{
		var serial = WeaponHolder.Local?.ActiveSerial;
		var active = Slots.FirstOrDefault( x => serial is not null && WeaponData.Read( x.Data )?.Serial == serial );
		if ( active is not null )
			InventoryActions.RequestUse( active.Slot );
		HorizonHud.ShowHotbar( HorizonHud.HandsPosition );
	}

	/// <summary>
	/// La molette avance d'une case à la fois : Mains → poche 1 → … → poche 5 → Mains.
	/// Une poche qui contient une arme la sort ; Mains, ou une poche sans arme, range
	/// l'arme en main sans rien utiliser : on ne mange pas un sandwich en faisant
	/// défiler, il se mange à sa touche. Coupée quand la molette sert déjà (pose d'un
	/// prop ou d'un objet, physgun) : SWB.Base.Weapon.InputBlocked le dit.
	/// </summary>
	void SwitchWithWheel()
	{
		var wheel = Input.MouseWheel.y;
		if ( wheel == 0 || sinceWheel < 0.12f || SWB.Base.Weapon.InputBlocked?.Invoke( null ) == true )
			return;
		sinceWheel = 0;

		// Positions de la barre : 0 = Mains, puis 1 à 5 = poches 0 à 4, puis 6 = Billets.
		var count = InventoryRules.HotbarSlots + 2;
		var position = ((HorizonHud.SelectedPocket + (wheel < 0 ? 1 : -1)) % count + count) % count;
		HorizonHud.ShowHotbar( position );
		var cash = position == HorizonHud.CashPosition;
		var pocket = cash ? -1 : position - 1;

		// Les billets : on les prend en main (l'hôte range l'arme ou la clé) ; toute autre case
		// les range.
		if ( cash )
		{
			Cash.RequestHold( true );
			return;
		}
		if ( Cash.IsHeld( RoleplayPlayer.Local ) )
			Cash.RequestHold( false );

		var serial = WeaponHolder.Local?.ActiveSerial;
		var target = pocket < 0 ? null : Slots.FirstOrDefault( x => x.Slot == pocket );
		var active = Slots.FirstOrDefault( x => serial is not null && WeaponData.Read( x.Data )?.Serial == serial );

		// Une clé de véhicule ou un prop se prend en main ; toute autre case range ce qu'on tenait.
		// Le prop était oublié ici : à la molette, sa poche s'allumait et les mains restaient
		// vides, sans carton à poser (Julien, 30/10/2026). Les touches 1 à 5, elles, le prenaient.
		var heldKey = RoleplayPlayer.Local?.HeldKey;
		if ( target is not null && (VehicleKeys.PlateOf( target.Item ) is not null || PropItems.EntryOf( target.Item ) > 0) )
		{
			if ( heldKey != target.Item )
				InventoryActions.RequestUse( pocket );   // l'hôte range l'arme ou l'autre clé
			return;
		}
		if ( !string.IsNullOrEmpty( heldKey ) && Slots.FirstOrDefault( x => x.Item == heldKey ) is { } keySlot )
			InventoryActions.RequestUse( keySlot.Slot );

		if ( target is not null && WeaponItems.WeaponOf( target.Item ) is not null )
		{
			if ( target != active )
				InventoryActions.RequestUse( pocket );   // l'hôte range l'autre et sort celle-ci
		}
		else if ( active is not null )
			InventoryActions.RequestUse( active.Slot );  // poche sans arme : mains vides
	}

	void SetOpen( bool open )
	{
		IsOpen = open;
		Moving = false;
		Selected = -1;
		UiLock.Set( "inventaire", open );
	}

	protected override void OnDisabled() => UiLock.Set( "inventaire", false );

	// Glisser-déposer (Julien, 29/10/2026) : appuyer sur une case pleine, bouger de quelques pixels
	// et l'objet suit la souris ; lâché sur une case, il y va (même règle que « Déplacer » : poser,
	// empiler ou échanger, décidé par l'hôte), lâché ailleurs, il reste où il était. Sans bouger,
	// le clic choisit la case comme avant.

	int pressed = -1;
	Vector2 pressedAt;
	int hover = -1;
	bool Dragging { get; set; }
	RealTimeSince sinceDrop = 10;

	InventorySlot DragSlot => Dragging ? Slots.FirstOrDefault( x => x.Slot == pressed ) : null;

	/// <summary>Le coin de l'objet pris, sous la souris (en pixels de l'interface).</summary>
	Vector2 GhostPosition => Mouse.Position * Panel.ScaleFromScreen - new Vector2( 46, 46 );

	static string Px( float value ) => FormattableString.Invariant( $"{value:0}px" );

	void PressSlot( int index )
	{
		if ( !Slots.Any( x => x.Slot == index ) )
			return;
		pressed = index;
		pressedAt = Mouse.Position;
		Dragging = false;
	}

	void UpdateDrag()
	{
		if ( pressed < 0 || Dragging )
			return;
		if ( (Mouse.Position - pressedAt).Length > 8f )
		{
			Dragging = true;
			Moving = false;
		}
	}

	void ReleaseDrag()
	{
		if ( Dragging )
		{
			if ( hover >= 0 && hover != pressed )
			{
				InventoryActions.RequestMove( pressed, hover );
				Selected = hover;
			}
			// Le clic qui suit le relâchement n'est pas un choix de case.
			sinceDrop = 0;
		}
		pressed = -1;
		Dragging = false;
	}

	void Click( int index )
	{
		if ( sinceDrop < 0.25f )
			return;

		if ( Moving && Selected >= 0 )
		{
			InventoryActions.RequestMove( Selected, index );
			Moving = false;
			Selected = index;
			return;
		}

		Selected = Selected == index || !Slots.Any( x => x.Slot == index ) ? -1 : index;
		Amount = 1;
	}

	void Drop()
	{
		if ( SelectedSlot is null )
			return;
		InventoryActions.RequestDrop( Selected, Amount );
		Amount = 1;
	}

	void Give( RoleplayPlayer player )
	{
		if ( SelectedSlot is null )
			return;
		InventoryActions.RequestGive( Selected, Amount, player.SteamId );
		Amount = 1;
	}

	static IEnumerable<int> AmountChoices( int count ) => new[] { 1, 5, 10, count }.Where( x => x <= count ).Distinct();

	/// <summary>Un prop en poche : l'inventaire se ferme et la pose commence, comme depuis le menu P.</summary>
	void PlaceProp( long entryId )
	{
		SetOpen( false );
		Scene.GetAllComponents<PropMenu>().FirstOrDefault()?.PlaceFromInventory( entryId );
	}

	static string Category( ItemDefinition item ) => item.Category switch
	{
		"Nourriture" or "Boisson" => "food",
		"Soins" => "care",
		"Outils" or "Props" => "tool",
		_ => "misc",
	};

	static string Percent( float value ) => $"{Math.Clamp( value, 0, 100 ):0.#}%";

	protected override int BuildHash() => HashCode.Combine(
		HashCode.Combine( IsOpen, Selected, Moving, Amount, WeaponHolder.Local?.ActiveSerial,
			HashCode.Combine( Dragging, hover, Dragging ? (int)(Mouse.Position.x / 2) : 0, Dragging ? (int)(Mouse.Position.y / 2) : 0 ) ),
		Me?.InventoryJson,
		NearbyDropped.Aggregate( 0, ( h, x ) => HashCode.Combine( h, x.GameObject.Id, x.Count ) ),
		NearbyPlayers.Aggregate( 0, ( h, x ) => HashCode.Combine( h, x.SteamId ) ) );
}