Drag And Drop инвентарь в s&box
В s&box недавно было добавлено API для Drag And Drop. Предлагаю посмотреть, как с помощью (или вопреки) ему сделать инвентарь с сеткой и drag and drop.

Приступая к работе
Недавно я как раз попробовал такой интерфейс реализовать — результат можно видеть на титульной картинке выше. Но тут мы ограничимся целью попроще: сделать инвентарь с сеткой и DnD с нуля, в который можно будет положить предмет и перемещать внутри этого инвентаря — этого хватит для рассмотрения специфики s&box.
Начнём мы с вёрстки сетки:
- Интуитивный вариант: сделать сетку NxM квадратных слотов, границу между ними задать через
border-width: 1px. Мы по этому варианту не пойдём, так как в таком случае у каждого элемента будет граница, которая сложится и получится два пикселя. - Не интуитивный вариант: делаем те же NxM слотов, но задаём сетку через
gap: 1px, а сами линии сетки будут сделаны за счёт панели, которая будет виднеться за просветами между клетками.
Код:
Inventory.razor:
@inherits PanelComponent
<root>
<div class="outline">
<div class="background">
@for (var i = 0; i < Rows; i++)
{
<div class="row">
@for (var j = 0; j < Cols; j++)
{
<InventoryCell/>
}
</div>
}
</div>
</div>
</root>
@code {
public int Rows { get; set; } = 5;
public int Cols { get; set; } = 5;
}
Inventory.razor.scss:
$grid-color: #FFFFFFf0;
Inventory {
position: absolute;
left: 30%;
top: 20%;
bottom: 20%;
right: 30%;
pointer-events: all;
background-color: #000000F0;
backdrop-filter: blur(10px);
justify-content: center;
align-items: center;
> .outline {
background-color: $grid-color;
padding: 1px;
> .background {
position: relative;
background-color: $grid-color;
flex-direction: column;
gap: 1px;
> .row {
flex-direction: row;
gap: 1px;
}
}
}
}
InventoryCell.razor
@inherits Panel
<root/>
InventoryCell.razor.scss
InventoryCell {
width: 96px;
height: 96px;
background-color: #000000FA;
}
Разбираем:
outlineявляется "рамкой" для сетки и идёт отдельно, чтобы в дальнейшем не пришлось отдельно учитывать сдвиг на один пиксель относительно начала.backgroundсоставляет внутреннюю часть сетки, становится видим благодаря тому, чтоgapу клеток в один пиксель.- также он имеет позицию relative, что будет важно далее.
- Клетка имеет размер в 96 пикселей. Если оставим 100, то будут оставаться небольшие зазоры из-за округления, так как по умолчанию s&box масштабирует картинку относительно 1080p, и некоторые артефакты округления могут накапливаться. 96 пикселей в этом плане более безопасный, хотя и не полностью — если сжать окно редактора по вертикали, то зазор появится и там.
По итогу сетка будет выглядеть примерно так:

