28. Composing Enemy Behaviors
[[programvideogames]]In this lesson we'll be composing enemy behaviors.
We'll also be adding entirely new ones.
In the assets you'll find two new sprite sheets - a rabbit and a pig.
These will be our test subjects.
We're adding yet more state to our Entity struct and Entity_Behaviors enum. I'm working on measuring performance in the background to see when we may want to change strategy.
Before we get started, you may want to turn on debug drawing again:
main :: proc() {
gs = new(Game_State)
gs.debug_draw_enabled = true
// ...
}
I found it quite useful when working on behaviors.
Entity_Behaviors :: enum {
Walk,
Flip_At_Wall,
Flip_At_Edge,
// New below
Wander,
Hop,
Charge_At_Player,
}
Entity :: struct {
// ... other fields remain ...
destination: Maybe(Vec2),
charge_dir: Maybe(Vec2),
wander_timer: f32,
hop_timer: f32,
charge_timer: f32,
charge_cooldown_timer: f32,
}
We'll need to add new enemy definitions as well.
Just below where we define "Walker".
The reason I call them by their behaviors is because their sprites could be anything. If we call then "bunny" and "pig" then we have a preconceived notion of what that is.
With names based on behavior, we could come up with different and interesting art for them - potentially lore, as well.
gs.enemy_definitions["Jumper"] = Enemy_Def {
collider_size = {50, 48},
health = 2,
behaviors = {.Wander, .Hop},
on_hit_damage = 1,
texture = rl.LoadTexture("assets/textures/bunny_50x48.png"),
animations = {
"idle" = Animation{size = {50, 48}},
"hop" = Animation {
size = {50, 48},
offset = {},
start = 0,
end = 2,
time = 0.15,
flags = {},
},
},
initial_animation = "idle",
hit_response = .Knockback,
hit_duration = 0.25,
}
gs.enemy_definitions["Charger"] = Enemy_Def {
collider_size = {64, 35},
health = 3,
move_speed = 25,
behaviors = {.Walk, .Flip_At_Wall, .Flip_At_Edge, .Charge_At_Player},
on_hit_damage = 2,
texture = rl.LoadTexture("assets/textures/pig_64x35.png"),
animations = {
"walk" = Animation{size = {64, 35}, end = 3, time = 0.25, flags = {.Loop}},
"charge" = Animation{size = {64, 35}, end = 3, time = 0.15},
},
initial_animation = "walk",
hit_response = .Stop,
hit_duration = 0.25,
}
As you can see we've added all the behaviors that the "Walker" has to the "Charger".
The "Jumper" only has new behaviors.
I will admit these behaviors didn't come out as clean as I'd like.
First, the Wander:
if .Wander in e.behaviors {
e.wander_timer -= dt
_, has_destination := e.destination.?
if e.wander_timer < 0 && !has_destination {
// Pick a destination
// 1. Inside level
// 2. Not in a wall
ray_start := rect_center(e.collider)
ray_dir := rand.uint32() % 2 == 0 ? LEFT : RIGHT
ray_length := rand.float32_range(20, 150)
ray_end := ray_start + ray_dir * ray_length
within_left := ray_end.x > gs.level.level_min.x + e.collider.width
within_right := ray_end.x < gs.level.level_max.x - e.collider.width
within_bounds := within_left && within_right
if within_bounds {
_, did_hit := raycast(ray_start, ray_dir * ray_length, static_colliders)
if !did_hit {
e.destination = ray_end
}
}
}
}
The entity can wander left or right, and checks to make sure the destination is within level bounds and no inside a wall.
The Wander behavior by itself doesn't actually move the entity.
For now, only Hop takes advantage of the destination. I want to add it to Walk later on.
if .Hop in e.behaviors {
e.hop_timer -= dt
if destination, ok := e.destination.?; ok && e.hop_timer < 0 {
dir := linalg.normalize0(destination - rect_center(e.collider))
e.vel.x = dir.x * 200
e.vel.y = UP.y * 400
e.destination = nil
e.hop_timer = rand.float32_range(1, 3)
switch_animation(&e, "hop")
if e.vel.x < 0 {
e.flags += {.Left}
} else {
e.flags -= {.Left}
}
} else if .Grounded in e.flags {
e.vel.x = 0
switch_animation(&e, "idle")
}
}
Basically, we check if we can hop hop_timer < 0 and if there's a destination.
If there is, the entity "hops" toward it.
When it hits the ground, the velocity is reset to 0 and the animation reset.
Finally, we have Charge_At_Player:
if .Charge_At_Player in e.behaviors {
is_charging := e.charge_timer > 0
if is_charging {
e.charge_timer -= dt
if e.charge_timer < 0 {
is_charging = false
e.charge_cooldown_timer = 4
e.vel.x = 0
e.charge_dir = nil
}
} else {
e.charge_cooldown_timer -= dt
}
can_charge := e.charge_cooldown_timer < 0
if can_charge && !is_charging {
player := entity_get(gs.player_id)
player_pos := rect_center(player.collider)
pos := rect_center(e.collider)
if linalg.distance(player_pos, pos) < 200 {
e.charge_dir = linalg.normalize0(player_pos - pos)
e.charge_timer = 0.35
}
}
if charge_dir, ok := e.charge_dir.?; ok {
e.vel.x = charge_dir.x * 300
if charge_dir.x > 0 {
e.flags -= {.Left}
} else {
e.flags += {.Left}
}
}
}
This one is a bit more complex. If the entity is charging, we decrease the timer and then check if it's below 0 and do all the state reset we need to.
If it isn't, we decrease the cooldown timer.
So charge_timer controls how long the charge lasts for and charge_cooldown_timer lets us know when we can charge again.
If we can charge and are not currently charging, we'll check to see if the player is nearby.
If so, we'll start the charge.
Finally, if charge_dir is set we must be charging, so use that variable to set the velocity and sprite direction.
That's actually it, though I fixed a bug we had in the entity.odin file.
Checking for e.health == 0 && .Immortal not_in e.flags
However, if an entities health went to below 0, like -1, then this wouldn't apply and death would be impossible.
So, change the condition to e.health <= 0 && .Immortal not_in e.flags.