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.