Отображение предметов
Сетка у нас есть, но пока она исключительно декоративная, так что следующим шагом добавим отображение предметов. Для начала нужен сам класс предмета:
InventoryItem.cs
public class InventoryItem
{
public int Row { get; set; }
public int Col { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public string Icon { get; set; } = "handyman";
}
А также визуальная обёртка над ним:
InventoryItemPanel.razor
@inherits Panel
<root>
<i>@Icon</i>
</root>
@code {
[Parameter]
public InventoryItem Item { get; set; }
[Parameter]
public Inventory Owner { get; set; }
public int Row => Item.Row;
public int Col => Item.Col;
public int Width => Item.Width;
public int Height => Item.Height;
public string Icon => Item.Icon;
public sealed override void Tick()
{
Style.Width = 96 * Width + 1 * (Width - 1);
Style.Height = 96 * Height + 1 * (Height - 1);
SetPosition();
}
protected virtual void SetPosition()
{
Style.Left = Col * 96 + 1 * Col;
Style.Top = Row * 96 + 1 * Row;
}
}
InventoryItemPanel.razor.scss
InventoryItemPanel {
background-color: #000000FA;
color: white;
position: absolute;
font-size: 128px;
justify-content: center;
align-items: center;
}
Отметим здесь следующее:
- У предметов
position: absolute. Благодаря тому, что уbackgroundмы выставилиposition: relativeпредметы будут позиционироваться относительно сетки, а не всего экрана. - В
TickиSetPositionдинамически проставляем размер и позицию, не забывая учитывать пробелы между клетками в один пиксель- При выставлении размера не забываем добавить длину с учётом промежутков между клетками, на которые попадает панель.
- При выставлении позиции также учитываем, сколько было промежутков до этой клетки.
SetPositionотделён отTickне случайно, об этом сразу следом.
- Также здесь можно заметить размножение магического числа
96— в реальном коде так делать не стоит, и лучше вынести его в константу.
Теперь осталось только добавить в Inventory.razor создание и отображение нашего предмета: Inventory.razor
<div class="background">
@* ... *@
@if (item != null)
{
<InventoryItemPanel Item="@item" Owner="@this"/>
}
</div>
@code {
// ...
private InventoryItem item;
protected override void OnStart()
{
item = new InventoryItem
{
Width = 3,
Height = 2,
Row = 1,
Col = 1
};
}
}
Результат же должен выглядеть так:

Drag
Выглядит уже неплохо, но пока это только статичная картинка, так что время добавлять DnD. Однако просто взять и перетащить ту же панель, что находится внутри сетки, мы, к сожалению, не можем. Причина становится очевидной, если задуматься о сути DOM, хоть тут мы имеем дело и не с ней. Когда элемент находится в DOM, его свойства определяются не только ним самим, но и всеми его окружающими элементами, так что попробовать поменять положение в иерархии, конечно, можно, но закончится это скорее всего печально (я пытался).
Вместо этого нам надо создать "призрачный" элемент — копию, которая будет привязана к совсем другому месту, тем самым ничего точно не ломая. К счастью, так как razor-компоненты являются обычными классами в C#, благодаря наследованию копировать логику нам не придётся, и можно просто унаследоваться от InventoryItemPanel:
DraggedInventoryItemPanel.cs
[StyleSheet("InventoryItemPanel.razor.scss")]
public class DraggedInventoryItemPanel : InventoryItemPanel
{
[Property]
public DragEvent DragEvent { get; set; }
public override bool WantsDrag => false;
protected override void SetPosition()
{
var mouse = Mouse.Position;
Style.Left = Length.Pixels(mouse.x * ScaleFromScreen - DragEvent.LocalGrabPosition.x * ScaleFromScreen);
Style.Top = Length.Pixels(mouse.y * ScaleFromScreen - DragEvent.LocalGrabPosition.y * ScaleFromScreen);
}
}
Здесь стоит обратить внимание на следующее:
SetPositionмы как раз и отделили для переопределения. Делаем это так, чтобы позиция предмета шла за курсором.- В координатах курсора всегда есть сдвиг по экрану, так что умножаем позиции курсора на
ScaleFromScreen.
- В координатах курсора всегда есть сдвиг по экрану, так что умножаем позиции курсора на
- В
DragEventпередаётся информация о том, где был схвачен предмет — благодаря этому курсор на "призрачном" предмете будет именно там, где он был при начале перетаскивания. WantsDragпереопределён и возвращаетfalse. Это его значение по умолчанию, но мы скоро вернёмся в InventoryItemPanel и переопределим там это свойство.- Атрибут
StyleSheetговорит, куда идти за стилями. Нам это пригодится, чтобы не дублировать их, можно просто вернуться в прежний файл и немного дополнить его.
InventoryItemPanel.razor.scss
InventoryItemPanel, DraggedInventoryItemPanel {
// ...
}
DraggedInventoryItemPanel {
background-color: #000000AA;
border-width: 1px;
border-color: white;
}
Указав стиль через запятую, мы применили его на оба элемента, а нужные нам отличия задали в отдельном селекторе.
Сам "призрачный" элемент у нас есть, теперь нужен кто-то, кто будет им владеть. За основу возьмём DragHandler из самого sandbox.
DragHandler.razor
@inherits PanelComponent
<root>
@if (Current is not null)
{
<DraggedInventoryItemPanel @ref="_draggedOverlay" class="dragging" Item="@Current.Item" DragEvent="@_currentDragEvent" />
}
</root>
@code
{
public static DragHandler Instance { get; private set; }
public static bool IsDragging => Instance?.Current is not null;
public static Panel DraggedVisual => Instance?._draggedOverlay;
public InventoryItemPanel Current { get; set; }
private DragEvent _currentDragEvent;
private DraggedInventoryItemPanel _draggedOverlay;
protected override void OnStart()
=> Instance = this;
protected override void OnDestroy()
{
Instance = null;
Current = null;
}
public static void StartDragging(InventoryItemPanel data, DragEvent e)
{
if (Instance is null) return;
Instance.Current = data;
Instance._currentDragEvent = e;
Instance.StateHasChanged();
}
public static void StopDragging()
{
if (!IsDragging || Instance is null) return;
Instance._draggedOverlay = null;
Instance.Current = null;
Instance.StateHasChanged();
}
protected override int BuildHash() => HashCode.Combine(Current);
}
DragHandler.razor.scss
DragHandler
{
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 10000;
pointer-events: none;
}
Это отдельный компонент, который для удобства имеет статические указатели на себя, но никто не мешает использовать компонент через обычную ссылку. В целом, никто не мешает сделать эту логику внутри самого инвентаря, но отдельный компонент удобнее, если мы в будущем хотим добавить drag and drop между разными инвентарями.
Свойства у DragHandler такие:
Instance: статическая ссылка на компонент.IsDragging: перетаскивается ли сейчас что-то.Current: панель-источник для перетаскиваемого предмета.DraggedVisual: сама перетаскиваемая панель, то есть наш "призрачный" элемент.
Методы же довольно прямолинейные и просто обновляют эти свойства. Честь вызывать DragHandler достаётся самому InventoryItemPanel:
InventoryItemPanel.razor
public override bool WantsDrag => true;
protected override void OnDragStart(DragEvent e)
=> DragHandler.StartDragging(this, e);
protected override void OnDragEnd(DragEvent e)
{
DragHandler.StopDragging();
var slot = GetSlot();
if (slot is {} sl)
{
Item.Col = sl.X;
Item.Row = sl.Y;
}
}
private (int X, int Y)? GetSlot()
=> null;
WantsDrag переопределять нужно, чтобы панель откликалась на OnDrag методы. В OnDragStart мы сразу делегируем логику в DragHandler, в OnDragEnd же ещё и обрабатываем изменение позиции, хотя на месте GetSlot пока заглушка. И да, slot is {} sl это такой необычный синтаксис для проверки переменной на null вместе с тем, чтобы здесь же получить не null идентификатор. Чем-то напоминает C++ своей окольностью.
В любом случае, теперь мы можем перетаскивать предмет по инвентарю! Пусть пока из-за заглушки изменить позицию и не выйдет.

And Drop
Всё, что нам осталось, это убрать заглушку из кода. Собственно, сделать это несложно:
private (int X, int Y)? GetSlot()
{
var dragged = DragHandler.DraggedVisual;
var x = (int)MathF.Round((dragged.Box.Left - Parent.Box.Left) / 97f);
var y = (int)MathF.Round((dragged.Box.Top - Parent.Box.Top) / 97f);
return x < 0 || y < 0 || x + Width > Owner.Cols || y + Height > Owner.Rows ? null : (x, y);
}
Наша цель здесь — это вычислить левую верхнюю клетку, на которую попадает панель, которую мы перетаскиваем. Эта клетка и будет той координатой, на которую нужно перенести наш предмет. Для этого мы делим позицию перетаскиваемой панели на размер клеток + промежуток между ними (отсюда и 97). Parent здесь — наш div с классом background.
Итоговый результат выглядит так:
Отмечу ещё один момент. В s&box есть ещё 4 OnDrag метода: OnDrag, OnDragSelect, OnDragEnter, OnDragLeave. Зачем нужны первые два, я не знаю (по первому даже документации никакой нет), последние два я использовать пытался, но принципы их работы мне оказались не ясны. Они активировались, если я ничего не перетаскивал, но замолкали, как только на курсоре что-то появлялось. Для меня это и оказалось тем самым "вопреки". Возможно, обуздай я их, логику вычисления слотов можно было бы немного упростить, но глобально картину это не меняет. Плюс, по моим ощущениям, это было бы полезнее для инвентарей, в которых все предметы имеют размер 1x1.
Подводя итоги
И на этом у нас есть инвентарь, в котором можно перетаскивать предметы. Конечно, он далёк от того, чтобы быть полностью готовым. Например, по убыванию важности:
- Нет логики учёта коллизий между несколькими предметами. Способов делать это много, и они никак не завязаны на s&box, так что я решил это опустить.
- Вся логика свалена в одну кучу.
- Как я уже говорил выше, магические числа лучше выносить в константы.
- Плюс лучше отделять игровую логику от UI. Последняя должна быть отображением, а не владеть какой-то логикой.
- Логика определения клетки чересчур грубая и прямолинейная. Лучше дать зазор игроку, чтобы не нужно было так строго попадать в нужные позиции.
- Нет подсветки клеток, на которые осуществляется перенос.
Но это абсолютно функциональная база, на которую можно насаживать любой функционал.