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 ) ) );
}