PVG
19. Enemy Hit Feedback and Animating Enemies — Program Video Games

19. Enemy Hit Feedback and Animating Enemies

[[programvideogames]]Alright, let's dive into adding some hit feedback and animations for our enemies. This will make our game feel more responsive and engaging.

There's a lot of small changes to get through - let's take it step by step.

First, we're going to update Entity_Flags and Entity:

Entity_Flags :: enum {
        Debug_Draw,
        Left,
        Immortal,
        Frozen, // New flag
}

Entity :: struct {
        // ... other fields remain ...
        hit_timer:                  f32, // How long the hit response lasts
        hit_duration:               f32,
        hit_response:               Entity_Hit_Response,
}

What's Entity_Hit_Response? It's an enum that we can use to perform a specific action when an entity is hit.

Entity_Hit_Response :: enum {
    Stop, // Right now we'll just use this one
    Knockback,
}

Next, we're going to switch from defining entities in LDtk to instead have definitions in our game code:

Enemy_Def :: struct {
    collider_size:       Vec2,
    move_speed:          f32,
    behaviors:           bit_set[Entity_Behaviors],
    health:              int,
    on_hit_damage:       int,
    texture:             rl.Texture2D,
    animations:          map[string]Animation,
    initial_animation:   string,
    hit_response:        Entity_Hit_Response,
    hit_duration:        f32,
    hit_knockback_force: f32,
}

Having a type we can use to describe entity defaults makes more sense when we start needing things like textures. Does LDtk really need to know about our entity textures? I don't think so - just where they are and their name should be enough.

Game_State :: struct {
    camera:                rl.Camera2D,
    level_min, level_max:  Vec2,
    entities:              [dynamic]Entity,
    colliders:             [dynamic]Rect,
    bg_tiles:              [dynamic]Tile,
    tiles:                 [dynamic]Tile,
    spikes:                map[Entity_Id]Direction,
    debug_shapes:          [dynamic]Debug_Shape,
    safe_position:         Vec2,
    safe_reset_timer:      f32,
    player_movement_state: Player_Movement_State,
    player_id:             Entity_Id,
    jump_timer:            f32,
    coyote_timer:          f32,
    enemy_definitions:     map[string]Enemy_Def, // New
    debug_draw_enabled:    bool, // New
    attack_cooldown_timer: f32, // New
    attack_recovery_timer: f32, // New
}

We're also going to add two constants for our player's attack:

ATTACK_COOLDOWN_DURATION :: 0.3
ATTACK_RECOVERY_DURATION :: 0.2

These values allow us to limit the player's attack speed somewhat - as we are going to speed up the animation.

Before we get to that, we'll define our "Walker" Enemy_Def:

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,
}

This new texture is included in the asset files.

I put this just above where we define the player animations.

Speaking of, here's the new attack:

player_anim_attack := Animation {
    size         = {120, 80},
    offset       = {52, 42},
    start        = 0,
    end          = 3,
    row          = 3,
    time         = 0.05, // Changed
    on_finish    = player_on_finish_attack,
    timed_events = {{timer = 0.05, duration = 0.05, callback = player_attack_callback}}, // Changed
}

This gives us a much faster attack, hence why we added a cooldown to it, which we'll implement shortly.

First, let's rewrite the enemy loading code, it's now much simpler:

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

    enemy := 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,
    }

    entity_create(enemy)
}

A bit of cleanup before we leave main.odin:

Let's not draw dead entities:

