25. Ability Gating Dashing
[[programvideogames]]In this lesson we'll implement an air-dash mechanic for the player.
The air-dash is locked behind a power-up - in our case a simple pick-up item.
This will allow them to dash across wider gaps and up further.
Using this mechanic, we can lock of part of the game until the dash is available.
I've extended the world in LDtk to accommodate this.
First, we'll create our variables on Game_State:
Game_State :: struct {
// ... other fields ...
power_ups: [dynamic]Power_Up,
collected_power_ups: bit_set[Power_Up_Type],
dash_timer: f32,
dash_cooldown_timer: f32,
}
We need to create a new type to hold the power up "pick-up" in our world.
Power_Up :: struct {
using position: Vec2,
type: Power_Up_Type,
}
Power_Up_Type :: enum {
Dash,
}
We can use the same Power_Up_Type enum to store when we have collected the power up and enable the dash.
We'll add yet another dynamic array into Level.
Level :: struct {
// ... other fields ...
power_ups: [dynamic]Power_Up
}
We'll also add some new constants to store data about Dash:
DASH_DURATION :: 0.3
DASH_COOLDOWN :: 1
DASH_VELOCITY :: 500
In LDtk, create a new Entity type called "Power_Up" and give it an enum value also called Power_Up that holds our Power_Up types - for now it's only Dash.
![[Pasted image 20241024153559.png]]
You'll want to place this somewhere accessible without Dash, and then have an alternative path that's only traversable with the Dash.
To parse our Power_Up entity, we'll add another case to the entity switch statement:
case "Power_Up":
power_up_str := entity.fieldInstances[0].__value
switch power_up_str {
case "Dash":
append(
&l.power_ups,
Power_Up{position = {entity.__worldX, entity.__worldY}, type = .Dash},
)
}
Now, our loading code in level_load is going to be a bit different to the others.
We don't want to reload the power-up if it's already collected.
clear(&gs.power_ups) // clear as usual
// ... append all the stuff ...
// only append if this power up isn't collected by the player
for power_up in level.power_ups {
if power_up.type not_in gs.collected_power_ups {
append(&gs.power_ups, power_up)
}
}
Great, we'll draw the power-up as a gold box for now:
In our drawing code section:
for power_up in gs.power_ups {
rl.DrawRectangleLinesEx({power_up.x, power_up.y, 16, 16}, 1, rl.GOLD)
}
Before we leave main.odin, we'll update the player animations and add dash.
I've added new frames to the player sprite:
player_anim_dash := Animation {
size = {120, 80},
offset = {52, 42},
start = 4,
end = 5,
row = 3,
time = 0.15,
}
gs.player_id = entity_create(
{
// ...
animations = {
// ...
"dash" = player_anim_dash,
},
// ...
},
)
Okay, let's head over to player.odin and start building out the dash functionality.
Add a new movement state to Player_Movement_State:
Player_Movement_State :: enum {
// ...
Dash,
}
We're going to update our input_x to input_dir and use both X and Y inputs.
This allows us to do 8-directional dashing.
input_dir: Vec2
// Yeah, my keyboard is weird. You may want to use WASD here
if rl.IsKeyDown(.T) do input_dir.x += 1 // Right
if rl.IsKeyDown(.R) do input_dir.x -= 1 // Left
if rl.IsKeyDown(.S) do input_dir.y += 1 // Down
if rl.IsKeyDown(.F) do input_dir.y -= 1 // Up
if gs.player_movement_state != .Dash { // Don't set velocity while dashing
player.vel.x = input_dir.x * player.move_speed
}
if gs.dash_cooldown_timer > 0 {
gs.dash_cooldown_timer -= dt
}
Anywhere we are using input_x needs to be changed to input_dir.x.
Now, we're going to add another one of those try_action procs. try_dash.
This time, it has 3 parameters: try_dash(gs, player, input_dir)
I've added this to the end of the Attack_Cooldown, Idle, Run, Jump, and Fall cases.
And here's the new Dash case:
case .Dash:
if gs.dash_timer > 0 {
gs.dash_timer -= dt
if gs.dash_timer <= 0 {
gs.dash_cooldown_timer = DASH_COOLDOWN
gs.player_movement_state = .Fall
switch_animation(player, "fall")
}
}
We need to add our power_ups loop to see when we pick one up. Inside player_update after we check for Door collisions:
for power_up, i in gs.power_ups {
if power_up.type not_in gs.collected_power_ups {
rect := Rect{power_up.x, power_up.y, 16, 16}
if rl.CheckCollisionRecs(rect, player.collider) {
gs.collected_power_ups += {power_up.type}
unordered_remove(&gs.power_ups, i)
break
}
}
}
Speaking of door, the door code was broken:
![[Pasted image 20241024155432.png]]
We were setting the position of the player spawn based on the other door's Y pos - that's why it seemed to jump a bit.
The other thing is, we were adding to both X and Y position instead of just X.
We'll still need to revisit this when doing vertical transitions.
Finally, the new try_dash proc:
try_dash :: proc(gs: ^Game_State, player: ^Entity, input_dir: Vec2) {
if .Dash not_in gs.collected_power_ups do return
if rl.IsMouseButtonPressed(.RIGHT) {
if gs.dash_cooldown_timer <= 0 {
// Handle dash while standing still
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
player.vel = DASH_VELOCITY * linalg.normalize0(input_dir)
}
}
}
Lastly, we have to add some player-specific code to physics.odin.
Code like this is usually a sign we have made an error with our structure.
I don't think Player should be an Entity as there are too many special cases.
However, for now we'll patch it up:
// In physics_update's for loop:
if .Kinematic not_in entity.flags {
for _ in 0 ..< PHYSICS_ITERATIONS {
// The fact we have to write exceptions for the player here
// means the player may have been better off being it's own
// type. i.e. not an Entity
is_player := entity_id == gs.player_id
player_is_dashing := gs.player_movement_state == .Dash
step := dt / PHYSICS_ITERATIONS
if !(is_player && player_is_dashing) {
entity.vel.y += GRAVITY
if entity.vel.y > TERMINAL_VELOCITY {
entity.vel.y = TERMINAL_VELOCITY
}
}
// ...
And, another special case inside resolve_entity_vs_static_y:
resolve_entity_vs_static_y :: proc(entity: ^Entity, static: Rect) {
if entity.vel.y > 0 {
entity.y = static.y - entity.height
entity.flags += {.Grounded}
} else {
entity.y = static.y + static.height
// Yet another case of special handling here
if entity == entity_get(gs.player_id) {
// Cancel the jump timer so we don't "bounce" in a 3-tall hallway
gs.jump_timer = 0
}
}
entity.vel.y = 0
}
We use this special case to fix the bouncing bug when inside a hallway.
I considered passing collision data out for entities so they could do an event callback.
For example: Have an array that stores the entity id and which direction it just collided with - that way you can iterate over the array separately and do whatever.
For now, though, that's all for this lesson.