PVG
47. Entity Homogenisation and Placement — Program Video Games

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

  1. Move editor_init() to the end of game_init() instead of calling it in main(). This ensures all game resources are loaded before the editor initialises.

  2. The player is now just another entity type, which simplifies spawning and management.

  3. All textures are now referenced by index rather than pointer.

  4. Entity definitions now include all fields needed for any entity type (player or enemy).

  5. The entity tool in the editor allows cycling through available entity types with the E key and placing them with left-click.