Skip to main content Link Search Menu Expand Document (external link)

Day 2: Grid Logic System

Today, we implemented a simple version of the IInventoryGrid interface as well as 4 unit tests to ensure our the interface can meet our needs. With that in place, we were able to write scriptable objects to represent a player’s inventory or another container type.

Table of contents
  1. Today’s Tasks
  2. Write Tests for IInventoryGrid
  3. Write a Simple Implementation
  4. Scriptable Objects and Implement Adapter for IInventoryGrid
    1. IInventoryGridAdapter

Today’s Tasks

  1. Write Tests for IInventoryGrid
  2. Implement InventoryGrid
  3. Implement Adapter for IInventoryGrid and Implement ScriptableObjects

Write Tests for IInventoryGrid

Previously, we defined an interface for IInventoryGrid. To ensure it met all of the the requirements necessary for our grid system, we started by writing a few unit tests. Because we chose to write the logic separate from Unity, we were able to utilize xUnit.

We started by defining a class SimpleInventoryGrid that implemented the IInventoryGrid interface with stub definitions that throw exceptions.

To be able to test the grid, we needed to have an implementation of the IInventoryItem interface. Within the test project, we created a simple MockInventoryItem with the following definition:

internal record class MockInventoryItem(string Name, Dimensions Size) : IInventoryItem;

The ease of doing this with a record type makes me smile!

As we began writing tests, it became obvious that we would need to update the IInventoryGrid to accept a generic type. The signature of the interface changed to:

public interface IInventoryGrid<T> where T : IInventoryItem

And the methods which accepted IInventoryItem as a parameter were updated to match.

With this in place, we wrote 4 unit tests:

/// Tests that the constructor initializes the state properly
public void TestConstructor(int rows, int cols);
/// Tests adding and remove items with simple success / failures
public void TestSimpleAddAndRemove();
/// Tests that adding items that would not fit in the grid fail to be added
public void TestAddOutOfBounds();
/// Tests that an item can be added to the inventory when a single item 
/// is occupying the same space and the swapped item is retrieved
public void TestSimpleAddWithSwap();

The full test implementations can be found here: Tests Day 2

Write a Simple Implementation

Throughout the process of writing tests, we also implemented the SimpleInventoryGrid. Below is the final result which passes all tests:

public class SimpleInventoryGrid<T> : IInventoryGrid<T> where T : class, IInventoryItem
{

    private readonly Dictionary<T, Position> _itemLookup = new();
    private readonly T?[,] _grid;

    /// <summary>
    /// Initializes an instance of <see cref="SimpleInventoryGrid{T}"/> with a
    /// GridSize of 4 Rows and 10 Columns.
    /// </summary>
    public SimpleInventoryGrid()
    {
        _grid = new T[GridSize.Rows, GridSize.Columns];
    }

    /// <summary>
    /// Initializes an instance of <see cref="SimpleInventoryGrid{T}"/> with the
    /// specified <paramref name="gridSize"/>
    /// </summary>
    public SimpleInventoryGrid(Dimensions gridSize) : this()
    {
        GridSize = gridSize;
    }

    /// <inheritdoc/>
    public Dimensions GridSize { get; init; } = new(4, 10);

    /// <inheritdoc/>
    public IEnumerable<IInventoryGrid<T>.GridSlot> Items
    {
        get
        {
            foreach ((T item, Position topLeft) in _itemLookup)
            {
                yield return new IInventoryGrid<T>.GridSlot(topLeft, item, this);
            }
        }
    }

    /// <inheritdoc/>
    public bool IsOccupied(Position position) => _grid[position.Row, position.Col] != null;

    private bool IsInBounds(Position position) => 
            !(position.Row < 0 ||
              position.Col < 0 ||
              position.Row >= GridSize.Rows ||
              position.Col >= GridSize.Columns);

    /// <inheritdoc/>
    public bool TryGetItemAt(Position position, out T? item)
    {
        item = null;
        if (!IsInBounds(position) || !IsOccupied(position)) { return false; }
        item = _grid[position.Row, position.Col];
        return true;
    }

    /// <inheritdoc/>
    public bool TryRemoveItemAt(Position position, out T? item)
    {
        item = null;
        if (!IsOccupied(position)) { return false; }
        item = _grid[position.Row, position.Col]!;
        Position topLeft = _itemLookup[item];
        _itemLookup.Remove(item);
        foreach (Position itemCell in item.Size)
        {
            Position inGrid = topLeft + itemCell;
            _grid[inGrid.Row, inGrid.Col] = null;
        }
        return true;
    }

    /// <inheritdoc/>
    public bool TrySetItemAt(Position topLeft, T item)
    {
        bool canSet = item.Size.Positions.Any(p => !IsInBounds(p + topLeft) || IsOccupied(p + topLeft));
        if (canSet) { return false; }

        _itemLookup[item] = topLeft;
        foreach (Position itemCell in item.Size)
        {
            Position inGrid = topLeft + itemCell;
            _grid[inGrid.Row, inGrid.Col] = item;
        }
        return true;
    }

