PVG
21. Level Transitions — Program Video Games

21. Level Transitions

[[programvideogames]]Alright, let's enhance our game by adding support for multiple levels and doors that allow the player to transition between them.

There's a lot to cover, so let's take it step by step.

Updating Data Structures for Levels and Doors

First, we need to modify our data structures to handle multiple levels and door transitions.

In src/main.odin, at the top of the file, add the following import:

import "core:strings" // New import

Next, update the LDtk_Level struct to include unique identifiers (iid) and neighbour information:

LDtk_Level :: struct {
    identifier:     string,
    iid:            string,                 // New
    layerInstances: []LDtk_Layer_Instance,
    __neighbours:   []LDtk_Neighbor,        // New, spelling is UK/AU
    worldX, worldY: f32,
    pxWid, pxHei:   f32,
}

LDtk_Neighbor :: struct {
    levelIid: string,
    dir:      string,
}

Similarly, update the LDtk_Entity struct to include iid:

LDtk_Entity :: struct {
    iid:            string, // New
    __identifier:   string,
    __worldX:       f32,
    __worldY:       f32,
    width, height:  f32,
    __tags:         []string,
    fieldInstances: []LDtk_Field_Instance,
}

Extending the Game State

We'll introduce a new Level struct to encapsulate level-specific data:

Level :: struct {
    iid, name:    string,
    player_spawn: Maybe(Vec2),
    level_min:    Vec2,
    level_max:    Vec2,
    entities:     [dynamic]Entity,
    colliders:    [dynamic]Rect,
    bg_tiles:     [dynamic]Tile,
    tiles:        [dynamic]Tile,
    spikes:       [dynamic]Spike,
    falling_logs: [dynamic]Falling_Log,
    doors:        [dynamic]Door,
}

Door :: struct {
    iid:      string,
    rect:     Rect,
    to_level: string,
    to_iid:   string,
}

Update the Game_State struct to include level definitions and current level:

Game_State :: struct {
    camera:                rl.Camera2D,
    // level_min, level_max:  Vec2,              // Deleted
    player_texture:        rl.Texture,        // New
    tileset_texture:       rl.Texture,        // New
    level_defintions:      map[string]Level,  // New
    level:                 ^Level,            // New
    doors:                 [dynamic]Door,     // New
    // ...
}

Also, change the global gs variable to be a pointer:

gs: ^Game_State // Changed from 'gs: Game_State'

NOTE: Anywhere that was using &gs must now use gs instead.

Parsing and Storing Levels

We need to parse each level from the LDtk data and store it in our game state.

Add the following procedure to parse and store levels:

