PVG
24. Checkpoints — Program Video Games

24. Checkpoints

[[programvideogames]]In this lesson we'll add checkpoints to the game.

LDtk

First, if you are making your own level in LDtk, you'll need to create a new Entity type.

Create an Entity called "Checkpoint" - with no data added.

The ones I've made are 32x32 pixels.

Code

We need to know which checkpoint is active regardless of the current level.

We also need somewhere to store the current level's checkpoints.

As such, let's add some fields to Game_State and Level and a new type to store the data.

Game_State :: struct {
    // ... other fields ...
    checkpoints: [dynamic]Checkpoint,
    checkpoint_level_iid: string,
    checkpoint_iid: string,
    original_spawn_point: Vec2,
}

Level :: struct {
    // ... other fields ...
    checkpoints: [dynamic]Checkpoint,
}

Checkpoint :: struct {
    iid: string,
    using position: Vec2,
}

If the player has no active checkpoint, we want to spawn them at the beginning of the game, instead.

To do that, we'll move our hard-coded IID into a constant global so we can reload the first level from anywhere with ease.

FIRST_LEVEL_IID :: "4d7f9832-73f0-11ef-a130-47a0a21e21a3"

Of course we need to update our first level_load call as well:

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

While we are in the area, we need some way to trigger a respawn event - so let's add an on_death procedure pointer that we can use.

Entity :: struct {
    // ... other fields ...
    on_death: proc(entity: ^Entity, gs: ^Game_State)
}

I also thought I'd take a second to show you an Odin feature - take note that we are passing a pointer to the type of the struct that this proc is declared on.

Head over to entity.odin and now we can use our proc:

for &e in gs.entities {
        if e.health == 0 && .Immortal not_in e.flags {
            e.flags += {.Dead}
            if e.on_death != nil {
                e->on_death(gs)
            }
        }
    // ...
}

The -> syntax is an Odin feature designed for dealing with Win32's COM (Component Object Model) - but we can utilise it as well.

When called on a proc pointer, the first argument must be a pointer of the same type - then the instance is passed. This is similar to the this keyword in other languages.

I usually only use this for API related things, but I thought it'd be neat to show off here.

Next, head back on over to main.odin - we have a few changes to make.

I've moved the entire player_on_enter proc from main.odin to player.odin - just some house-keeping.

We need to load our Checkpoints - so in our entity switch where we load Doors, add a new case:

case "Checkpoint":
    append(&l.checkpoints, Checkpoint {
        iid = strings.clone(entity.iid),
        x = entity.__worldX,
        y = entity.__worldY
    })

And in the Player case - we'll store our original spawn point:

case "Player":
    l.player_spawn = Vec2{entity.__worldX, entity.__worldY}
    gs.original_spawn_point = Vec2{entity.__worldX, entity.__worldY}

Fixing a bug that we introduced - the player's health gets reset to full when we change levels.

Down in level_load:

player_anim_name: string
player_health: int // new

if player != nil {
    // ... store anim name
    player_health = player.health // new
}

// ... clear the dynamic arrays ...
clear(&gs.checkpoints) // new

// ... copying data to dynamic arrays ...
append(&gs.checkpoints, ..level.checkpoints[:]) // new

if player_anim_name != "" {
    player = entity_get(gs.player_id)
    player.health = player_health // new
    // ... the rest ...
}

Now we need to update our spawn_player proc so that the player has the player_on_death proc that we'll make shortly.

current_anim_name = "idle",
on_death = player_on_death, // new

Finally, let's draw the checkpoints before moving over to player.odin

for checkpoint in gs.checkpoints {
    rl.DrawRectangleLinesEx({checkpoint.x, checkpoint.y, 32, 32}, 1, rl.ORANGE)
}

I've decided to draw them as orange boxes for now until we have some sprites.

Now, in player.odin we can bring it all together. First, in player_update under the Idle case:

// ...
try_attack(gs, player)
try_activate_checkpoint(gs, player) // new

And here is that procedure:

try_activate_checkpoint :: proc(gs: ^Game_State, player: ^Entity) {
    if rl.IsKeyPressed(.W) {
        for checkpoint in gs.checkpoints {
            rect := Rect{checkpoint.x, checkpoint.y, 32, 32}
            if rl.CheckCollisionRecs(rect, player.collider) {
                gs.checkpoint_level_iid = gs.level.iid
                gs.checkpoint_iid = checkpoint.iid
            }
        }
    }
}

We check if the button is pressed - I chose W as it's probably what most people use as "up". We simply iterate over the loaded checkpoints and check if we are colliding - if so, update the checkpoint fields in our game state.

Remember, we cloned these strings into the permanent allocator when parsing the LDtk data - so they should be around forever.

Finally, here is the player_on_death proc:

player_on_death :: proc(player: ^Entity, gs: ^Game_State) {
    player := player
    spawn_point := gs.original_spawn_point

    // TODO: Effects

    if gs.checkpoint_level_iid != "" && gs.checkpoint_iid != "" {
        level_load(gs, &gs.level_defintions[gs.checkpoint_level_iid])

        for checkpoint in gs.checkpoints {
            if checkpoint.iid == gs.checkpoint_iid {
                spawn_point.x = checkpoint.x
                spawn_point.y = checkpoint.y
                break
            }
        }
    } else {
        level_load(gs, &gs.level_defintions[FIRST_LEVEL_IID])
    }

    // NOTE: Player pointer invalidated now
    player = entity_get(gs.player_id)
    player.x = spawn_point.x
    player.y = spawn_point.y
}