for &e in gs.entities {
    if .Dead in e.flags do continue

    if e.texture != nil {
        // ...

And let's wrap our debug draw in a check:

if gs.debug_draw_enabled {
    // ... debug drawing code ...
}

Now let's head over to entity.odin and add the new functionality:

    for &e in gs.entities {
        if e.health == 0 && .Immortal not_in e.flags {
            e.flags += {.Dead}
        }

        if e.hit_timer > 0 { // New
            e.hit_timer -= dt
            if e.hit_timer <= 0 {
                #partial switch e.hit_response {
                case .Stop:
                    e.behaviors += {.Walk}
                    e.flags -= {.Frozen}
                }
            }
        }

        if len(e.animations) > 0 {
            anim := e.animations[e.current_anim_name]

            // Switch frames?
            if .Frozen not_in e.flags { // New
                e.animation_timer -= dt
            }

First, we make sure to decrease each entity's hit_timer and reset the flags and behaviors when it runs out.

Next, we stop decreasing the animation_timer if the entity has Frozen status.

Down the bottom of the file, create a new procedure:

entity_hit :: proc(id: Entity_Id, hit_force := Vec2{}) {
    entity := entity_get(id)
    entity.hit_timer = entity.hit_duration

    switch entity.hit_response {
    case .Stop:
        entity.behaviors -= {.Walk}
        entity.flags += {.Frozen}
        entity.vel = 0
    case .Knockback:
        entity.vel += hit_force
    }
}

This procedure resets the hit timer and updates the entity state based on the hit_response.

We add an optional parameter hit_force which we'll use for knockback.

Now we'll jump over to physics and delete the following lines:

// Delete these
if entity.vel.x < 0 do entity.flags += {.Left}
if entity.vel.x > 0 do entity.flags -= {.Left}

Enemies don't need these as the flag gets set in behavior.odin, and for our player, well, let's go to player.odin right now:

// At the top, import a new package
import "core:math/linalg"

Add a new state:

Player_Movement_State :: enum {
    Uncontrollable,
    Attacking,
    Attack_Cooldown, // New
    Idle,
    Run,
    Jump,
    Fall,
}

Update the player_update procedure to set the .Left flag and decrease the attack recovery timer:

player.vel.x = input_x * player.move_speed // Stays the same

if player.vel.x > 0 do player.flags -= {.Left}
if player.vel.x < 0 do player.flags += {.Left}

if gs.attack_recovery_timer > 0 {
    gs.attack_recovery_timer -= dt
    player.vel *= 0.5
}

Add the new case for Attack_Cooldown, we also delete the code that set vel to 0 in Attacking - this allows us to have that knockback effect:

case .Attacking:
case .Attack_Cooldown:
    gs.attack_cooldown_timer -= dt
    if gs.attack_cooldown_timer <= 0 {
        gs.player_movement_state = .Idle
    }
    try_run(gs, player)

In try_attack, we want to set our cooldown timer so we can't attack super fast:

try_attack :: proc(gs: ^Game_State, player: ^Entity) {
    if rl.IsMouseButtonPressed(.LEFT) {
        switch_animation(player, "attack")
        gs.player_movement_state = .Attacking
        gs.attack_cooldown_timer = ATTACK_COOLDOWN_DURATION
    }
}

Down in player_on_finish_attack, we'll change the state to Attack_Cooldown:

player_on_finish_attack :: proc(gs: ^Game_State, player: ^Entity) {
    switch_animation(player, "idle")
    gs.player_movement_state = .Attack_Cooldown
}

And, in player_attack_callback we need to call this new functionality:

player_attack_callback :: proc(gs: ^Game_State, player: ^Entity) {
    center := Vec2{player.x, player.y}
    center += {.Left in player.flags ? -30 + player.collider.width : 30, 20}

    for &e, i in gs.entities {
        id := Entity_Id(i)
        if id == gs.player_id do continue
        if .Dead in e.flags do continue
        if .Immortal in e.flags do continue

        if rl.CheckCollisionCircleRec(center, 25, e.collider) {
            entity_damage(Entity_Id(i), 1)

            // Below is new
            a := rect_center(player.collider)
            b := rect_center(e.collider)
            dir := linalg.normalize0(b - a)

            player.vel.x = -dir.x * 500
            player.vel.y = -dir.y * 200 - 100

            gs.attack_recovery_timer = ATTACK_RECOVERY_DURATION

            entity_hit(Entity_Id(i), dir * 500)
        }
    }
}

rect_center is a new helper proc that I have put in src/util.odin:

package main

rect_center :: #force_inline proc(r: Rect) -> Vec2 {
    return Vec2{r.x, r.y} + Vec2{r.width, r.height} * 0.5
}

#force_inline is an instruction to the compiler to copy the body of the function to the place it's called at, rather than using a function call.