level_parse_and_store :: proc(gs: ^Game_State, level: ^LDtk_Level) {
    l: Level

    l.iid = strings.clone(level.iid)
    l.name = strings.clone(level.identifier)

    l.level_min = {level.worldX, level.worldY}
    l.level_max = l.level_min + {level.pxWid, level.pxHei}

    // Iterate through the layer instances
    for layer in level.layerInstances {
        switch layer.__identifier {
        case "Entities":
            for entity in layer.entityInstances {
                switch entity.__identifier {
                case "Player":
                    l.player_spawn = Vec2{entity.__worldX, entity.__worldY}
                case "Door":
                    ref := entity.fieldInstances[0].__value.(LDtk_Entity_Ref)

                    pos := Vec2{entity.__worldX, entity.__worldY}
                    size := Vec2{entity.width, entity.height}

                    side: Direction
                    if entity.__worldX + entity.width == l.level_max.x {
                        side = .Right
                        size.x = 4
                    } else if entity.__worldX == l.level_min.x {
                        side = .Left
                        size.x = 4
                    } else if entity.__worldY + entity.height == l.level_max.y {
                        side = .Down
                    }

                    door := Door {
                        rect     = {pos.x, pos.y, size.x, size.y},
                        iid      = strings.clone(entity.iid),
                        to_level = strings.clone(ref.levelIid),
                        to_iid   = strings.clone(ref.entityIid),
                    }
                    append(&l.doors, door)
                case "Spikes":
                    facing := Direction.Right
                    x, y := entity.__worldX, entity.__worldY
                    width, height := entity.width, entity.height

                    switch entity.fieldInstances[0].__value {
                    case "Up":
                        facing = .Up
                        y += SPIKE_DIFF
                        height = SPIKE_DEPTH
                    case "Right":
                        width = SPIKE_DEPTH
                    case "Down":
                        facing = .Down
                        height = SPIKE_DEPTH
                    case "Left":
                        facing = .Left
                        width = SPIKE_DEPTH
                        x += SPIKE_DIFF
                    }
                    append(&l.spikes, Spike{collider = {x, y, width, height}, facing = facing})
                case "Falling_Log":
                    append(
                        &l.falling_logs,
                        Falling_Log {
                            collider = {
                                entity.__worldX,
                                entity.__worldY,
                                entity.width,
                                entity.height,
                            },
                        },
                    )
                }

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

                    append(
                        &l.entities,
                        Entity {
                            collider = {
                                entity.__worldX,
                                entity.__worldY,
                                def.collider_size.x,
                                def.collider_size.y,
                            },
                            move_speed = def.move_speed,
                            behaviors = def.behaviors,
                            health = def.health,
                            on_hit_damage = def.on_hit_damage,
                            texture = &def.texture,
                            animations = def.animations,
                            current_anim_name = def.initial_animation,
                            debug_color = rl.RED,
                            flags = {.Debug_Draw},
                            hit_response = def.hit_response,
                            hit_duration = def.hit_duration,
                        },
                    )
                }
            }
        case "Collisions":
            // Process collision tiles
            solid_tiles := make([dynamic]Rect, context.temp_allocator)

            x, y: f32
            for v, i in layer.intGridCsv {
                if v != 0 {
                    append(&solid_tiles, Rect{x, y, TILE_SIZE, TILE_SIZE})
                }
                x += TILE_SIZE
                if (i + 1) % layer.__cWid == 0 {
                    y += TILE_SIZE
                    x = 0
                }
            }

            // Combine tiles into larger rectangles
            wide_rect := solid_tiles[0]
            wide_rects := make([dynamic]Rect, context.temp_allocator)

            for i in 1 ..< len(solid_tiles) {
                rect := solid_tiles[i]

                if rect.x == wide_rect.x + wide_rect.width {
                    wide_rect.width += TILE_SIZE
                } else {
                    append(&wide_rects, wide_rect)
                    wide_rect = rect
                }
            }

            append(&wide_rects, wide_rect)

            // Sort and merge vertically
            slice.sort_by(wide_rects[:], proc(a, b: Rect) -> bool {
                if a.x != b.x do return a.x < b.x
                return a.y < b.y
            })

            big_rect := wide_rects[0]

            for i in 1 ..< len(wide_rects) {
                rect := wide_rects[i]

                if rect.x == big_rect.x &&
                   big_rect.width == rect.width &&
                   big_rect.y + big_rect.height == rect.y {
                    big_rect.height += TILE_SIZE
                } else {
                    big_rect.x += level.worldX
                    big_rect.y += level.worldY
                    append(&l.colliders, big_rect)
                    big_rect = rect
                }
            }

            big_rect.x += level.worldX
            big_rect.y += level.worldY
            append(&l.colliders, big_rect)

            // Tiles
            for auto_tile in layer.autoLayerTiles {
                append(&l.tiles, Tile{auto_tile.px + l.level_min, auto_tile.src, auto_tile.f})
            }
        case "Background":
            for auto_tile in layer.autoLayerTiles {
                append(&l.bg_tiles, Tile{auto_tile.px + l.level_min, auto_tile.src, auto_tile.f})
            }
        }
    }

    // Adjust falling log rope heights after colliders are processed
    for &falling_log in l.falling_logs {
        center := rect_center(falling_log.collider)
        hits, hits_ok := raycast(center, UP * (l.level_max.y - l.level_min.y), l.colliders[:])
        if hits_ok {
            slice.sort_by(hits, proc(a, b: Vec2) -> bool {
                return a.y > b.y || a.y == b.y
            })
            falling_log.rope_height = center.y - hits[0].y - falling_log.collider.height / 2
        }
    }

    // Remove background tiles under spikes
    #reverse for tile, i in l.bg_tiles {
        for spike in l.spikes {
            if rl.CheckCollisionRecs({tile.pos.x, tile.pos.y, 16, 16}, spike.collider) {
                unordered_remove(&l.bg_tiles, i)
            }
        }
    }

    // Store the level in the game state's level definitions
    gs.level_defintions[l.iid] = l
}

Loading Levels

Now, we need a function to load levels into the game state when required:

level_load :: proc(gs: ^Game_State, level: ^Level) {
    gs.level = level

    player := entity_get(gs.player_id)
    player_anim_name: string
    if player != nil {
        player_anim_name = strings.clone(player.current_anim_name, context.temp_allocator)
    }

    // Clear existing level data
    clear(&gs.entities)
    clear(&gs.colliders)
    clear(&gs.bg_tiles)
    clear(&gs.tiles)
    clear(&gs.spikes)
    clear(&gs.falling_logs)
    clear(&gs.doors)

    // Load new level data
    append(&gs.entities, ..level.entities[:])
    append(&gs.colliders, ..level.colliders[:])
    append(&gs.bg_tiles, ..level.bg_tiles[:])
    append(&gs.tiles, ..level.tiles[:])
    append(&gs.spikes, ..level.spikes[:])
    append(&gs.falling_logs, ..level.falling_logs[:])
    append(&gs.doors, ..level.doors[:])

    // Spawn the player
    spawn_player(gs)

    if player_anim_name != "" {
        player = entity_get(gs.player_id)
        for k in player.animations {
            if k == player_anim_name {
                player.current_anim_name = k
            }
        }
    }
}

