PVG
29. Inventory System — Program Video Games

29. Inventory System

[[programvideogames]]In this lesson we'll cover how to make a rudimentary inventory system - which means we also need some items. So, we'll make a simple item system as well.

I'm avoiding putting more data into LDtk as it looks like we'll be switching to a custom editor. So, we'll create our items as simple structs in the code.

Let's start from the data, so we'll need to add state and types in main.odin:

Game_State :: struct {
    // ... other fields ...
    game_menu_state:        Game_Menu_State,
    items:                  [dynamic]Item,
    item_texture:           rl.Texture,
    inventory:              [dynamic]Inventory_Slot,
}

Game_Menu_State :: struct {
    menu_type: Game_Menu_Type,
}

Game_Menu_Type :: enum {
    None,
    Inventory,
}

Inventory_Slot :: struct {
    item_type: Item_Type,
    src:       Vec2,
    count:     int,
}

// This is in the world
Item :: struct {
    type: Item_Type,
    src:  Vec2,
    pos:  Vec2,
}

Item_Type :: enum {
    // These DummyX items are for testing the layout
    Dummy0,
    Dummy1,
    Dummy2,
    Dummy3,
    Dummy4,
    Dummy5,
    Dummy6,
    Dummy7,
    Dummy8,
    Dummy9,
    DummyA,
    DummyB,
    DummyC,
    DummyD,
    DummyE,
    DummyF,
    Necrotic_Tail,
    Strange_Bacon,
    Lucky_Foot,
}

The only thing that may be confusing here is the src field on Inventory_Slot and Item. This is a Vec2 that holds where in the texture the item icon is.

I haven't profiled whether it's faster to store a Vec2 once or do a switch on an Item_Type every frame. It is something we'll look into in the polish and optimisation section.

By the way, I've update the schedule to allow more time for us to go over optimisation and polish. There is a lot more to look at than I thought there would be when I mapped it out.

We also need a way to figure out how many and whether an item should be dropped from an enemy.

So, we'll update Enemy_Def and create a new type to hold the data:

Enemy_Def :: struct {
    // ... other fields ...
    drops:               [dynamic]Drop,
}

Drop :: struct {
    type:   Item_Type,
    chance: f32,
    range:  [2]int, // Minimum and maximum drop IF chance is met
}

And, since we store this data on the Enemy_Def, we need a way to reference it from our Entity code... It's becoming clear that we should split the concept of Entity, Enemy and Player entirely.

Entity :: struct {
    // ... other fields ...
    definition:                 ^Enemy_Def,
}

We can use a pointer as the enemy definitions map is never touched again after loading.

In our loading proc level_parse_and_store, we need to assign the field:

if slice.contains(entity.__tags, "Enemy") {
    def := &gs.enemy_definitions[entity.__identifier]

    append(
        &l.entities,
        Entity {
            // ... other fields ...
            definition = def,
        },
    )
}

Now, where we declare our Enemy_Defs, let's add their drops:

gs.enemy_definitions["Walker"] = Enemy_Def {
    // ... other fields ...
    drops = {{type = .Necrotic_Tail, chance = 1, range = 1}},
}

gs.enemy_definitions["Jumper"] = Enemy_Def {
    // ... other fields ...
    drops = {{type = .Lucky_Foot, chance = 1, range = {1, 2}}},
}

gs.enemy_definitions["Charger"] = Enemy_Def {
    // ... other fields ...
        drops = {
            {type = .Strange_Bacon, chance = 0.5, range = {1, 3}},
            {type = .Dummy4, chance = 1, range = {1, 5000}},
        },
}

Each Enemy_Def may have multiple types of items dropped with different chances and ranges.

While we are in game_init, let's add some dummy inventory data:

// TODO: Remove this. Just to test inventory layout
{
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy0, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy1, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy2, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy3, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy4, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy5, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy6, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy7, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy8, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .Dummy9, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .DummyA, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .DummyB, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .DummyC, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .DummyD, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .DummyE, count = 1})
    append(&gs.inventory, Inventory_Slot{item_type = .DummyF, count = 1})
}

gs.scene = .Game // This was already at the end

Crude, but shows that our rudimentary inventory layout system is working as expected.

Let's move on to game_update where we'll handle opening and closing the menu as well as drawing it and the items.

// At the top of our loop:
dt := rl.GetFrameTime() // Existed prior

if rl.IsKeyPressed(.TAB) {
    if gs.game_menu_state.menu_type != .None {
        gs.game_menu_state.menu_type = .None
    } else {
        gs.game_menu_state.menu_type = .Inventory
    }
}

Down in our drawing code, we'll draw the items that drop on the ground. So, just after power up drawing:

for item in gs.items {
    src := Rect{item.src.x, item.src.y, 16, 16}
    rl.DrawTextureRec(gs.item_texture, src, item.pos, rl.WHITE)
}

We use the same technique as drawing tiles.

Now, in our UI drawing section, after we draw the player's health:

