Drag And Drop инвентарь в s&box

Right

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

Приступая к работе

Недавно я как раз попробовал такой интерфейс реализовать — результат можно видеть на титульной картинке выше. Но тут мы ограничимся целью попроще: сделать инвентарь с сеткой и 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 пикселей в этом плане более безопасный, хотя и не полностью — если сжать окно редактора по вертикали, то зазор появится и там.

По итогу сетка будет выглядеть примерно так:

47d8eafe118abd83cac5778d275b5fc5.png

Отображение предметов

Сетка у нас есть, но пока она исключительно декоративная, так что следующим шагом добавим отображение предметов. Для начала нужен сам класс предмета:

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

Результат же должен выглядеть так:

7d0e3f1a415b27bc93958124757de71d.png

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++ своей окольностью.

В любом случае, теперь мы можем перетаскивать предмет по инвентарю! Пусть пока из-за заглушки изменить позицию и не выйдет.

dd16790722fea841e5dbc81baaa2344a.png

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. Последняя должна быть отображением, а не владеть какой-то логикой.
  • Логика определения клетки чересчур грубая и прямолинейная. Лучше дать зазор игроку, чтобы не нужно было так строго попадать в нужные позиции.
  • Нет подсветки клеток, на которые осуществляется перенос.

Но это абсолютно функциональная база, на которую можно насаживать любой функционал.