Demos/TarkovInventory/TarkovStashUI.cs
using System.Collections.Generic;
using Goo;
using Sandbox;
using Sandbox.UI;
using static Sandbox.TarkovInventory.StashTheme;
namespace Sandbox.TarkovInventory;
// dense stash grid with multi-cell drag-to-place, built on the Goo DnD primitive (single ancestor DropZone + positional DropLocation); lives in a full-screen modal toggled by "I", per-item-type solid tints (no typed gradient primitive yet).
public sealed class TarkovStashUI : GooPanel<Container>
{
const int Cols = 10;
const int Rows = 9;
const string ToggleKey = "I";
const string RotateKey = "R";
const string SplitKey = "shift"; // engine default Run action binds this string (Input.Common.cs)
const string CancelKey = "escape";
// Sizes, colors, fonts and pure tile geometry live in StashTheme (imported via `using static`
// above) so the controller and the StashTileBlob/SplitModalBlob presenters share one source.
Grid<string>? _grid;
Loadout? _loadout;
AvatarView? _avatar;
readonly Dictionary<string, StashItem> _items = new();
readonly DragContext<string> _dnd = new();
(int x, int y)? _hoverAnchor;
bool _open = true;
bool _togglePrev;
// Per-item orientation (id -> rotated-90); missing key means upright. _dragRotated tracks the in-flight drag.
readonly Dictionary<string, bool> _rotated = new();
bool _dragRotated;
bool _rotatePrev;
string? _dragId;
// split modal state: _splitId = open item (null = closed), _splitAmount = slider value, _splitHasRoom = free 1x1 existed at open (drives disabled-Split), _splitSeq = monotonic id counter.
string? _splitId;
int _splitAmount;
bool _splitHasRoom;
int _splitSeq;
bool _escPrev;
// rising-edge I-key toggle then base rebuild; OnUpdate ticks every frame, making it the natural place for input polling on a GooPanel.
protected override void OnUpdate()
{
bool down = Sandbox.Input.Keyboard.Down( ToggleKey );
if ( down && !_togglePrev ) { _open = !_open; Rebuild(); }
_togglePrev = down;
// Seed the drag orientation from the item's stored rotation when a drag begins; clear on end.
if ( _dnd.IsDragging )
{
if ( _dragId != _dnd.Payload )
{
_dragId = _dnd.Payload;
_dragRotated = _rotated.GetValueOrDefault( _dnd.Payload );
}
bool r = Sandbox.Input.Keyboard.Down( RotateKey );
if ( r && !_rotatePrev ) { _dragRotated = !_dragRotated; Rebuild(); }
_rotatePrev = r;
}
else
{
_dragId = null;
_rotatePrev = false;
}
// Esc closes the split modal (rising-edge, same idiom as the I/R polls above).
bool esc = Sandbox.Input.Keyboard.Down( CancelKey );
if ( esc && !_escPrev && _splitId != null ) { _splitId = null; Rebuild(); }
_escPrev = esc;
base.OnUpdate();
}
// Only a stackable item with at least two in the pile can be split (gems; never magazines,
// whose Count is rounds-loaded display with MaxStack 1). Mirrors stacking invariant #1.
static bool IsSplittable( StashItem item ) => item.MaxStack > 1 && item.Count >= 2;
// Shift + left-click on a splittable item opens the centered split modal. A pure click fires
// OnClick (the engine starts a drag only on press-and-move, via OnDragStart), so this never
// collides with dragging. The slider defaults to the round-up half of the stack.
void TryOpenSplit( StashItem item )
{
if ( !Sandbox.Input.Keyboard.Down( SplitKey ) || !IsSplittable( item ) ) return;
_splitId = item.Id;
_splitAmount = StashItem.DefaultSplit( item.Count );
_splitHasRoom = Stash().FindFirstFree( 1, 1 ) is not null;
Rebuild();
}
// Ctors do not run on hotload; build lazily (NestedInventory idiom).
Grid<string> Stash()
{
if ( _grid != null ) return _grid;
_grid = new Grid<string>( Cols, Rows );
foreach ( var item in StashCatalog.Dense() )
{
var spot = _grid.FindFirstFree( item.W, item.H );
if ( spot is { } r && _grid.TryPlace( item.Id, r ) )
_items[item.Id] = item;
}
return _grid;
}
// Lazily build the equip model (ctors do not run on hotload, same idiom as Stash()).
Loadout Gear()
{
if ( _loadout != null ) return _loadout;
// Helmet + weapon are functional (matching items exist in the stash); top/bottom/shoes are
// labeled stubs with empty Accepts - they render as placeholders and reject every drop until
// there is clothing/items to fill them. Adding a real item later is a one-line Accepts change.
_loadout = new Loadout( new[]
{
new EquipSlot( "helmet", "Helmet", new HashSet<ItemKind> { ItemKind.Helmet } ),
new EquipSlot( "top", "Top", new HashSet<ItemKind>() ),
new EquipSlot( "bottom", "Bottom", new HashSet<ItemKind>() ),
new EquipSlot( "shoes", "Shoes", new HashSet<ItemKind>() ),
new EquipSlot( "weapon", "Weapon", new HashSet<ItemKind> { ItemKind.Rifle } ),
} );
return _loadout;
}
// Lazily build the avatar view (ctors do not run on hotload, same idiom as Gear()).
AvatarView Avatar() => _avatar ??= new AvatarView();
protected override Container Build()
{
// Closed: an empty root (no children). The idle->open transition diffs as keyed inserts
// because every child below carries a Key (engine-fact: avoids the all-unkeyed length warning).
if ( !_open )
return new Container { Position = PositionMode.Absolute, PointerEvents = PointerEvents.None };
var grid = Stash();
Avatar().Sync( Gear() ); // avatar tracks the equip state on every rebuild
return new Container
{
Position = PositionMode.Absolute,
Left = 0f, Top = 0f,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
PointerEvents = PointerEvents.None,
Children =
{
new Container
{
Key = "backdrop",
Position = PositionMode.Absolute,
Left = 0f, Top = 0f,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
BackgroundColor = Backdrop,
PointerEvents = PointerEvents.All,
},
Shell( grid ),
Cell.Mount<DragLayer<string>>( key: "drag-layer", configure: l => l.Context = _dnd ),
_splitId != null
? SplitModal()
: new Container { Key = "split-slot", Position = PositionMode.Absolute, PointerEvents = PointerEvents.None },
},
};
}
// Centered split window over an additional backdrop dim. Shift+click a gem to open it; the slider
// picks how many to peel off. Pure presentation lives in SplitModalBlob; this wires the state in.
Container SplitModal() => SplitModalBlob.Build( new SplitModalBlob.Props(
Item: _items[_splitId!],
Amount: _splitAmount,
HasRoom: _splitHasRoom,
OnCancel: () => { _splitId = null; Rebuild(); },
OnAmount: v => { _splitAmount = (int)v; Rebuild(); },
OnSplit: DoSplit ) );
// Peel _splitAmount off the open item into the first free 1x1 cell as a new stack, then close.
void DoSplit()
{
if ( _splitId is not { } id || !_items.TryGetValue( id, out var src ) ) { _splitId = null; Rebuild(); return; }
int n = _splitAmount;
if ( n >= 1 && n < src.Count )
{
var grid = Stash();
if ( grid.FindFirstFree( 1, 1 ) is { } spot )
{
var newId = $"{id}#{++_splitSeq}";
var (reduced, peeled) = StashItem.Split( src, n, newId );
if ( grid.TryPlace( peeled.Id, spot ) )
{
_items[peeled.Id] = peeled;
_items[id] = reduced;
}
}
}
_splitId = null;
Rebuild();
}
// Drop an item onto an equip slot. Type mismatch is rejected (DnD snaps the item back). The
// incoming item's grid cells are freed first, then a displaced occupant is re-homed to the
// first free cell; if it cannot be re-homed the swap is reverted, so no item is ever lost.
void EquipDrop( string slotId, string id )
{
if ( !_items.TryGetValue( id, out var item ) ) return;
var gear = Gear();
var grid = Stash();
if ( !gear.CanEquip( slotId, item.Kind ) ) return;
if ( gear.Occupant( slotId ) == id ) { Rebuild(); return; }
bool fromGrid = grid.Placed.TryGetValue( id, out var prev );
grid.Remove( id );
GridRect? spot = null;
if ( gear.Occupant( slotId ) is { } old && _items.TryGetValue( old, out var oldItem ) )
{
var (w, h) = Eff( oldItem, _rotated.GetValueOrDefault( old ) );
spot = grid.FindFirstFree( w, h );
if ( spot is null )
{
if ( fromGrid ) grid.TryPlace( id, prev );
return;
}
}
if ( gear.SlotOf( id ) is { } from && from != slotId ) gear.Unequip( from );
var result = gear.Equip( slotId, id, item.Kind );
if ( result.Displaced is { } disp && spot is { } s ) grid.TryPlace( disp, s );
_hoverAnchor = null;
Rebuild();
}
// RPG paper-doll: relative body is a positioned ancestor (engine-fact-absolute-needs-positioned-ancestor) for slot anchors; scene frame must be a Column with FlexGrow ScenePanel, never Height=Percent(100) which collapses to zero (black avatar bug).
Container CharacterBody()
{
var body = new Container
{
Key = "character-body",
Position = PositionMode.Relative,
FlexGrow = 1f,
PointerEvents = PointerEvents.None,
};
// Central live avatar, inset from the edges to leave perimeter bands for the slots. No
// backdrop: the scene clears to transparent (camera BackgroundColor alpha 0 in avatar.scene)
// so only Terry draws, over the panel's worn-metal crate skin behind him.
body.Children.Add( new Container
{
Key = "avatar-frame",
Position = PositionMode.Absolute,
Left = 116f, Right = 116f, Top = 112f, Bottom = 14f,
FlexDirection = FlexDirection.Column,
PointerEvents = PointerEvents.None,
Children =
{
new Goo.ScenePanel
{
Key = "avatar",
Scene = Avatar().Scene,
FlexGrow = 1f,
Width = Length.Percent( 100 ),
},
},
} );
// Helmet centred on the top edge; weapon centred on the right edge; the body slots
// (top/bottom/shoes) stack down the left column. Anchors are starting values - nudge at UAT.
if ( Gear().Slot( "helmet" ) is { } helmet )
body.Children.Add( PlacedSlot( helmet, left: Length.Percent( 50 ), top: 10f, marginLeft: -EquipSlotSize / 2f ) );
if ( Gear().Slot( "weapon" ) is { } weapon )
body.Children.Add( PlacedSlot( weapon, right: 10f, top: Length.Percent( 50 ), marginTop: -EquipSlotSize / 2f ) );
body.Children.Add( LeftColumn() );
return body;
}
// A single equip slot pinned at an absolute anchor in the character body. Unset anchors stay
// null (omitted) so callers pick any edge/centre combination; the optional margins centre a slot
// on a percentage anchor (Left 50% + MarginLeft -half = horizontally centred).
Container PlacedSlot( EquipSlot slot, Length? left = null, Length? top = null,
Length? right = null, Length? bottom = null, float marginLeft = 0f, float marginTop = 0f )
=> new Container
{
Key = $"slot-anchor-{slot.Id}",
Position = PositionMode.Absolute,
Left = left, Top = top, Right = right, Bottom = bottom,
MarginLeft = marginLeft, MarginTop = marginTop,
Width = EquipSlotSize, Height = EquipSlotSize,
PointerEvents = PointerEvents.All,
Children = { EquipSlotZone( slot ) },
};
// The left column of body slots (top/bottom/shoes), stacked top-down along the left edge.
Container LeftColumn()
{
var col = new Container
{
Key = "left-col",
Position = PositionMode.Absolute,
Left = 10f, Top = 112f,
FlexDirection = FlexDirection.Column,
Gap = 12f,
PointerEvents = PointerEvents.All,
};
foreach ( var id in new[] { "top", "bottom", "shoes" } )
if ( Gear().Slot( id ) is { } slot )
col.Children.Add( new Container
{
Key = $"slot-anchor-{id}",
Width = EquipSlotSize, Height = EquipSlotSize,
PointerEvents = PointerEvents.All,
Children = { EquipSlotZone( slot ) },
} );
return col;
}
CellElement EquipSlotZone( EquipSlot slot ) =>
Cell.Mount<DropZone<string>>( key: $"equip-{slot.Id}", configure: z =>
{
z.Context = _dnd;
z.OnDropPayload = ( id, _ ) => EquipDrop( slot.Id, id );
z.Content = _ => EquipSlotTile( slot );
} );
Container EquipSlotTile( EquipSlot slot )
{
var bg = SlotBg;
if ( _dnd.IsDragging && _dnd.HasPayload && _items.TryGetValue( _dnd.Payload, out var held ) )
bg = slot.CanHold( held.Kind ) ? OkTint : BadTint;
var tile = new Container
{
Key = "tile",
Position = PositionMode.Relative,
Width = EquipSlotSize, Height = EquipSlotSize,
BackgroundColor = bg,
BorderRadius = 6f,
BorderWidth = 1f,
BorderColor = ItemBdr,
JustifyContent = Justify.Center,
AlignItems = Align.Center,
PointerEvents = PointerEvents.All,
};
if ( Gear().Occupant( slot.Id ) is { } id && _items.TryGetValue( id, out var item ) )
tile.Children.Add( EquipOccupant( id, item ) );
else
tile.Children.Add( new Text( slot.Label.ToUpperInvariant() ) // faint label marks an empty slot
{
Key = "lbl",
FontColor = Faint,
FontFamily = CrateTheme.Font,
FontSize = 12f,
LetterSpacing = 1f,
} );
return tile;
}
// The equipped item's glyph, draggable back out via a DragSource over the shared context.
CellElement EquipOccupant( string id, StashItem item ) =>
Cell.Mount<DragSource<string>>( key: $"equipped-{id}", configure: s =>
{
s.Context = _dnd;
s.Payload = id;
s.Content = dragging => new Container
{
Key = "g",
JustifyContent = Justify.Center,
AlignItems = Align.Center,
Width = EquipSlotSize, Height = EquipSlotSize,
Opacity = dragging ? 0.35f : 1f,
Children =
{
new Goo.SvgPanel { Key = "svg", Path = item.Icon, Color = IconTint,
Width = EquipSlotSize - 20f, Height = EquipSlotSize - 20f },
},
};
s.Ghost = () => StashTileBlob.Ghost( item, _dragRotated );
} );
// Two-column crate shell: a full-screen flex row over the backdrop with the paper-doll and stash centered. The stash never shrinks so the cursor-to-cell measurement holds; PointerEvents.None on the row lets the backdrop catch gap clicks.
Container Shell( Grid<string> grid ) => new Container
{
Key = "shell",
Position = PositionMode.Absolute,
Left = 0f, Top = 0f,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
FlexDirection = FlexDirection.Row,
AlignItems = Align.Stretch,
JustifyContent = Justify.Center,
Gap = 12f,
Padding = 16f,
PointerEvents = PointerEvents.None,
Children =
{
CratePanel.Panel( "character-panel", "CHARACTER", CharacterBody(), width: 560f ),
CratePanel.Panel( "stash-panel", "STASH", StashBody( grid ), hint: "[I] close" ),
},
};
// Stash body: the exact-sized grid wrapper that hosts the DropZone. Size MUST stay
// Cols*SlotSize x Rows*SlotSize (the DropZone measures this wrapper via e.This for the
// cursor->cell mapping); centered in the panel, never stretched.
Container StashBody( Grid<string> grid ) => new Container
{
Key = "stash-body",
FlexGrow = 1f,
AlignItems = Align.Center,
JustifyContent = Justify.Center,
Padding = 10f,
PointerEvents = PointerEvents.None,
Children =
{
new Container
{
Key = "grid-wrap",
Width = Cols * SlotSize,
Height = Rows * SlotSize,
FlexShrink = 0,
PointerEvents = PointerEvents.All,
Children = { GridZone( grid ) },
},
},
};
CellElement GridZone( Grid<string> grid ) =>
Cell.Mount<DropZone<string>>( key: "grid", configure: z =>
{
z.Context = _dnd;
z.OnHover = loc =>
_hoverAnchor = loc is { } l ? GridGeometry.CellAt( l.Local, l.ZoneSize, Cols, Rows ) : null;
z.OnDropPayload = ( id, loc ) =>
{
var (cx, cy) = GridGeometry.CellAt( loc.Local, loc.ZoneSize, Cols, Rows );
var src = _items[id];
// An equipped item dragged back to the grid: unequip, then place at the target cell
// (or the first free spot if the target is taken). If the grid is full, re-equip.
if ( Gear().SlotOf( id ) is { } slot )
{
Gear().Unequip( slot );
var (ew, eh) = Eff( src, _dragRotated );
if ( grid.TryPlace( id, new GridRect( cx, cy, ew, eh ) )
|| (grid.FindFirstFree( ew, eh ) is { } free && grid.TryPlace( id, free )) )
_rotated[id] = _dragRotated;
else
Gear().Equip( slot, id, src.Kind );
_hoverAnchor = null;
Rebuild();
return;
}
// Stack onto a same-kind stackable item under the anchor cell instead of moving.
if ( grid.ItemAt( cx, cy ) is { } tid && tid != id
&& _items.TryGetValue( tid, out var tgt ) && StashItem.CanStack( src, tgt )
&& tgt.Count < tgt.MaxStack )
{
var (merged, leftover) = StashItem.Stack( src, tgt );
_items[tid] = merged;
if ( leftover > 0 )
{
_items[id] = src with { Count = leftover }; // partial: source stays put with the remainder
}
else
{
grid.Remove( id ); // full merge: source consumed
_items.Remove( id );
_rotated.Remove( id );
}
_hoverAnchor = null;
Rebuild();
return;
}
var (w, h) = Eff( src, _dragRotated );
if ( grid.TryMove( id, new GridRect( cx, cy, w, h ) ) )
_rotated[id] = _dragRotated;
_hoverAnchor = null;
Rebuild();
};
z.Content = _ => GridFrame( grid );
} );
Container GridFrame( Grid<string> grid )
{
// relative + sized so e.This.Box.Rect equals the rendered grid (fixes cursor->cell mapping) and keeps a positioned ancestor for absolute cell tiles (engine-fact-absolute-needs-positioned-ancestor).
var frame = new Container
{
Key = "frame",
Position = PositionMode.Relative,
Width = Cols * SlotSize,
Height = Rows * SlotSize,
BackgroundColor = GridBg,
BorderRadius = 6f,
PointerEvents = PointerEvents.All,
};
// while dragging, cells paint above items so every square is the topmost drop target (engine-fact-sbox-ui-drop-dispatch: dragenter fires on DropTarget change); idle, items paint above for a clean look.
void AddCells()
{
for ( int y = 0; y < Rows; y++ )
for ( int x = 0; x < Cols; x++ )
frame.Children.Add( CellTile( x, y, grid ) );
}
void AddItems()
{
foreach ( var kv in grid.Placed )
frame.Children.Add( ItemView( kv.Key, kv.Value ) );
}
if ( _dnd.IsDragging ) { AddItems(); AddCells(); }
else { AddCells(); AddItems(); }
return frame;
}
Container CellTile( int x, int y, Grid<string> grid )
{
var bg = CellBg;
if ( _dnd.IsDragging && _hoverAnchor is { } a && InFootprint( x, y, a, out var rect ) )
{
if ( grid.CanPlaceIgnoring( rect, _dnd.Payload ) )
bg = OkTint;
else
bg = IsMergeTarget( grid, x, y ) ? MergeTint : BadTint;
}
// Inset by 1px so the dark frame shows through as grid lines (no border props needed).
return new Container
{
Key = $"cell-{x}-{y}",
Position = PositionMode.Absolute,
Left = x * SlotSize + 1f, Top = y * SlotSize + 1f,
Width = SlotSize - 2f, Height = SlotSize - 2f,
BackgroundColor = bg,
PointerEvents = PointerEvents.All, // makes each cell a DropTarget → per-cell enter
};
}
bool InFootprint( int x, int y, (int x, int y) anchor, out GridRect rect )
{
int w = 1, h = 1;
if ( _dnd.HasPayload && _items.TryGetValue( _dnd.Payload, out var item ) )
(w, h) = Eff( item, _dragRotated );
rect = new GridRect( anchor.x, anchor.y, w, h );
return x >= anchor.x && x < anchor.x + w && y >= anchor.y && y < anchor.y + h;
}
// True when the dragged payload would stack onto the item occupying (x, y) and that stack has room.
bool IsMergeTarget( Grid<string> grid, int x, int y )
{
if ( !_dnd.HasPayload || !_items.TryGetValue( _dnd.Payload, out var src ) ) return false;
if ( grid.ItemAt( x, y ) is not { } tid || tid == _dnd.Payload ) return false;
return _items.TryGetValue( tid, out var tgt ) && StashItem.CanStack( src, tgt ) && tgt.Count < tgt.MaxStack;
}
Container ItemView( string id, GridRect rect )
{
var item = _items[id];
return new Container
{
Key = $"slot-{id}",
Position = PositionMode.Absolute,
Left = rect.X * SlotSize, Top = rect.Y * SlotSize,
PointerEvents = PointerEvents.All,
Children =
{
Cell.Mount<DragSource<string>>( key: $"item-{id}", configure: s =>
{
s.Context = _dnd;
s.Payload = id;
s.Content = dragging => StashTileBlob.Tile( item, rect, dragging, _rotated.GetValueOrDefault( id ), _ => TryOpenSplit( item ) );
s.Ghost = () => StashTileBlob.Ghost( item, _dragRotated );
} ),
},
};
}
}