PVG
46. Attack Combo and Screen Shake — Program Video Games

46. Attack Combo and Screen Shake

In this lecture, we'll cover adding a 2nd attack animation that can be chained after the first.

We'll also add screen shake.

First, in entity.odin, reset the animation timer to 0 when the animation is finished:

if anim.on_finish != nil {
    anim.on_finish(gs, e)
}
e.animation_timer = 0

This will ensure if we check the animation timer on the last frame, that we can compare with 0 - otherwise, it may be something like 0.16 - which is almost 0 and almost a single frame time (at 60 FPS).

Now in main.odin, we'll need to import a package, create a type, and 3 new fields on Game_State:

import "core:math/rand"

Screen_Shake :: struct {
    strength: f32,
    timer: f32,
}

Game_State :: struct {
    // ...
    combo_window_timer: f32,
    buffered_second_attack: bool,
    screen_shakers: [dynamic]Screen_Shake,
}

combo_window_timer is used to go into the 2nd attack animation. Press attack after the timer runs out, and the first attack will be performed again.

buffered_second_attack is set when the player presses attack just before the first attack ends - that way they can smoothly transition to the 2nd attack without having to get the timing perfect.

screen_shakers is going to hold active Screen_Shake structs - which we'll use then remove when their timer runs out.

Optional: Change the player's width from 16 to 14 in spawn_player.
Optional: Put this somewhere gs.save_data.collected_power_ups += {.Dash} to have access to the dash power.

Down in spawn_player, we'll add a new attack animation: "attack_2":

attack_2_timed_events: [dynamic]Animation_Event

append(&attack_2_timed_events, Animation_Event{timer = 0.01, duration = 0.01, callback = player_attack_sfx})
append(&attack_2_timed_events, Animation_Event{timer = 0.05, duration = 0.05, callback = player_attack_callback})

player.animations["attack_2"] = Animation {
    size           = {120, 80},
    offset         = {52, 42},
    offset_flipped = {52, 42},
    start          = 0,
    end            = 5,
    row            = 4,
    time           = 0.05,
    timed_events   = attack_2_timed_events,
}

Note: Since we are using dynamic arrays and map, we are allocating new ones every time this function runs. This is what's often referred to as a memory leak.

I also deleted the binding for on_finish attached to "attack" and the associated procedure player_on_finish_attack.

In game_update, we want to update the camera to use the Screen_Shake structs:

// Update Camera
{
    render_half_size := Vec2{RENDER_WIDTH, RENDER_HEIGHT} / 2

    gs.camera.target = {player.x, player.y} - render_half_size

    // ...

    // Accumulate magnitude
    screen_shake: Vec2

    #reverse for &shaker, i in gs.screen_shakers {
        if shaker.timer <= 0 {
            unordered_remove(&gs.screen_shakers, i)
        }

        screen_shake.x += rand.float32_range(-shaker.strength, shaker.strength)
        screen_shake.y += rand.float32_range(-shaker.strength, shaker.strength)

        shaker.timer -= gs.delta_time
    }

    gs.camera.target += screen_shake
}

We accumulate the magnitude of all the active shakers - that way a large screen shake doesn't get cancelled by a small screen shake.

This works well because the camera target is set back to the player on the next frame (as you can see at the top).

Screen shake is actually done - that's it.

Let's move over to player.odin:

Player_Movement_State :: enum {
    // ...
    // Delete Attack, new states below:
    Attack_First,
    Attack_First_Recovery,
    Attack_Second,
    Attack_Second_Recovery,
}

COMBO_WINDOW_DURATION :: 0.3
EARLY_INPUT_WINDOW :: 0.2

We've defined concrete states to transition between.

The two _Recovery states could be replaced with Idle and nothing would functionally change with this example. However, you may want to use these states to apply some effects like a different animation, slower speed, etc.

You could also use on_finish functions in place of these states.

Here they are in full:

// At the start of the procedure:
attack_pressed := rl.IsMouseButtonPressed(.LEFT)

// Just after setting input_dir
input_dir = linalg.normalize0(input_dir) // Directions should be normalized

// Where the timers are, these should decrease regardless of state:
gs.attack_cooldown_timer -= dt
gs.combo_window_timer -= dt

// ...

case .Attack_First:
    // Allow for early input during first attack to combo into second attack
    if attack_pressed && player.animation_timer <= EARLY_INPUT_WINDOW {
        gs.buffered_second_attack = true
    }

    if player.animation_timer <= 0 {
        gs.player_movement_state = .Attack_First_Recovery
        gs.combo_window_timer = COMBO_WINDOW_DURATION
        switch_animation(player, "idle")

        if gs.buffered_second_attack {
            gs.player_movement_state = .Attack_Second
            switch_animation(player, "attack_2")
            gs.buffered_second_attack = false
        }
    }
case .Attack_First_Recovery:
    if gs.combo_window_timer <= 0 {
        switch_animation(player, "idle")
        gs.player_movement_state = .Idle
    }

    try_run(gs, player)
    try_jump(gs, player)
    try_dash(gs, player, input_dir)
    try_attack(gs, player)
case .Attack_Second:
    gs.combo_window_timer = 0

    if player.animation_timer <= 0 {
        gs.player_movement_state = .Attack_Second_Recovery
        switch_animation(player, "idle")
        gs.attack_cooldown_timer = ATTACK_COOLDOWN_DURATION
    }
case .Attack_Second_Recovery:
    if gs.attack_cooldown_timer <= 0 {
        switch_animation(player, "idle")
        gs.player_movement_state = .Idle
    }

    try_run(gs, player)
    try_jump(gs, player)
    try_dash(gs, player, input_dir)

try_attack has been modified to handle the combo situation:

try_attack :: proc(gs: ^Game_State, player: ^Entity) {
    attack_pressed := rl.IsMouseButtonPressed(.LEFT)

    if attack_pressed && gs.attack_cooldown_timer <= 0 {
        if gs.combo_window_timer > 0 {
            gs.player_movement_state = .Attack_Second
            switch_animation(player, "attack_2")
            player.vel *= 0.3
        } else {
            switch_animation(player, "attack")
            gs.player_movement_state = .Attack_First
            gs.buffered_second_attack = false
            player.vel *= 0.5
        }
    }
}

I've modified try_dash to do a little screen shake, as well:

try_dash :: proc(gs: ^Game_State, player: ^Entity, input_dir: Vec2) {
    if .Dash not_in gs.save_data.collected_power_ups do return

    if rl.IsMouseButtonPressed(.RIGHT) {
        if gs.dash_cooldown_timer <= 0 {

            // Local copy so we can adjust the fields
            input_dir := input_dir

            if input_dir == 0 {
                if .Left in player.flags {
                    input_dir.x = -1
                } else {
                    input_dir.x = 1
                }
            }

            switch_animation(player, "dash")
            gs.player_movement_state = .Dash
            gs.dash_timer = DASH_DURATION
            // It's already normalized (used to do it here)
            player.vel = DASH_VELOCITY * input_dir

            append(&gs.screen_shakers, Screen_Shake {strength = 1, timer = 0.2})
        }
    }
}

Now we have a simple, yet robust, screen shake system and a simple combo for the player!

That's all for this lecture. Please let me know if you have any questions or comments!