31. Boss Battle
[[programvideogames]]When should we break patterns? That's the question for today.
Let's take a look at the way our behaviour system works:
![[Pasted image 20241112145501.png]]
We have a chain of conditions to check and code to run, but regardless of the state of any one condition, they are all checked.
The benefits of this structure:
- reusable behaviors
- it's easy to extend
- predictable patterns
As you know, we check each flag in sequence and run code in a block...
if .Walk in e.behaviors {
// ...
}
if .Flip_At_Wall in e.behaviors {
// ...
}
However, as usual, there ae trade-offs:
- tuning one behaviour affects all enemies tagged with that behaviour
- complexity is added to all enemies
- specific attack patterns become more difficult to reason about / guarantee
With all that said, this is what I argue we want for a boss battle.
Bosses are usually the most memorable part of Metroidvania or any games that have them, really.
With that in mind, we want to make sure that we can finely tine them and mold their behavior to match our design requirements.
This is what I think bosses should allow for, in terms of programming behaviour:
- complete control over movement
- unique physics, effects, and animations
- independent tuning of parameters
- either:
- predictable attack patterns
- telegraphed attack patterns
With that in mind, this is the structure we'll use for this boss. It's a state machine.
![[Pasted image 20241112150159.png]]
If we were to try to shoe-horn this into the current behaviour system, there would be so many special cases... Timers added to every entity... And, a whole bunch of spaghetti.
We don't want spaghetti. As much as possible, we want sane code.
With all that out of the way, let's implement a custom update loop for our boss.
We'll hook into it from the entity update procedure and then skip the usual update.
Let's get started.
First, in entity.odin, let's create that hook:
entity_update :: proc(gs: ^Game_State, dt: f32) {
for &e in gs.entities {
if .Dead in e.flags do continue // New
if e.on_update != nil { // New
e.on_update(&e, gs, dt)
continue
}
if e.hit_timer > 0 {
// ...
}
if len(e.animations) > 0 { // Updated (fixed bug)
entity_animate(&e, dt)
}
}
}
As you can see, if our entity has a custom proc bound to on_update (which we'll create) then we skip the normal update flow and use this custom proc.
I've also added a skip for dead entities and changed the animation code. As it turns out, we had a bug in the animation system and all animations were updating twice per frame! We'll be sprinkling some fixes for that throughout these changes.
In entity_animate, we want to hoist the Frozen check out of the loop as well as adding remaining time for our animation to the next animation (which would actually subtract as it's a negative number):
entity_animate :: proc(e: ^Entity, dt: f32) {
anim := e.animations[e.current_anim_name]
// Switch frames?
if .Frozen not_in e.flags {
e.animation_timer -= dt
}
if e.animation_timer <= 0 {
e.current_anim_frame += 1
remaining_time := e.animation_timer // This will be negative
e.animation_timer = anim.time + remaining_time
// ... rest of the proc remains ...
This just reduces the nesting a bit and ensures animation times are closer to correct.
Down in entity_hit we'll pre-emptively add a case for our hit_reponse switch statement:
switch entity.hit_response {
case .None: // Do nothing
// ...
}
We will be updating some data fields, so let's go to main.odin:
// Only showing New Fields
// Since there are many small changes, I will comment each one in the code
LDtk_Level :: struct {
// ...
// We will use this to bind an on_enter proc for levels
fieldInstances: []LDtk_Field_Instance,
}
Entity_Flags :: enum {
// ...
// Used for the boss' projectiles
Flying,
}
Entity :: struct {
// ...
// Alternate update code path
on_update: proc(_: ^Entity, _: ^Game_State, dt: f32),
// Callback to destroy boss' projectiles on hit
on_hit_static: proc(_: ^Entity, _: ^Game_State, pos: Vec2, dt: f32),
}
Enemy_Def :: struct {
// ...
// QoL
debug_color: Maybe(rl.Color),
// We should be able to define flags in Enemy_Def
flags: bit_set[Entity_Flags],
// The proc to bind for this enemy type
on_update: proc(_: ^Entity, _: ^Game_State, dt: f32),
}
Animation :: struct {
// ...
// Sometimes animations are not centered
offset_flipped: Vec2,
// Animations may have different spritesheets now
texture: ^rl.Texture2D,
// We can tint animations (used for boss' projectile)
tint: rl.Color,
}
Game_State :: struct {
// ...
// Need to store this texture somewhere accessible
demon_boss_attack_texture: rl.Texture,
}
Level :: struct {
// ...
// Optional proc to call when a level is entered
on_enter: proc(gs: ^Game_State: level: ^Level),
}
Alright, now let's get to using all these new fields. First up, we'll transfer the fields from Enemy_Def onto instances of Entity:
def := &gs.enemy_definitions[entity.__identifier]
append(
&l.entities,
Entity {
// ...
debug_color = def.debug_color.? or_else rl.RED,
flags = def.flags + {.Debug_Draw},
// ...
on_update = def.on_update,
},
)
Before we store the level in gs.level_definitions, we'll check if there's a field instance:
for fi in level.fieldInstances {
switch fi.__identifier {
case "enter":
switch fi.__value {
case "demon_boss_init":
l.on_enter = demon_boss_init
}
}
}
gs.level_definitions[l.iid] = l
// the end of the proc
This is a custom field instance stored in LDtk by clicking the World tab and adding a new field. It's a string type.
The reason we create this is to allow some arbitrary code to run to reset the boss' state.
Down in level_load, we'll want to call this at the end if it exists:
if level.on_enter != nil {
level.on_enter(gs, level)
}
// the end of the proc
Now we'll sprinkle in some of the animation fixes that I talked about.
Basically, we want to halve the time of every animation.
For most animations, the time is set to 0.15. Instead, set it to 0.075.
We also need to add the new offset_flipped field to every animation.
For the player, the value is the same as the normal offset since the animations are centered. Same for the Walker enemy.
I will skip including every changed line as that's the extent of the complication.
Here is the boss enemy def - it's pretty beefy:
gs.demon_boss_attack_texture = rl.LoadTexture("assets/textures/demon_boss_240x192.png")
gs.enemy_definitions["Demon_Boss"] = Enemy_Def {
collider_size = {32, 64},
health = 30,
debug_color = rl.Color{255, 255, 0, 255},
texture = rl.LoadTexture("assets/textures/demon_boss_160x144.png"),
animations = {
"fly" = Animation {
size = {160, 144},
offset = {80, 48},
offset_flipped = {48, 48},
start = 0,
end = 5,
time = 0.075,
flags = {.Loop},
},
"attack" = Animation {
size = {240, 192},
offset = {80, 96},
offset_flipped = {120, 96},
start = 0,
end = 10,
time = 0.075,
on_finish = demon_boss_on_finish_attack,
texture = &gs.demon_boss_attack_texture,
timed_events = {
{duration = 0.600, callback = demon_boss_breath_cb},
{duration = 0.675, callback = demon_boss_breath_cb},
{duration = 0.750, callback = demon_boss_breath_cb},
},
},
},
initial_animation = "fly",
on_update = demon_boss_on_update,
// Skip normal physics collision checks, not affected by gravity
flags = {.Flying, .Kinematic},
}
// This Orb is what the boss shoots out
gs.enemy_definitions["Orb"] = Enemy_Def {
collider_size = {16, 16},
health = 30,
on_hit_damage = 2,
texture = rl.LoadTexture("assets/textures/orb_32x32.png"),
animations = {
"idle" = Animation {
size = {32, 32},
offset = {8, 8},
offset_flipped = {8, 8},
start = 0,
end = 1,
time = 1,
flags = {.Loop},
tint = {0, 255, 255, 255},
},
},
initial_animation = "idle",
flags = {.Flying, .Kinematic},
}
Now that we've got the offsets set up, let's change the drawing code:
In game_update where we draw our entities:
for e in gs.entities { // Don't need to use pointer, anim code removed
if .Dead in e.flags do continue
if e.texture != nil {
// Delete line here that was e.animation_timer -= dt
// ... rest remains the same, until:
offset := .Left in e.flags ? anim.offset_flipped : anim.offset
texture := anim.texture == nil ? e.texture : anim.texture
tint := anim.tint == 0 ? rl.WHITE : anim.tint
rl.DrawTextureRec(texture^, source, {e.x, e.y} - offset, tint)
}
// ... debug draw remains the same
}
Alright, now let's move over to physics.odin where we can use this new Flying flag and also use the new on_hit_static callback:
// First, we remove gravity updates from entities with .Flying
if !(is_player && player_is_dashing) {
if .Flying not_in entity.flags {
entity.vel.y += GRAVITY
if entity.vel.y > TERMINAL_VELOCITY {
entity.vel.y = TERMINAL_VELOCITY
}
}
}
// Create two new variables
did_hit := false
hit_pos: Vec2
Inside, all 4 checks (static_colliders and logs), after resolving the collisions but before break, add these lines:
did_hit = true
hit_pos = {entity.x, entity.y} // Not accurate, but good enough for now
And then after all those checks, in the same scope, add these lines to call our callback proc:
if did_hit && entity.on_hit_static != nil {
entity.on_hit_static(&entity, gs, hit_pos, dt)
}
Finally, we get to the meat. The whole new file: demon_boss.odin:
package main
import "core:math/linalg"
import rl "vendor:raylib"
// State managed separately
@(private = "file")
Demon_Boss_State :: struct {
current: Demon_Boss_State_Type,
searching_timer: f32,
breath_cooldown_timer: f32,
breath_duration_timer: f32,
hovering_timer: f32,
}
@(private = "file")
Demon_Boss_State_Type :: enum {
Searching,
Long_Range_Breath,
Short_Range_Breath,
Hovering,
Retreating,
Swooping,
}
@(private = "file")
state: Demon_Boss_State
// Called on level load
demon_boss_init :: proc(gs: ^Game_State, level: ^Level) {
state.current = .Searching
state.searching_timer = 0
state.breath_cooldown_timer = 0
state.breath_duration_timer = 0
state.hovering_timer = 0
}
// Called every frame
demon_boss_on_update :: proc(e: ^Entity, gs: ^Game_State, dt: f32) {
BREATH_COOLDOWN :: 0.5
BREATH_DURATION :: 3
SEARCHING_DURATION :: 5
HOVER_DURATION :: 2
// Custom Physics
for _ in 0 ..< PHYSICS_ITERATIONS {
// Y axis
step := dt / PHYSICS_ITERATIONS
e.y += e.vel.y * step
e.flags -= {.Grounded}
for static in gs.colliders {
if rl.CheckCollisionRecs(e.collider, static) {
resolve_entity_vs_static_y(e, static)
break
}
}
// X axis
e.x += e.vel.x * step
for static in gs.colliders {
if rl.CheckCollisionRecs(e.collider, static) {
resolve_entity_vs_static_x(e, static)
break
}
}
}
if len(e.animations) > 0 {
entity_animate(e, dt)
}
player := entity_get(gs.player_id)
if rect_center(e).x < rect_center(player).x {
e.flags -= {.Left}
} else {
e.flags += {.Left}
}
switch state.current {
case .Searching:
state.searching_timer -= dt
if state.searching_timer <= 0 {
state.current = .Long_Range_Breath
state.breath_duration_timer = BREATH_DURATION
}
// Try to fly upwards until near ceiling
if e.y > gs.level.level_min.y + 64 {
e.vel.y = -100
}
// Turn around at edges
if .Left in e.flags {
e.vel.x = -150
if e.x <= gs.level.level_min.x + 64 {
e.flags -= {.Left}
}
} else {
e.vel.x = 150
if e.x >= gs.level.level_max.x - 64 {
e.flags += {.Left}
}
}
case .Long_Range_Breath:
// Shoot fireball at player
state.breath_cooldown_timer -= dt
state.breath_duration_timer -= dt
if state.breath_cooldown_timer <= 0 {
pos := rect_center(e)
dir := linalg.normalize0(rect_center(player) - pos)
def := &gs.enemy_definitions["Orb"]
// Spawn an entity that travels in a straight line at the player
// This position is roughly the head of the boss
// The target is the centre of the player, no prediction
pos -= Vec2{def.collider_size.x / 2, e.collider.height / 2}
entity_create(
Entity {
collider = {pos.x, pos.y, def.collider_size.x, def.collider_size.y},
behaviors = def.behaviors,
definition = def,
vel = dir * 350,
flags = {.Debug_Draw, .Immortal, .Flying},
debug_color = {255, 0, 0, 255},
on_hit_damage = def.on_hit_damage,
health = def.health,
max_health = def.health,
on_hit_static = fireball_on_hit_static,
animations = def.animations,
current_anim_name = def.initial_animation,
texture = &def.texture,
},
)
state.breath_cooldown_timer = BREATH_COOLDOWN
}
if state.breath_duration_timer <= 0 {
state.current = .Swooping
state.breath_cooldown_timer = 0
}
case .Short_Range_Breath:
// Check `demon_boss_breath_cb` proc below
case .Hovering:
state.hovering_timer += dt
if state.hovering_timer >= HOVER_DURATION {
state.current = .Retreating
}
case .Retreating:
dir := Vec2{-0.5, -0.5}
if rect_center(player).x < rect_center(e).x {
dir.x = 0.5
}
if e.y > gs.level.level_min.y + 64 {
e.vel = dir * 250
} else {
state.current = .Searching
}
case .Swooping:
// Add lift to get a more interesting arc
forward_force :: 250
lift :: 100
player_pos := rect_center(player)
self_pos := rect_center(e)
v := rect_center(player) - rect_center(e)
// Use perpendicular instead of UP
perp := linalg.normalize0(Vec2{-v.y, v.x})
acc := linalg.normalize0(v) * forward_force + perp * lift
e.vel += acc * dt
bottom_pos := e.y + e.height
threshold := gs.level.level_max.y - 32
// Attack only when both near the ground and player
if linalg.distance(player_pos, self_pos) < 100 && bottom_pos >= threshold {
state.current = .Short_Range_Breath
switch_animation(e, "attack")
e.vel = 0
}
// Keep from clipping into the ground
if bottom_pos >= threshold {
e.vel.y = 0
}
}
}
@(private = "file")
fireball_on_hit_static :: proc(e: ^Entity, gs: ^Game_State, pos: Vec2, dt: f32) {
e.flags += {.Dead}
e.vel = 0
}
demon_boss_on_finish_attack :: proc(gs: ^Game_State, e: ^Entity) {
state.current = .Hovering
state.hovering_timer = 0
switch_animation(e, "fly")
}
// Create circular damage area around boss
// Damage player if inside of it
demon_boss_breath_cb :: proc(gs: ^Game_State, e: ^Entity) {
player := entity_get(gs.player_id)
player_center := rect_center(player)
pos := rect_center(e)
dir := linalg.normalize0(player_center - pos)
pos += Vec2{dir.x * 35, 0}
radius := f32(65)
debug_draw_circle(pos, radius, rl.RED)
if rl.CheckCollisionCircleRec(pos, radius, player) {
entity_damage(gs.player_id, 1)
}
}
Alright, that was a big one. I hope you found it useful to see how sometimes breaking our patterns can be useful.