Spawning the Player

Modify the spawn_player procedure to use the player's spawn point from the current level:

spawn_player :: proc(gs: ^Game_State) {
    // ... animations ...

    gs.player_id = entity_create(
        {
            x = gs.level.player_spawn.?.x,
            y = gs.level.player_spawn.?.y,
            width = 16,
            height = 38,
            move_speed = 220,
            jump_force = 650,
            on_enter = player_on_enter,
            health = 5,
            max_health = 5,
            debug_color = rl.GREEN,
            texture = &gs.player_texture,
            animations = {
                "idle" = player_anim_idle,
                "jump" = player_anim_jump,
                "jump_fall_inbetween" = player_anim_jump_fall_inbetween,
                "fall" = player_anim_fall,
                "run" = player_anim_run,
                "attack" = player_anim_attack,
            },
            current_anim_name = "idle",
        },
    )

    if pos, ok := gs.level.player_spawn.?; ok {
        gs.safe_position = pos
    }
}

Initialising the Game State

In the main procedure, initialise the game state and load the textures:

// main :: proc() { // omitted to save horizontal space
gs = new(Game_State)

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

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

// NOTE: We are going to move all this stuff soon
// Load player texture
gs.player_texture = rl.LoadTexture("assets/textures/player_120x80.png")
gs.tileset_texture = rl.LoadTexture("assets/textures/tileset.png")

// Define enemy definitions (as before)
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,
}

Parsing LDtk Data and Loading Levels

After reading the LDtk data, parse and store each level:

// Load level data
{
    // ... read file, json.unmarshal ...

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

level_load(gs, &gs.level_defintions["4d7f9832-73f0-11ef-a130-47a0a21e21a3"]) // NOTE: Use your level's IID from LDtk

Adjusting the Game Loop

In the game loop, update references from gs.level_min and gs.level_max to gs.level.level_min and gs.level.level_max:

if gs.camera.target.x < gs.level.level_min.x {
    gs.camera.target.x = gs.level.level_min.x
}

if gs.camera.target.y < gs.level.level_min.y {
    gs.camera.target.y = gs.level.level_min.y
}

if gs.camera.target.x + RENDER_WIDTH > gs.level.level_max.x {
    gs.camera.target.x = gs.level.level_max.x - RENDER_WIDTH
}

if gs.camera.target.y + RENDER_HEIGHT > gs.level.level_max.y {
    gs.camera.target.y = gs.level.level_max.y - RENDER_HEIGHT
}

Ensure that you replace all instances where gs.level_min and gs.level_max were used.

Likewise, where tileset_texture was a local variable in main, it's now gs.tileset_texture.

Rendering Doors

To visualise doors for debugging purposes, add the following in the rendering section after drawing entities:

for door in gs.doors {
    rl.DrawRectangleLinesEx(door.rect, 1, rl.BLUE)
}

Handling Level Transitions

In src/player.odin, update the player_update procedure to handle door collisions and level transitions:

player_update :: proc(gs: ^Game_State, dt: f32) {
    // ...

    for door in gs.doors {
        if rl.CheckCollisionRecs(player.collider, door.rect) {
            if level_def, ok := gs.level_defintions[door.to_level]; ok {
                for other_door in level_def.doors {
                    if other_door.iid == door.to_iid {
                        dir := linalg.normalize0(
                            Vec2{other_door.rect.x, other_door.rect.y} -
                            Vec2{door.rect.x, door.rect.y},
                        )

                        player_spawn := Vec2{other_door.rect.x, other_door.rect.y}

                        if dir.x > 0 {
                            player_spawn.x += other_door.rect.width
                        } else if dir.x < 0 {
                            player_spawn.x -= (other_door.rect.width + player.collider.width + 8)
                        }
                        if dir.x != 0 {
                            player_spawn += other_door.rect.height - player.collider.height
                        }

                        level_def.player_spawn = player_spawn
                    }
                }

                // Update the level definitions and load the new level
                gs.level_defintions[level_def.iid] = level_def
                level_load(gs, &gs.level_defintions[door.to_level])
            }
        }
    }
}

This code checks if the player is colliding with a door, finds the corresponding door in the destination level, adjusts the player's spawn position accordingly, and then loads the new level.

Cleaning Up Memory

At the end of each frame in main, add the following line to free up temporary memory allocations:

free_all(context.temp_allocator)

This ensures that any temporary memory used during the frame is properly released.

That's it! We've successfully added support for multiple levels and door transitions in our game.