PVG
20. Hazards and Obstacles — Program Video Games

20. Hazards and Obstacles

First, I'll cover the changes in main.odin.

Remove this import (if you still have it):

import "core:fmt"

We add a new field to the LDtk_Field_Instance_Value union:

LDtk_Field_Instance_Value :: union {
    // ... other fields remain ...
    string,
}

This allows for string values in LDtk field instances.

We update the Game_State struct:

// ... other fields remain ...
spikes:                [dynamic]Spike,
falling_logs:          [dynamic]Falling_Log,

We're changing spikes from a map to a dynamic array and adding a new dynamic array for falling logs.

Why are we are changing how Spikes work?

Having everything be a generic Entity type means adding more and more special-case code to Entity.

For example, how do we know an Entity is a Spike?

What if we want the Enemies to make sure they don't walk into Spikes?

We could do round-about checks on a generic Entity.

Or, we just add Spike as a type.

It's what I wanted to do in the beginning, but misguidedly made it more generic which has just caused more awkward code.

We define two new structs:

Spike :: struct {
    collider: Rect,
    facing:   Direction,
}

Falling_Log :: struct {
    collider:    Rect,
    rope_height: f32,
    state:       enum {
        Default,
        Falling,
        Settled,
    },
}

We update the background color:

BG_COLOR :: rl.Color{50, 44, 67, 255}

This changes the background to a dark purple color.

We delete the spike_on_enter procedure as we'll use something specific to the player, instead.

In the main procedure, we update the level loading code:

case "Player":
    gs.player_id = entity_create(
        {
            x = entity.__worldX,
            y = entity.__worldY,
            // ... other fields ...
        },
    )
    gs.safe_position = {entity.__worldX, entity.__worldY}

We're now setting the safe position to the player's initial position - just making sure we always have one.

We add handling for spikes and falling logs in the level loading:

case "Spikes":
    facing := Direction.Right
    x, y := entity.__worldX, entity.__worldY
    width, height := entity.width, entity.height

    // We know the shape of our data - we don't have to pretend we don't
    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(
        &gs.spikes,
        Spike{collider = {x, y, width, height}, facing = facing},
    )
case "Falling_Log":
    append(
        &gs.falling_logs,
        Falling_Log {
            collider = {
                entity.__worldX,
                entity.__worldY,
                entity.width,
                entity.height,
            },
        },
    )

This code creates spike and falling log entities based on their placement in the LDtk level.

We update the background tile creation to avoid placing tiles where spikes are:

// When drawing the background - don't draw behind spikes
// This prevents trees and bushes from spawning behind spikes
// It's why we changed the BG_COLOR
for spike in gs.spikes {
    if !rl.CheckCollisionRecs(
        spike.collider,
        {auto_tile.px.x, auto_tile.px.y, 16, 16},
    ) {
        append(
            &gs.bg_tiles,
            Tile{auto_tile.px, auto_tile.src, auto_tile.f},
        )
    }
}

After loading the layers, we calculate the rope height for falling logs:

for layer in level.layerInstances {
    // ... layer loading code ...
}

for &falling_log in gs.falling_logs {
    center := rect_center(falling_log.collider)
    hits, hits_ok := raycast(
        center,
        UP * (gs.level_max.y - gs.level_min.y),
        gs.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
    }
}

In the main game loop, we update the physics call and add falling log behaviour:

player_update(&gs, dt)
entity_update(&gs, dt)
physics_update(gs.entities[:], gs.colliders[:], gs.falling_logs[:], dt)
behavior_update(gs.entities[:], gs.colliders[:], dt)

for &falling_log in gs.falling_logs {
    if falling_log.state == .Falling {
        falling_log.collider.y += dt * 600

        for collider in gs.colliders {
            if rl.CheckCollisionRecs(collider, falling_log.collider) {
                if collider.y <= falling_log.collider.y + falling_log.collider.height {
                    falling_log.state = .Settled
                    append(&gs.colliders, falling_log.collider)
                    break
                }
            }
        }

        for entity, i in gs.entities {
            if rl.CheckCollisionRecs(entity.collider, falling_log.collider) {
                entity_damage(Entity_Id(i), 999)
            }
        }
    }
}

This code handles the falling and settling of logs, as well as damaging entities they collide with.

We update the safe position check to include spikes:

// Without this check there's a chance to set the safe position to inside spikes on the ground
for spike in gs.spikes {
    if rl.CheckCollisionRecs(spike.collider, player.collider) {
        break safety_check
    }
}

In the drawing section, we add code to draw spikes and falling logs:

for spike in gs.spikes {
    rl.DrawRectangleLinesEx(spike.collider, 1, rl.YELLOW)
}

for falling_log in gs.falling_logs {
    center := rect_center(falling_log.collider)
    if falling_log.state == .Default {
        rope_pos := Vec2{center.x, center.y - falling_log.collider.height / 2}
        rl.DrawLineEx(rope_pos, rope_pos - {0, falling_log.rope_height}, 1, rl.BROWN)
    }
    rl.DrawRectangleLinesEx(falling_log.collider, 1, rl.BROWN)
}

We also draw the safe position (again):

rl.DrawRectangleLinesEx({gs.safe_position.x, gs.safe_position.y, 16, 16}, 1, rl.BLUE)

Moving on to the changes in physics.odin.

We update the physics_update procedure signature to take the logs:

physics_update :: proc(
    entities: []Entity,
    static_colliders: []Rect,
    logs: []Falling_Log,
    dt: f32,
)

We add falling log collision checks in the Y and X axis updates:

for log in logs {
    if rl.CheckCollisionRecs(entity.collider, log.collider) {
        resolve_entity_vs_static_y(&entity, log.collider)
        break
    }
}

// ... and similarly for X axis

We add two new procedures:

resolve_entity_vs_static_y :: proc(entity: ^Entity, static: Rect) {
    if entity.vel.y > 0 {
        entity.y = static.y - entity.height
        entity.flags += {.Grounded}
    } else {
        entity.y = static.y + static.height
    }
    entity.vel.y = 0
}

resolve_entity_vs_static_x :: proc(entity: ^Entity, static: Rect) {
    if entity.vel.x > 0 {
        entity.x = static.x - entity.width
    } else {
        entity.x = static.x + static.width
    }
    entity.vel.x = 0
}

These procedures handle collision resolution for entities against static objects.

Finally, we'll look at the changes in player.odin:

We add spike collision handling in the player_update procedure:

for spike in gs.spikes {
    if rl.CheckCollisionRecs(spike.collider, player.collider) {
        player.x = gs.safe_position.x
        player.y = gs.safe_position.y
        player.vel = 0
        gs.safe_reset_timer = PLAYER_SAFE_RESET_TIME
        gs.player_movement_state = .Uncontrollable
        switch_animation(player, "idle")
    }
}

We update the player_attack_callback procedure to include falling log interaction:

for &falling_log in gs.falling_logs {
    if falling_log.state != .Default do continue

    log_center := rect_center(falling_log.collider)
    rope_pos := Vec2{log_center.x, log_center.y - falling_log.collider.height / 2}
    rect := Rect {
        rope_pos.x - 1,
        rope_pos.y - falling_log.rope_height,
        2,
        falling_log.rope_height - TILE_SIZE,
    }

    if rl.CheckCollisionCircleRec(center, 25, rect) {
        falling_log.state = .Falling
    }
}