PVG
41. Level Transitions — Program Video Games

41. Level Transitions

[[programvideogames]]In this lecture, we're going to cover moving between levels, optimising the autotiling, updating the map, and proper encoding of our world so we can build upon it.

Let's update the editor.odin file first.

We are going to store neighbors on the Tile type for optimisation purposes.

We were calculating neighbors using autotile_calculate_neighbors for for every rule and every tile. This was taking a 200-700ms on my 5950x processor, which is not new, but certainly not a bad processor.

That told me something was very wrong with the implementation.

autotile_run :: proc(l: ^Level) {
 
     for rule in es.tileset.rules {
         for &tile in gs.level.tiles {
            // Delete coords, neighbors

             if .Match_Exact in rule.flags {
     			// Use tile.neighbors instead
                if rule.neighbors == tile.neighbors && rule.not_neighbors & tile.neighbors == {} {
                     tile.src = rule.src
                 }
             } else {
     			// Use tile.neighbors here, too
                if rule.neighbors <= tile.neighbors && rule.not_neighbors & tile.neighbors == {} {
                     tile.src = rule.src
                 }
             }

At the end of each command, we'll call world_data_save to save our world.

editor_command_execute :: proc(cmd: Cmd) {
    // ...
     recreate_colliders(gs.level.pos, &gs.colliders, gs.falling_logs[:], gs.level.tiles[:])
     autotile_run(gs.level)
    world_data_save()
 }

We are going to modify the level_load procedure to always take a position instead of using a Maybe. Anywhere we don't care about passing it, we can pass 0 - it'll be converted to Vec2{0, 0}. However, you could also make the procedure signature use default value (..., player_spawn := Vec2{0, 0}).

Also, while we are in editor_update, we'll add a way to draw tiles that are in other levels - this way when we edit the world, we can easily see how levels connect to each other.

editor_update :: proc(gs: ^Game_State, dt: f32) {
                    // ...
                    level_load(gs, level.id, 0) // Modified

    // ... 
    // Draw Tiles from other levels, too
    rl.BeginMode2D(gs.camera)

    for l in gs.levels {
        if l.id == gs.level.id do continue

        for tile in l.tiles {
            rl.DrawRectangleV(tile.pos, TILE_SIZE, rl.BROWN)
        }
    }

    rl.EndMode2D()

    // Below code is for placement reference ...
    for l in gs.levels {
            level_rect := Rect{l.pos.x, l.pos.y, l.size.x, l.size.y}
            level_rect = rect_pos_add(level_rect, -gs.camera.target)

We need to modify our level header to use signed numbers as we can draw levels in negative ranges.

 World_Data_Level_Header :: struct {
     magic:  u32,
     id:     u32,
    x:      i32, // Changed
    y:      i32, // Changed
     width:  u32,
     height: u32,
 }

In world_data_save, we can use the length of gs.levels instead of hard coding 1.

We also want to iterate over all levels rather than hard-coding only level_1. So here is the entire procedure in full:

world_data_save :: proc() {
    b := bytes.Buffer {
        buf = make([dynamic]u8, context.temp_allocator),
    }

    header := World_Data_Header {
        magic         = HEADER_MAGIC,
        version_major = 0,
        version_minor = 1,
        version_patch = 0,
        level_count   = u32(len(gs.levels)), // New
        tileset_count = 0,
    }

    bytes.buffer_write_ptr(&b, &header, size_of(World_Data_Header))

    // Iterate instead of just one
    for level in gs.levels {
        size_in_tiles := coords_from_pos(level.size)
        pos_in_tiles := coords_from_pos(level.pos)
        level_header := World_Data_Level_Header {
            magic  = LEVEL_MAGIC,
            id     = level.id,
            x      = i32(pos_in_tiles.x),
            y      = i32(pos_in_tiles.y),
            width  = u32(size_in_tiles.x),
            height = u32(size_in_tiles.y),
        }

        bytes.buffer_write_ptr(&b, &level_header, size_of(level_header))

        tiles := make([]u8, level_header.width * level_header.height, context.temp_allocator)

        for tile in level.tiles {
            coords := coords_from_pos(tile.pos - level.pos)
            tiles[u32(coords.y) * level_header.width + u32(coords.x)] = 1
        }

        bytes.buffer_write(&b, tiles)

        if !os.write_entire_file("data/world.dat", bytes.buffer_to_bytes(&b)) {
            panic("Failed to write world file")
        }
    }
}

When loading the data, we have a few QoL changes:

world_data_load :: proc() {
         assert(level_header.magic == LEVEL_MAGIC)
 
         level.id = level_header.id
        level.pos = pos_from_coords({level_header.x, level_header.y}) // Changed
        level.size = pos_from_coords({i32(level_header.width), i32(level_header.height)}) // Changed

        // Deleted solid_tiles dynamic array
        // Because we call recreate_colliders later on
 
         for y in 0 ..< level_header.height {
             for x in 0 ..< level_header.width {
                 tile_type_index: u8
                 bytes.reader_read(&r, mem.any_to_bytes(tile_type_index))
                 if tile_type_index > 0 {
     				// Deleted append, same reason as above
                    pos := level.pos + pos_from_coords({i32(x), i32(y)})
                     append(&level.tiles, Tile{pos = pos})
                 }
             }
         }

        // Batch the hard work for autotiling up-front
        // This way we do it at load time once
        for &tile in level.tiles {
            tile.neighbors = autotile_calculate_neighbors(coords_from_pos(tile.pos), &level)
        }

        // Call recreate_colliders instead of combine_colliders
        recreate_colliders(level.pos, &gs.colliders, gs.falling_logs[:], level.tiles[:])
        // ...

NOTE: You can delete the Door type and associated code.
However, I didn't get around to it in the video, so I'll leave the change as-is in the text here.

In main.odin

 Door :: struct {
    rect: Rect, // Only rect now, to be deleted, probably
 }
 Tile :: struct {
    pos:       Vec2,
    src:       Vec2,
    neighbors: Tile_Neighbors, // New
    f:         u8,
 }

We'll update some constants to move magic numbers/values up here:

BG_COLOR_CURRENT_LEVEL :: rl.SKYBLUE // DELETED
BG_COLOR_OTHER_LEVEL :: BG_COLOR_MAIN_MENU // DELETED

INGAME_UI_BG_RECT :: Rect{16, 16, RENDER_WIDTH - 32, RENDER_HEIGHT - 32}
INGAME_UI_BG_COLOR :: rl.Color{0, 0, 0, 120}

MAP_SCALE :: 16
MAP_COLOR_CURRENT_LEVEL :: rl.Color{10, 10, 50, 255}
MAP_COLOR_OTHER_LEVEL :: BG_COLOR_MAIN_MENU
MAP_COLOR_SOLID :: rl.LIGHTGRAY
MAP_COLOR_SOLID_OTHER :: rl.GRAY
MAP_COLOR_LEVEL_BORDER :: rl.DARKGRAY
MAP_COLOR_PLAYER :: rl.RED

The aforementioned change to level_load.

I show in the video, but the reason is because we can jump between levels - the velocity of the player was being reset to 0. This caused moving upwards between levels to be nearly impossible. We also want to do autotiling on level load near the end.

level_load :: proc(gs: ^Game_State, id: u32, player_spawn: Vec2) {
    // ...
     player := entity_get(gs.player_id)
     player_anim_name: string
     player_health: int
    player_vel: Vec2 // New
 
     if player != nil {
         player_anim_name = strings.clone(player.current_anim_name, context.temp_allocator)
         player_health = player.health
        player_vel = player.vel // New
     }
 
     clear(&gs.entities)
 
    spawn_player(gs, player_spawn) // Changed
 
    if player_anim_name != "" {
        player = entity_get(gs.player_id)
        player.health = player_health
        for k in player.animations {
            if k == player_anim_name {
                player.current_anim_name = k
            }
        }
        player.vel = player_vel // New
    }
 
     recreate_colliders(level.pos, &gs.colliders, gs.falling_logs[:], level.tiles[:])
    autotile_run(level) // New

spawn_player has a few changes to help with level transitions.

spawn_player :: proc(gs: ^Game_State, player_spawn: Vec2) { // Changed

    // Delete the player_spawn_exists code

     gs.player_id = entity_create(
         {
            x = player_spawn.x, // Changed
            y = player_spawn.y, // Changed
             width = 16,
             height = 38,
             move_speed = 220,
             // ...

    // Delete the safe position code
 }

For main_menu_update changes, we just need to update where level_load is called and pass in 0 as the last parameter (if you didn't use the default parameter option).

Next, we'll put to use those constants we created earlier.

In game_update we have a switch case for Inventory and Map.

Here's Inventory in full:

// Background
rect := INGAME_UI_BG_RECT
rl.DrawRectangleRec(rect, INGAME_UI_BG_COLOR)

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)
    }
    if (i + 1) % 8 == 0 {
        pos.y += 24
        pos.x = 16
    }

    pos.x += 24
}

Here's Map in full:

// Background
rect := INGAME_UI_BG_RECT
rl.DrawRectangleRec(rect, INGAME_UI_BG_COLOR)

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

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

for id in gs.save_data.visited_level_ids {
    level := level_from_id(gs.levels[:], id)
    if level == nil {
        continue
    }

    base_pos := level.pos
    base_pos -= map_offset
    base_pos = linalg.floor(base_pos / 16)
    base_pos += Vec2{RENDER_WIDTH, RENDER_HEIGHT} / 2

    size := level.size / 16

    bg_color := id == gs.level.id ? MAP_COLOR_CURRENT_LEVEL : MAP_COLOR_OTHER_LEVEL

    rl.DrawRectangleV(base_pos, size, bg_color)

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

    for tile in level.tiles {
        rel_tile_pos := Vec2{(tile.pos.x - level.pos.x) / 16, (tile.pos.y - level.pos.y) / 16}
        tile_pos := linalg.round(base_pos + rel_tile_pos)
        tile_size :: f32(TILE_SIZE) / 16
        color := id == gs.level.id ? MAP_COLOR_SOLID : MAP_COLOR_SOLID_OTHER
        rl.DrawRectangleV(tile_pos, tile_size, color)
    }
}

player_pos := Vec2{RENDER_WIDTH, RENDER_HEIGHT} / 2
player_size :: 4
rl.DrawRectangleV(player_pos, player_size, MAP_COLOR_PLAYER)

Delete world_data_save() from the start of main, then open up player.odin.

Replace the door code in player_update with the following code:

Note: The first overlap is because I modified the code at different times. You could also use rl.CheckCollisionRecs.

Basically we check which level we are overlapping with if we aren't overlapping with the currently loaded one, and load that.

We use the overlap of the other level and the player to set the position accurately. Seamless!

// If we move outside the level, we are moving into another level!
{
    overlap := rl.GetCollisionRec(player.collider, rect_from_pos_size(gs.level.pos, gs.level.size))
    if overlap.width == 0 && overlap.height == 0 {
        for l in gs.levels {
            if rl.CheckCollisionRecs(player.collider, rect_from_pos_size(l.pos, l.size)) {
                overlap = rl.GetCollisionRec(player.collider, rect_from_pos_size(l.pos, l.size))
                level_load(gs, l.id, Vec2{overlap.x, overlap.y})
                break
            }
        }
    }
}

You'll need to replace level_load elsewhere in this file if you didn't use the default parameter value.

Finally, an important change in util.odin:

We had an error in coords_from_pos and that's due to the order of operations.

// pseudocode
// old version:
v = floor(p)
v = v / s

// new version:
v = p / s
v = floor(v)

The difference? It's all in how negative numbers are handled.

Let's look at a simple example:

// old version
floor(-16.2) = -17
-17 / 16 = -1.0625

converted to i32 - truncates to -1

// new version
-16.2 / 16 = -1.0125
floor(-1.0125) = -2.0000

converted to i32 - truncates to -2

The code changes:

coords_from_pos :: #force_inline proc(pos: Vec2) -> Vec2i {
    coordsf := pos / TILE_SIZE
    coordsf = linalg.floor(coordsf)
     return Vec2i{i32(coordsf.x), i32(coordsf.y)}
 }
 
// Also added #force_inline here. Didn't do a benchmark!
pos_from_coords :: #force_inline proc(coords: Vec2i) -> Vec2 {
     return Vec2{f32(coords.x), f32(coords.y)} * TILE_SIZE
 } 

Alright, that is enough to really get the feeling of the game back.

We are coming up on being able to re-implement everything we had before and finish off this vertical slice.