PVG
26. Main Menu — Program Video Games

26. Main Menu

[[programvideogames]]In this lesson we'll do our first pass on the main menu.

To create a main menu, first we have to talk about scenes.

What's a scene? It depends who you ask.

The simplest version of a scene, and what we'll use (naturally), is a loop.

A scene is a different loop. Maybe it has an init proc to setup some state.

So, to store what scene we are on, we can simply use an enum.

In main.odin:

Game_State :: struct {
    // ... other fields ...
    scene: Scene_Type,
    font_48: rl.Font, // Fonts for our main menu
    font_64: rl.Font,
    bgm: rl.Music, // Move bgm from local variable
}

Scene_Type :: enum {
    Main_Menu,
    Game,
}

Next, we'll create three procedures:

  1. main_menu_update :: proc(gs: ^Game_State)
  2. game_init :: proc(gs: ^Game_State)
  3. game_update :: proc(gs: ^Game_State)

Before we fill in main_menu_update, we'll work on game_init and game_update because they are basically just moving code around.

For game_init, we want to move a bunch of all our setup code that pertains to the game itself:

game_init :: proc(gs: ^Game_State) {
    // Load player texture
    gs.player_texture = rl.LoadTexture("assets/textures/player_120x80.png")
    gs.tileset_texture = rl.LoadTexture("assets/textures/tileset.png")

    gs.sword_swoosh_sound = rl.LoadSound("assets/sounds/player_sword_swing.wav")
    gs.sword_swoosh_sound_2 = rl.LoadSound("assets/sounds/player_sword_swing_2.wav")
    gs.sword_hit_soft_sound = rl.LoadSound("assets/sounds/sword_hit_soft.wav")
    gs.sword_hit_medium_sound = rl.LoadSound("assets/sounds/sword_hit_medium.wav")
    gs.sword_hit_stone_sound = rl.LoadSound("assets/sounds/sword_hit_stone.wav")
    gs.player_jump_sound = rl.LoadSound("assets/sounds/player_jump.wav")

    gs.bgm = rl.LoadMusicStream("assets/music/bgm.ogg") // Changed
    rl.PlayMusicStream(gs.bgm) // Changed

    gs.enemy_definitions["Walker"] = Enemy_Def {
        collider_size = {36, 18},
        move_speed = 35,
        health = 3,
        behaviors = {.Walk, .Flip_At_Wall, .Flip_At_Edge},
        on_hit_damage = 1,
        texture = rl.LoadTexture("assets/textures/opossum_36x28.png"),
        animations = {
            "walk" = Animation {
                size = {36, 28},
                offset = {0, 10},
                start = 0,
                end = 5,
                time = 0.15,
                flags = {.Loop},
            },
        },
        initial_animation = "walk",
        hit_response = .Stop,
        hit_duration = 0.25,
    }

    // Load level data
    {
        level_data, ok := os.read_entire_file("data/world.ldtk", allocator = context.allocator)
        assert(ok, "Failed to load level data")

        ldtk_data := new(LDtk_Data, context.temp_allocator)
        err := json.unmarshal(level_data, ldtk_data, allocator = context.temp_allocator)
        if err != nil {
            log.panicf("failed to parse json: %v", err)
        }

        for &level in ldtk_data.levels {
            level_parse_and_store(gs, &level)
        }
    }

    level_load(gs, &gs.level_defintions[FIRST_LEVEL_IID])

    gs.scene = .Game // New
}

Pay attention to the last line there - gs.scene = .Game. That will be useful shortly.

For game_update the idea is similar, copy the entire current for loop:

game_update :: proc(gs: ^Game_State) {
    for !rl.WindowShouldClose() {
        rl.UpdateMusicStream(gs.bgm) // Changed
        // ... all the code ...
    }
}

Now our main proc should look like this:

main :: proc() {
    gs = new(Game_State)

    rl.InitWindow(WINDOW_WIDTH, WINDOW_HEIGHT, "Program Video Games!")
    rl.SetTargetFPS(60)
    rl.InitAudioDevice()

    gs.font_48 = rl.LoadFontEx("assets/fonts/Gogh-ExtraBold.ttf", 48, nil, 256)
    gs.font_64 = rl.LoadFontEx("assets/fonts/Gogh-ExtraBold.ttf", 64, nil, 256)

    gs.camera = rl.Camera2D {
        zoom = ZOOM,
    }
    gs.ui_camera = rl.Camera2D {
        zoom = ZOOM,
    }

    for !rl.WindowShouldClose() {
        switch gs.scene {
        case .Main_Menu:
            main_menu_update(gs)
        case .Game:
            game_update(gs)
        }
    }
}

Note the fonts we are loading - they are required for the menu.

I also left the camera setup in main because we may want to use the ui_camera if we do pixel-art menu art. They could be in either.

You may notice that we are using for !rl.WindowShouldClose() in an outer and inner loop.

This is intentional. It means any signal from the inner loop will exit that loop and also exit the outer loop. (By default that's pressing Escape, though we probably want to override this behaviour later).

Let's finally look at our main_menu_update proc:

main_menu_update :: proc(gs: ^Game_State) {
    for !rl.WindowShouldClose() {
        center := Vec2{WINDOW_WIDTH, WINDOW_HEIGHT} / 2
        title_text: cstring = "METROIDVANIA"
        title_text_size := rl.MeasureTextEx(gs.font_64, title_text, 64, 4)

        rl.BeginDrawing()
        rl.ClearBackground({0, 0, 28, 255})

        rl.DrawTextEx(
            gs.font_64,
            title_text,
            {center.x - title_text_size.x / 2, center.y / 2},
            64,
            4,
            rl.WHITE,
        )

        if main_menu_item_draw("Continue", center, rl.DARKGRAY, rl.DARKGRAY) {
            // TODO
        }

        if main_menu_item_draw("New Game", center + {0, 60}) {
            game_init(gs)
            return
        }

        if main_menu_item_draw("Quit", center + {0, 120}) {
            rl.CloseWindow()
            return
        }

        rl.EndDrawing()
    }
}

main_menu_item_draw :: proc(
    text: cstring,
    pos: Vec2,
    color := rl.WHITE,
    hover_color := rl.YELLOW,
) -> (
    pressed: bool,
) {
    pos := pos
    text_size := rl.MeasureTextEx(gs.font_48, text, 48, 0)
    pos.x -= text_size.x / 2
    rect := Rect{pos.x, pos.y, text_size.x, text_size.y}

    if rl.CheckCollisionPointRec(rl.GetMousePosition(), rect) {
        rl.DrawTextEx(gs.font_48, text, pos, 48, 0, hover_color)
        if rl.IsMouseButtonPressed(.LEFT) {
            pressed = true
        }
    } else {
        rl.DrawTextEx(gs.font_48, text, pos, 48, 0, color)
    }

    return
}

The code is quite straightforward, but I do want to bring your attention to the main_menu_item_draw proc.

This is a very quick and easy way to get something similar to an Immediate Mode GUI element.

Of course, this is bespoke, but the pattern is reminiscent. It allows for easy usage code:

if main_menu_item_draw(...) { /* do something on click */ }

I'll often reach for a solution like this and use it for as long as possible.

Hopefully you'll never have to make something more complex.

The best way to optimise something is to not do it (assuming you keep the functionality required).

That's it for the main menu for right now. It's plain, but functional.

In the future we'll be adding a save/load system, so we'll be back in there shortly.