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 somewheregs.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
dynamicarrays andmap, 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!