// Inventory
switch gs.game_menu_state.menu_type {
case .None:
case .Inventory:
    // Background
    rect := Rect{16, 16, RENDER_WIDTH - 32, RENDER_HEIGHT - 32}
    rl.DrawRectangleRec(rect, {0, 0, 0, 120})

    pos := Vec2{40, 40}

    text: cstring = "Inventory"
    font_size := f32(14)
    text_size := rl.MeasureTextEx(gs.font_48, text, font_size, 0)

    rl.DrawTextEx(
        gs.font_48,
        text,
        {pos.x + 24 * 4 - text_size.x / 2 - 4, 20},
        font_size,
        0,
        rl.WHITE,
    )

    // Items
    for slot, i in gs.inventory {
        src := Rect{slot.src.x, slot.src.y, 16, 16}
        rl.DrawTextureRec(gs.item_texture, src, pos, rl.WHITE)

        if slot.count > 1 {
            rl.DrawTextEx(
                gs.font_48,
                fmt.ctprintf("%d", slot.count),
                pos + 12,
                10,
                0,
                rl.WHITE,
            )
        }
        // New line
        if (i + 1) % 8 == 0 {
            pos.y += 24
            pos.x = 16
        }

        pos.x += 24
    }
}

I think, by now, we've done enough of this kind of stuff that I don't need to go over every detail.

What I will say, though, is that we've got a bit of an annoying design situation.

We are using a non-pixel-art font with pixel-art icons and other UI (like the Health).

So right now, we are shrinking our 48px text down to 10, then scaling it back up...

Ideally we want to either do a deferred render pass on the text or render the icons and rest of the UI scaled up.

Let's load our items texture down in main:

// just after loading fonts
gs.item_texture = rl.LoadTexture("assets/textures/items_16x16.png")

In building out the drop system, I realised we had a strange way to do entity deaths.

Even though we are using an entity_damage event callback, we are also checking each update in the entity_update if an entity should be dying this frame.

So, I've moved the entity death check out of the look and into the callback. We now don't have to pay the (probably tiny, but still) cost of those checks for every entity on every frame.

So, remove the death check code from entity_update and update entity_damage to look like this:

// You'll need to import rand
import "core:math/rand"

entity_damage :: proc(id: Entity_Id, amount: int) {
    entity := entity_get(id)
    entity.health -= amount
    if entity.health <= 0 && .Immortal not_in entity.flags {
        entity.flags += {.Dead}
        if entity.on_death != nil {
            entity->on_death(gs)
        }

        for drop in entity.definition.drops {
            if rand.float32() <= drop.chance {
                count := rand.int_max(drop.range[1] + 1 - drop.range[0]) + drop.range[0]

                for _ in 0 ..< count {
                    spawn_pos := rect_center(entity.collider)
                    spawn_pos.y = entity.collider.y + entity.collider.height - 16
                    item_spawn(gs, drop.type, spawn_pos)
                }
            }
        }
    }
}

The reason I loop over count items and call item_spawn here is because we'd just have to loop on the inside of the procedure anyway. And, the idea of spawning and item doesn't necessitate handling multiples.

Alright, let's create a new file - I've called it item.odin:

// X and Y coordinate into the items texture given a type
item_src :: proc(type: Item_Type) -> Vec2 {
    src: Vec2
    #partial switch type {
    case .Necrotic_Tail:
        src.x = 16
    case .Strange_Bacon:
        src.x = 32
    case .Lucky_Foot:
        src.x = 48
    }
    return src
}

item_spawn :: proc(gs: ^Game_State, type: Item_Type, pos: Vec2) {
    // I'd really like to know whether storing a Vec2 on here is
    // cheaper than looking up each frame
    src := item_src(type)
    append(&gs.items, Item{type = type, src = src, pos = pos})
}

// Add to slot if item already in inventory
// Otherwise, append to inventory
inventory_add :: proc(gs: ^Game_State, type: Item_Type, count: int) {
    did_add := false
    for &slot in gs.inventory {
        if slot.item_type == type {
            slot.count += count
            did_add = true
            break
        }
    }

    if !did_add {
        slot := Inventory_Slot {
            item_type = type,
            src       = item_src(type),
            count     = count,
        }
        append(&gs.inventory, slot)
    }
}

Next up we'll edit our player.odin file. First, player_update. I'll include the top of the proc:

player_update :: proc(gs: ^Game_State, dt: f32) {
    player := entity_get(gs.player_id)

    // Since we removed the entity_update death code
    // We need to check here
    // We're checking in the loop instead of a callback
    // because the player may get damaged from other procs
    if player.health <= 0 {
        player_on_death(player, gs)
        return
    }

    // Set a new boolean, if the menu is active
    menu_active := gs.game_menu_state.menu_type != .None

    input_dir: Vec2
    // Don't move if menu is active
    if !menu_active {
        if rl.IsKeyDown(.T) do input_dir.x += 1
        if rl.IsKeyDown(.R) do input_dir.x -= 1
        if rl.IsKeyDown(.S) do input_dir.y += 1
        if rl.IsKeyDown(.F) do input_dir.y -= 1
    }

    // ...

Now, everywhere we call try_run or try_whatever, we'll want to wrap those calls in a check to make sure the menu isn't active:

if !menu_active {
    try_run(gs, player)
    try_dash(gs, player, input_dir)
}
// .. update other call sites for these proc types ...

Finally, in player_on_enter we can remove the condition to skip a branch.

If the on_hit_damage of an entity is 0, then that's fine.

player_on_enter :: proc(self_id, other_id: Entity_Id) {
    player := entity_get(self_id)
    other := entity_get(other_id)

    player.health -= other.on_hit_damage
}

Okay, that was a lot of new code. If you have any questions please don't hesitate to reach out.