6. Spikes Collision Events Entity Ids
[[programvideogames]]Now that we've got an entity system and a simple player controller, we can start implementing interactions.
One of the staples of the platformer is an instant-death mechanic like spikes.
Usually what I'd do is create a Spike type and then add an array of them to Game_State spikes: [dynamic]Spike. Then when loading the level, add instances to the array.
However, I understand that you may want to take this code and create something a bit different with it. So we're going to generalise our code a bit.
I don't want to go overboard with it because that leads to high levels of complexity very quickly.
Instead of creating a separate Spike type, we'll just add a bit more data to our Entity type and use that.
We'll also change our data a bit to be a bit cleaner.
Here's the new Entity type:
Entity :: struct {
using collider: Rect,
vel: Vec2,
move_speed: f32,
jump_force: f32,
// deleted fields:
// is_dead, is_grounded
// new fields:
on_enter, on_stay, on_exit: proc(self_id, other_id: Entity_Id),
entity_ids: map[Entity_Id]time.Time,
flags: bit_set[Entity_Flags],
debug_color: rl.Color,
}
I've grouped the new fields at the bottom.
Let's go through them.
on_enter, on_stay, on_exit: These are procedure pointers that we'll call when entities overlap each other, or stop overlapping (exit).
entity_ids: A map containing a list of entities that are currently colliding with this entity and when they started colliding.
flags: A bit_set which is a bunch of bool values all packed into one set. The cool thing about these is that we can query the set with operators. E.g.: .Dead is_in entity.flags to check if the entity is dead. entity.flags += {.Dead} to mark the entity as dead.
debug_color: A colour to draw a rectangle (the collider) when the flag .Debug_Draw is set in .flags.
What is an Entity_Id?
I've created a distinct type as such:
Entity_Id :: distinct int
Now we can't accidentally pass an unrelated int into entity_get and get the wrong entity.
We'll go over the changes to entity.odin below.
For now, let's continue with our Entity changes.
Since we have this Entity_Flags type in the bit_set, let's check it out:
Entity_Flags :: enum {
Grounded,
Dead,
Kinematic,
Debug_Draw,
}
In here we can put values that we may consider storing as a bool.
Grounded, Dead: Same as before.
Kinematic: A term that may not be 100% applicable, but I am using it to mean "unaffected by forces". We'll use that for our spikes so they don't fall down.
Debug_Draw: If this is set, then we draw the collider using the debug_color field.
Let's go over the entity.odin changes now:
package main
entity_create :: proc(entity: Entity) -> Entity_Id {
for &e, i in gs.entities {
if .Dead in e.flags {
e = entity
e.flags -= {.Dead}
return Entity_Id(i)
}
}
index := len(gs.entities)
append(&gs.entities, entity)
return Entity_Id(index)
}
entity_get :: proc(id: Entity_Id) -> ^Entity {
if int(id) >= len(gs.entities) {
return nil
}
return &gs.entities[int(id)]
}
We've changed int to Entity_Id and added an out of bounds check in entity_get.
Note that we must cast between int and Entity_Id by wrapping the values.
If we try to pass an int to entity_get the compiler will throw an error as int != Entity_Id.
Of course the compiler will give errors everywhere that we've used int for our entity IDs.
In main.odin: player_id: int -> player_id: Entity_Id
Here's the entire new physics.odin file:
package main
import "core:time"
import rl "vendor:raylib"
physics_update :: proc(entities: []Entity, static_colliders: []Rect, dt: f32) {
for &entity, e_id in entities {
entity_id := Entity_Id(e_id)
if .Dead in entity.flags do continue
if .Kinematic not_in entity.flags {
for _ in 0 ..< PHYSICS_ITERATIONS {
step := dt / PHYSICS_ITERATIONS
entity.vel.y += GRAVITY
if entity.vel.y > TERMINAL_VELOCITY {
entity.vel.y = TERMINAL_VELOCITY
}
// Y axis
entity.y += entity.vel.y * step
entity.flags -= {.Grounded}
for static in static_colliders {
if rl.CheckCollisionRecs(entity.collider, static) {
if entity.vel.y > 0 {
entity.y = static.y - entity.height
entity.flags += {.Grounded}
} else {
entity.y = static.y + static.height
}
entity.vel.y = 0
break
}
}
// X axis
entity.x += entity.vel.x * step
for static in static_colliders {
if rl.CheckCollisionRecs(entity.collider, static) {
if entity.vel.x > 0 {
entity.x = static.x - entity.width
} else {
entity.x = static.x + static.width
}
entity.vel.x = 0
break
}
}
}
}
// Collisions
for &other, o_id in entities {
other_id := Entity_Id(o_id)
if entity_id == other_id do continue
if rl.CheckCollisionRecs(entity, other.collider) {
if entity_id not_in other.entity_ids {
other.entity_ids[entity_id] = time.now()
if other.on_enter != nil {
other.on_enter(other_id, entity_id)
}
} else {
if other.on_stay != nil {
other.on_stay(other_id, entity_id)
}
}
} else if entity_id in other.entity_ids {
if other.on_exit != nil {
other.on_exit(other_id, entity_id)
}
delete_key(&other.entity_ids, entity_id)
}
}
}
}
I've removed a lot of the comments as they were more for explanation.
Let's go over the changes.
for &entity, e_id in entities {
entity_id := Entity_Id(e_id)
if .Dead in entity.flags do continue
At the start of our loop, we create an entity_id which we'll reference later.
Our entity.is_dead check changes to use the correct syntax for a bit_set.
We wrap our entire loop in this check: if .Kinematic not_in entity.flags {.
This prevents the entity from moving, even if the velocity is set.
If we need entities that aren't effected by gravity but do move, then we'll have to add another flag and wrap the gravity application lines.
entity.flags -= {.Grounded} and entity.flags += {.Grounded} replace the bool.
Now for the collisions section.
We iterate through each entity, if it's the same one we are currently looking at, we skip that. Otherwise entities will be colliding with themselves all the time.
if entity_id not_in other.entity_ids: This tells us whether the entities are already colliding. How do we know? Because we're about to add them to a map if they collide.
other.entity_ids[entity_id] = time.now(): I've opted to add a Time instance here as it may prove useful. I'm not 100% sure - we could just use a bool.
Note that we could have written this either way:
if other_id not_in entity.entity_idsandentity.entity_ids[other_id] = time.now()
The important thing is that we set the value. We are iterating through all entities anyway.
Then we get to this part:
if other.on_enter != nil {
other.on_enter(other_id, entity_id)
}
If we don't set any callback proc on our entity, then we want to check for nil.
Otherwise we'll get a program crash as it tries to call a nil proc.
Now that we can handle events and have them trigger on collisions - how can we implement spikes?
Game_State :: struct {
camera: rl.Camera2D,
entities: [dynamic]Entity,
solid_tiles: [dynamic]Rect,
spikes: map[Entity_Id]Direction,
}
Direction :: enum {
Up,
Right,
Down,
Left,
}
spikes: This is a simple way to store which direction the spikes are facing.
Now, we can create our on_enter proc for spikes:
spike_on_enter :: proc(self_id, other_id: Entity_Id) {
self := entity_get(self_id)
assert(self != nil)
other := entity_get(other_id)
dir := gs.spikes[self_id]
switch dir {
case .Up:
if other.vel.y > 0 {
fmt.println("spikes face Up")
}
case .Right:
if other.vel.x < 0 {
fmt.println("spikes face Right")
}
case .Down:
if other.vel.y < 0 {
fmt.println("spikes face Down")
}
case .Left:
if other.vel.x > 0 {
fmt.println("spikes face Left")
}
}
}
self_id Refers to the spike's Entity_Id and other_id can be any entity.
For now we'll just print a value if the direction of the entering entity is oncoming.
We could even do some special collision resolution here if the other entity is entering the spikes from the side. For now, the level design is as such that it's not possible to do so.
Speaking of the level design, we need to add spikes:
########################################
# #
# #
# #
# #
# #
# #
# ## #
# <# #
# <# #
# <# #
# P <# # ## #
# <# # #
# ########### # #
# # #
# # #
# ######### #
## #vvv# #
#> #
#> ### #
#> ### #
###########^^^^^^#######################
########################################
Here we are using >, <, v, and ^ for our spikes. Keeping it simple!
I like the spikes in games to be a little shorter than the tile size.
To that end, I've added some constants:
SPIKE_BREADTH :: 16
SPIKE_DEPTH :: 12
SPIKE_DIFF :: TILE_SIZE - SPIKE_DEPTH
To handle spike entities we'll have to edit our loading code:
for v in level_data {
switch v {
case '
':
y += TILE_SIZE
x = 0
continue
case '#':
append(&gs.solid_tiles, Rect{x, y, TILE_SIZE, TILE_SIZE})
case 'P':
player_id = entity_create(
{x = x, y = y, width = 16, height = 38, move_speed = 280, jump_force = 650},
)
case '^':
id := entity_create(
Entity {
collider = Rect{x, y + SPIKE_DIFF, SPIKE_BREADTH, SPIKE_DEPTH},
on_enter = spike_on_enter,
flags = {.Kinematic, .Debug_Draw},
debug_color = rl.YELLOW,
},
)
gs.spikes[id] = .Up
case 'v':
id := entity_create(
Entity {
collider = Rect{x, y, SPIKE_BREADTH, SPIKE_DEPTH},
on_enter = spike_on_enter,
flags = {.Kinematic, .Debug_Draw},
debug_color = rl.YELLOW,
},
)
gs.spikes[id] = .Down
case '>':
id := entity_create(
Entity {
collider = Rect{x, y, SPIKE_DEPTH, SPIKE_BREADTH},
on_enter = spike_on_enter,
flags = {.Kinematic, .Debug_Draw},
debug_color = rl.YELLOW,
},
)
gs.spikes[id] = .Right
case '<':
id := entity_create(
Entity {
collider = Rect{x + SPIKE_DIFF, y, SPIKE_DEPTH, SPIKE_BREADTH},
on_enter = spike_on_enter,
flags = {.Kinematic, .Debug_Draw},
debug_color = rl.YELLOW,
},
)
gs.spikes[id] = .Left
}
x += TILE_SIZE
}
I hope that with the explanation of everything before this code is reasonable clear.
I've switched ;) the if ... to a switch as it's a better fit for this pattern now that we have so many cases ;).
We create entities that are our spikes, then we add to the gs.spikes map which direction the spikes face.
Finally, just below the tile drawing section, we will draw our entities if the Debug_Draw flag is set:
for e in gs.entities {
if .Debug_Draw in e.flags {
rl.DrawRectangleLinesEx(e.collider, 1, e.debug_color)
}
}
Check:
is_dead, is_grounded->{.Dead} in entity.flags, {.Grounded} in entity.flagsint->Entity_Id
That's it for this one. We've now got a pretty simple but useful entity event system!
Here's the entire project so far:
(link)