PVG
30. Map — Program Video Games

30. Map

[[programvideogames]]In this lesson we'll implement a simple map which can be accessed by pressing TAB twice (first opens inventory, then cycles to map).

Since we have access to the level bounds for each level, and the door positions and sizes - we can use this to construct a map of the rooms.

The style I decided to go for is:

  • Centre the map on the player's position
  • Only show visited rooms
  • Highlight the current room
  • Scale down the map by 16x the real size of the rooms

Given these criteria, let's get to work!

Before we get started, add this import to main.odin:

import "core:math/linalg"

First, we'll need to add a new Game_Menu_Type:

Game_Menu_Type :: enum {
    None,
    Inventory,
    Map,
    Count,
}

I also added Count here which we can use for easy cycling. I'll show you what I mean - let's update the code where we open the inventory to also cycle to the map:

if rl.IsKeyPressed(.TAB) {
    gs.game_menu_state.menu_type += cast(Game_Menu_Type)1
    if gs.game_menu_state.menu_type == .Count {
        gs.game_menu_state.menu_type = .None
    }
}

Here, you can see we increment the state by 1 and then check if it is Count, then wrap to .None.

So long as we leave Count in the last slot of the enum, then we can add as many menu states in between and not have to update this code at all.

For our map, we want to only display rooms the player has visited. As such, we'll track those in our save file:

Save_Data :: struct {
    slot:                int,
    // ... other fields ...
    visited_level_iids:  [dynamic]string, // New
    collected_power_ups: bit_set[Power_Up_Type],
}

To update this array, we'll add a few lines to savefile.odin in the save_data_update proc:

import "core:slice" // We'll need this

save_data_update :: proc(gs: ^Game_State) {
    // ... other lines stay the same ...
    if !slice.contains(gs.save_data.visited_level_iids[:], gs.level.iid) {
        append(&gs.save_data.visited_level_iids, gs.level.iid)
    }
}

This is a simple linear search through the array to make sure we don't append a room that's already visited.

The cool thing about using the json library is that this just works without any extra code.

The save file will now have a field called visited_level_iids.

Let's consolidate our save_data_update and savefile_save calls. We'll remove the pair that's in main_menu_update and the pair that's in player_update and instead call it inside level_load at the end.

Since we added Count to our Game_Menu_Type, the switch where we draw our inventory is going to be unhappy. Luckily, we are about to draw the map there anyway, so let's take a look at game_update:

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

    player_tile_pos_x := (int(player.x) / 16) * 16
    player_tile_pos_y := (int(player.y) / 16) * 16

    map_offset := Vec2{f32(player_tile_pos_x), f32(player_tile_pos_y)}

    for iid in gs.save_data.visited_level_iids {
        def := gs.level_definitions[iid]

        width := def.level_max.x - def.level_min.x
        height := def.level_max.y - def.level_min.y

        base_pos := Vec2{def.level_min.x, def.level_min.y}
        base_pos -= map_offset
        base_pos = linalg.floor(base_pos / 16)
        base_pos += Vec2{RENDER_WIDTH, RENDER_HEIGHT} / 2

        size := Vec2{width, height} / 16

        bg_color := iid == gs.level.iid ? BG_COLOR_CURRENT_LEVEL : BG_COLOR_OTHER_LEVEL

        rl.DrawRectangleV(base_pos, size, bg_color)

        rl.DrawRectangleLinesEx({base_pos.x, base_pos.y, size.x, size.y}, 1, rl.WHITE)

        for door in def.doors {
            rel_door_pos := Vec2 {
                (door.rect.x - def.level_min.x) / 16,
                (door.rect.y - def.level_min.y) / 16,
            }

            door_pos := linalg.floor(base_pos + rel_door_pos)

            door_size := Vec2{door.rect.width, door.rect.height} / 16
            door_size.x = max(door_size.x, 1)
            door_size.y = max(door_size.y, 1)

            rl.DrawRectangleV(door_pos, door_size, rl.DARKBLUE)
        }
    }

    // rl.DrawRectangleV({RENDER_WIDTH, RENDER_HEIGHT} / 2 - 2, 4, rl.RED)
}

This is all just calculation to draw the minimap at 1/16th the size. We use the player position as the centre of the map.

There's nothing that we haven't seen before. The only note I have is that getting the pixel-perfect position was a pain. We want to make sure to draw on absolute positions as much as possible to avoid warping - especially with the doors being 1px wide/tall.

Here are the colours as I have them defined:

BG_COLOR_CURRENT_LEVEL :: rl.SKYBLUE
BG_COLOR_OTHER_LEVEL :: BG_COLOR_MAIN_MENU

Alright, that's it to get a working map for a metroidvania game.

It's minimal, but I prefer that. Perhaps in a 2nd pass we will add checkpoints to the map. And perhaps, the checkpoint the player is currently saved at.