    /// <inheritdoc/>
    public bool TrySetItemAt(Position topLeft, T item, out T? removedItem)
    {
        removedItem = null;
        // No items occupy this space
        if (TrySetItemAt(topLeft, item)) { return true; }

        // If more than one item occupies the space, return false
        if (!IntersectsWithOne(topLeft, item, out T itemToRemove)) { return false; }

        // Exactly one item occupies the space
        Position toRemoveTopLeft = _itemLookup[itemToRemove];
        bool wasRemoved = TryRemoveItemAt(toRemoveTopLeft, out removedItem);
        Debug.Assert(wasRemoved, "Item could not be removed, this should not be possible.");
        bool wasAdded = TrySetItemAt(topLeft, item);
        Debug.Assert(wasAdded, "Item could not be added, this should not be possible");
        return true;
    }

    private bool IntersectsWithOne(Position topLeft, T item, out T foundItem)
    {
        HashSet<T> found = new();
        foundItem = null!;
        foreach (Position itemCell in item.Size)
        {
            Position inGrid = topLeft + itemCell;
            if (!IsOccupied(inGrid)) { continue; }
            foundItem = _grid[inGrid.Row, inGrid.Col]!;
            found.Add(foundItem);
            if (found.Count > 1) { return false; }
        }
        Debug.Assert(foundItem != null, "Impossible state detected. Should not call unless it is known that at least one item exists.");
        return true;
    }
}

Scriptable Objects and Implement Adapter for IInventoryGrid

With the logic for a grid based inventory implemented, we were ready to write the Unity version of these classes. One challenge is that we would like to use ScriptableObject to define our Inventory/Container types. Because of this we cannot extend SimpleInventoryGrid. Instead, we provide an interface for composition with an IInventoryGrid:

/// <summary>
/// The <see cref="IHasInventoryGrid{T}"/> interface is used to identify 
/// data types that are composed with an <see cref="IInventoryGrid{T}"/>.
/// </summary>
public interface IHasInventoryGrid<T> where T : class, IInventoryItem
{
    /// <summary>
    /// Retrieves the <see cref="IInventoryGrid{T}"/>
    /// </summary>
    public IInventoryGrid<T> InventoryGrid { get; }
}

Then, within our ScriptableObject extension, we explicitly set the underlying implementation to be a SimpleInventoryGrid:

public class InventoryGridData<T> : ScriptableObject, IHasInventory<T> where T : class, IInventoryItem
{
    [SerializeField]
    private MutableDimensions _dimensions = new (1,1);
    private IInventoryGrid<T> _grid;
    public IInventoryGrid<T> InventoryGrid => _grid ??= new SimpleInventoryGrid<T>(_dimensions.Freeze());
}

One thing that might be tricky to understand is the InventoryGrid property:

_grid ??= new SimpleInventoryGrid<T>(_dimensions.Freeze());

This is using the null-coalescing assignment operator, which will lazily set the _grid variable the first time it is accessed. It is essentially short hand for:

if (_grid == null)
{
  _grid = new SimpleInventoryGrid<T>(_dimensions.Freeze());
}
return _grid;

IInventoryGridAdapter

Just having the IHasInventoryGrid interface wasn’t enough for me. I want the InventoryGridData class to BE an IInventoryGrid. To do this, I added an IInventoryGridAdapter interface which adds a default implementation of IInventoryGrid through the IHasInventoryGrid interface:

public interface IInventoryGridAdapter<T> : IInventoryGrid<T>, IHasInventoryGrid<T> where T : class, IInventoryItem
{
    IEnumerable<GridSlot> IInventoryGrid<T>.Items => InventoryGrid.Items;
    bool IInventoryGrid<T>.IsOccupied(Position position) => InventoryGrid.IsOccupied(position);
    bool IInventoryGrid<T>.TryGetItemAt(Position position, out T? item) => InventoryGrid.TryGetItemAt(position, out item);
    bool IInventoryGrid<T>.TryRemoveItemAt(Position position, out T? item) => InventoryGrid.TryRemoveItemAt(position, out item);
    bool IInventoryGrid<T>.TrySetItemAt(Position topLeft, T item) => InventoryGrid.TrySetItemAt(topLeft, item);
    bool IInventoryGrid<T>.TrySetItemAt(Position topLeft, T item, out T? removedItem) => InventoryGrid.TrySetItemAt(topLeft, item, out removedItem);

With this interface, the implementing class looks identical BUT can now be passed into any method that accepts an IInventoryGrid:

public class InventoryGridData<T> : ScriptableObject, IInventoryGridAdapter<T> where T : class, IInventoryItem
{
    [SerializeField]
    private MutableDimensions _dimensions = new (1,1);
    private IInventoryGrid<T> _grid;
    public IInventoryGrid<T> InventoryGrid => _grid ??= new SimpleInventoryGrid<T>(_dimensions.Freeze());
}

And with that, we had an implementation of the IInventoryGrid within Unity. With this in place, we can focus our efforts on the UI for the remaining time and (hopefully) will not have to worry about the logic implementation.

Join the Discussion

Before commenting, you will need to authorize giscus. Alternatively, you can add a comment directly on the GitHub Discussion Board.