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
&gsmust now usegsinstead.
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.