47. Entity Homogenisation and Placement
In this lesson, we'll refactor our entity system to homogenise players and enemies into a unified entity system, and add the ability to place entities in the editor.
Overview of Changes
Previously, we had separate systems for players and enemies. Now we're unifying them into a single entity system where the player is just another type of entity. This simplifies our codebase and makes it easier to spawn any type of entity consistently.
Utility Function for Map Navigation
First, let's add a helpful utility function in util.odin that allows us to cycle through map keys:
map_next_key :: proc(m: ^map[$T]$V, current_key: Maybe(T) = nil) -> T {
result: T
first := true
return_next_key := current_key == nil
for k in m {
if first {
result = k
first = false
}
if return_next_key {
result = k
break
}
if k == current_key do return_next_key = true
}
return result
}
This function uses generics (denoted by $T and $V) to work with any map type. It returns the next key in the map after the current one, or the first key if no current key is provided. This will be useful for cycling through available entity types in the editor.
Texture System Refactoring
We're changing how textures are stored. Instead of using rl.Texture2D pointers throughout the code, we'll store all textures in a single array and reference them by index:
In main.odin, update the Game_State struct:
Game_State :: struct {
// ...
textures: [dynamic]rl.Texture,
player_texture: int,
tileset_texture: int,
// ...
entity_definitions: map[string]Entity_Definition, // renamed from enemy_definitions
// ...
item_texture: int,
demon_boss_attack_texture: int,
// ...
}
Add a texture loading helper function:
texture_load :: proc(path: string) -> int {
texture := rl.LoadTexture(fmt.ctprintf(path))
append(&gs.textures, texture)
return len(gs.textures) - 1
}
In the main procedure, initialise the texture array with an empty texture at index 0:
// First texture slot is not available so we can easily assume all code paths work
// And test against zero index instead of some nil
append(&gs.textures, rl.Texture {})
This allows us to use texture > 0 checks instead of texture != nil.
Entity Definition Refactoring
Rename Enemy_Def to Entity_Definition and update its fields:
Entity_Definition :: struct {
collider_size: Vec2,
move_speed: f32,
behaviors: bit_set[Entity_Behaviors],
debug_color: Maybe(rl.Color),
health: int,
on_hit_damage: int,
texture: int, // Changed from rl.Texture2D
animations: map[string]Animation,
initial_animation: string,
hit_response: Entity_Hit_Response,
hit_duration: f32,
hit_knockback_force: f32,
jump_force: f32, // Added field
drops: [dynamic]Drop,
flags: bit_set[Entity_Flags],
on_update: proc(_: ^Entity, _: ^Game_State, dt: f32),
on_enter: proc(self_id, other_id: Entity_Id), // Added field
on_death: proc(_: ^Entity, _: ^Game_State), // Added field
}
Update the Entity struct to use integer texture references:
Entity :: struct {
// ...
texture: int, // Changed from ^rl.Texture
definition: ^Entity_Definition, // Changed from ^Enemy_Def
// ...
}
Also update the Animation struct:
Animation :: struct {
// ...
texture: int, // Changed from ^rl.Texture2D
// ...
}
Entity Spawning System
Add a new entity_spawn function in entity.odin:
entity_spawn :: proc(type: string, pos: Vec2) -> Entity_Id {
def := &gs.entity_definitions[type]
e := Entity {
collider = {
x = pos.x,
y = pos.y,
width = def.collider_size.x,
height = def.collider_size.y,
},
move_speed = def.move_speed,
jump_force = def.jump_force,
behaviors = def.behaviors,
health = def.health,
max_health = def.health,
debug_color = def.debug_color == nil ? rl.YELLOW : def.debug_color.?,
definition = def,
current_anim_name = def.initial_animation,
texture = def.texture,
hit_response = def.hit_response,
hit_duration = def.hit_duration,
flags = def.flags,
on_hit_damage = def.on_hit_damage,
on_enter = def.on_enter,
on_update = def.on_update,
on_death = def.on_death,
}
for k, v in def.animations {
e.animations[k] = v
}
id := entity_create(e)
return id
}
Player as an Entity
Move the player definition to game_init and create it as an entity definition:
player_def := Entity_Definition {
collider_size = {14, 38},
move_speed = 220,
jump_force = 650,
health = 7,
debug_color = rl.GREEN,
texture = gs.player_texture,
initial_animation = "idle",
on_enter = player_on_enter,
on_death = player_on_death,
}
// Add all the player animations to player_def.animations
// (Same animations as before, just attached to the definition)
gs.entity_definitions["Player"] = player_def
Simplify spawn_player:
spawn_player :: proc(gs: ^Game_State, player_spawn: Vec2) {
gs.player_id = entity_spawn("Player", player_spawn)
// FIXME: Delete this
gs.save_data.collected_power_ups += {.Dash}
}
Editor Entity Tool
Add a new tool to the editor in editor.odin:
Editor_Tool :: enum {
// ...
Entity,
}
Add to Editor_State:
Editor_State :: struct {
// ...
entity_type_key: string,
}
Update the tileset texture field:
Tileset :: struct {
texture: int, // Changed from rl.Texture2D
// ...
}
In editor_init, initialise the entity selection:
es.entity_type_key = map_next_key(&gs.entity_definitions, nil)
Add the entity tool handling in editor_update:
if rl.IsKeyPressed(.E) {
es.tool = .Entity
}
// In the tool switch:
case .Entity:
if rl.IsKeyPressed(.E) {
es.entity_type_key = map_next_key(&gs.entity_definitions, es.entity_type_key)
}
if rl.IsMouseButtonPressed(.LEFT) {
entity_spawn(es.entity_type_key, rl.GetScreenToWorld2D(mouse_pos, gs.camera))
}
Add display text in the editor panel:
editor_panel_text(fmt.ctprintf("Entity: %s", es.entity_type_key))
Update All Entity Definitions
Update all entity definitions to use the new system. Replace all rl.LoadTexture calls with texture_load:
gs.entity_definitions["Walker"] = Entity_Definition {
// ...
texture = texture_load("assets/textures/opossum_36x28.png"),
// ...
}
Do this for all entities: Walker, Jumper, Charger, Demon_Boss, and Orb.
Rendering Updates
Update all rendering code to use the texture array. For example:
// Instead of:
rl.DrawTextureRec(texture^, source, pos, tint)
// Use:
rl.DrawTextureRec(gs.textures[texture_index], source, pos, tint)
In the entity rendering code:
if e.texture > 0 {
anim := e.definition.animations[e.current_anim_name]
// ... calculate source ...
texture_index := anim.texture == 0 ? e.texture : anim.texture
tint := anim.tint == 0 ? rl.WHITE : anim.tint
rl.DrawTextureRec(gs.textures[texture_index], source, {e.x, e.y} - offset, tint)
}
Demon Boss Updates
In demon_boss.odin, update the orb spawning to use the new system:
def := &gs.entity_definitions["Orb"] // Changed from enemy_definitions
When creating the fireball entity, update the texture field:
texture = def.texture, // Changed from &def.texture
Remove the manual animation copying since entity_spawn handles this now.
Important Notes
Move
editor_init()to the end ofgame_init()instead of calling it inmain(). This ensures all game resources are loaded before the editor initialises.The player is now just another entity type, which simplifies spawning and management.
All textures are now referenced by index rather than pointer.
Entity definitions now include all fields needed for any entity type (player or enemy).
The entity tool in the editor allows cycling through available entity types with the E key and placing them with left-click.