48. Entity Spawners
In this lesson, we'll implement entity spawners that can be placed and removed in the level editor. This involves updating our file format to support arbitrary entity types and their spawn positions.
File Format Changes
Let's start by understanding the new file format structure. I've added detailed documentation in encoding.odin:
/*
+---------------------------------------+
| World_Data_Header |
|---------------------------------------|
| magic (0x57475650) 4 bytes |
| version_major 4 bytes |
| version_minor 4 bytes |
| version_patch 4 bytes |
| level_count 4 bytes |
| tileset_count 4 bytes |
+---------------------------------------+
| Entity Table |
|---------------------------------------|
| magic (0xEFBEEFBE) 4 bytes |
| entity_count 4 bytes |
+---------------------------------------+
| Name Length 1 4 bytes |
| Name Length 2 4 bytes |
| ... |
| Name Length N 4 bytes |
+---------------------------------------+
| Entity Name 1 variable len |
| Entity Name 2 variable len |
| ... |
| Entity Name N variable len |
+---------------------------------------+
| Level 1 |
|---------------------------------------|
| magic (0xBEBAFECA) 4 bytes |
| id 4 bytes |
| x 4 bytes |
| y 4 bytes |
| width 4 bytes |
| height 4 bytes |
+---------------------------------------+
| Tile Data |
| (width * height bytes) 1 byte |
| per tile |
+---------------------------------------+
| Spawners (Optional) |
|---------------------------------------|
| magic (0xEFBEDEC0) 4 bytes |
| spawner_count 4 bytes |
+---------------------------------------+
| Entity Index 1 4 bytes |
| X 1 4 bytes |
| Y 1 4 bytes |
+---------------------------------------+
| Entity Index 2 4 bytes |
| X 2 4 bytes |
| Y 2 4 bytes |
+---------------------------------------+
| ... |
+---------------------------------------+
| Level 2 |
| ... |
+---------------------------------------+
*/
The key additions are:
Entity Table: Lists all entity types available in the game
Spawners Section: Optional per-level section containing entity spawn positions
New Constants and Types
In encoding.odin, add the new magic numbers and tile value enum:
ENTITY_TABLE_MAGIC :: 0xEFBEEFBE
SPAWNERS_MAGIC :: 0xEFBEDEC0
Tile_Value :: enum u8 {
None,
Solid,
Spike_N,
Spike_E,
Spike_S,
Spike_W,
}
Also add the necessary imports:
import "core:slice"
import "core:strings"
Update Level Structure
In main.odin, replace enemy_spawns with a more generic spawners field:
Level :: struct {
// ... other fields ...
spawners: [dynamic]Spawner,
// ... other fields ...
}
Spawner :: struct {
type: string,
pos: Vec2,
}
Editor Commands
In editor.odin, add new command types for entity operations:
Cmd :: union {
// ... existing commands ...
Cmd_Entity_Insert,
Cmd_Entity_Remove,
}
Cmd_Entity_Insert :: struct {
coords: Vec2i,
type: string,
}
Cmd_Entity_Remove :: struct {
coords: Vec2i,
type: string,
}
Implement Command Execution
In editor_command_execute, add cases for the new commands:
case Cmd_Entity_Insert:
pos := pos_from_coords(v.coords)
found := false
for spawner in gs.level.spawners {
if spawner.pos == pos {
found = true
break
}
}
if !found {
append(&gs.level.spawners, Spawner {pos = pos, type = v.type})
}
case Cmd_Entity_Remove:
pos := pos_from_coords(v.coords)
for spawner, i in gs.level.spawners {
if spawner.pos == pos {
unordered_remove(&gs.level.spawners, i)
break
}
}
Update Spike Placement
Spikes are now placed individually rather than as joined areas. In editor_command_construct, update the spike placement logic:
area_coords := coords_from_area(es.area_begin, es.area_end)
spikes := make([]Spike, len(area_coords))
for coords, i in area_coords {
pos := pos_from_coords(coords)
spike := Spike {
collider = {pos.x, pos.y, TILE_SIZE, TILE_SIZE},
facing = es.spike_orientation,
}
switch spike.facing {
case .Up:
spike.collider.y += SPIKE_DIFF
spike.collider.height = SPIKE_DEPTH
case .Right:
spike.collider.width = SPIKE_DEPTH
case .Down:
spike.collider.height = SPIKE_DEPTH
case .Left:
spike.collider.width = SPIKE_DEPTH
spike.collider.x += SPIKE_DIFF
}
spikes[i] = spike
}
Entity Insert/Remove Commands
Add construction for entity commands in editor_command_construct:
case Cmd_Entity_Insert:
mouse_pos := rl.GetMousePosition()
world_pos := rl.GetScreenToWorld2D(mouse_pos, gs.camera)
coords := coords_from_pos(world_pos)
if is_tile_at(coords, gs.level) || is_spawner_at(coords, gs.level) {
return {}, {}
}
forward := Cmd_Entity_Insert {
coords = coords,
type = es.entity_type_key,
}
inverse := Cmd_Entity_Remove {
coords = coords,
type = es.entity_type_key,
}
return {forward, inverse}, true
case Cmd_Entity_Remove:
mouse_pos := rl.GetMousePosition()
world_pos := rl.GetScreenToWorld2D(mouse_pos, gs.camera)
coords := coords_from_pos(world_pos)
if !is_spawner_at(coords, gs.level) {
return {}, {}
}
forward := Cmd_Entity_Remove {
coords = coords,
type = es.entity_type_key,
}
inverse := Cmd_Entity_Insert {
coords = coords,
type = es.entity_type_key,
}
return {forward, inverse}, true
Helper Function
Add the is_spawner_at helper function:
is_spawner_at :: proc(coords: Vec2i, l: ^Level = gs.level) -> bool {
for spawner in l.spawners {
spawner_coords := coords_from_pos(spawner.pos)
if spawner_coords == coords {
return true
}
}
return false
}
Update Editor Input
In editor_update, update the entity tool handling to use the command system:
if rl.IsMouseButtonPressed(.LEFT) {
editor_command_dispatch(Cmd_Entity_Insert)
}
if rl.IsMouseButtonPressed(.RIGHT) {
editor_command_dispatch(Cmd_Entity_Remove)
}
Save File Format
Update world_data_save to write the entity table and spawners:
// Create Entity Table
bytes.buffer_write(&b, mem.any_to_bytes(u32(ENTITY_TABLE_MAGIC)))
entity_count := len(gs.entity_definitions)
bytes.buffer_write(&b, mem.any_to_bytes(u32(entity_count)))
// Name lengths
for k in gs.entity_definitions {
bytes.buffer_write(&b, mem.any_to_bytes(u32(len(k))))
}
entity_names := make([dynamic]string, context.temp_allocator)
for k in gs.entity_definitions {
bytes.buffer_write_string(&b, k)
append(&entity_names, k)
}
Update tile saving to use the new Tile_Value enum:
tiles := make([]Tile_Value, level_header.width * level_header.height, context.temp_allocator)
for tile in level.tiles {
coords := coords_from_pos(tile.pos - level.pos)
tiles[u32(coords.y) * level_header.width + u32(coords.x)] = .Solid
}
for spike in level.spikes {
coords := coords_from_pos({spike.collider.x, spike.collider.y})
spike_value := Tile_Value.Spike_N
if spike.facing == .Right do spike_value = .Spike_E
if spike.facing == .Down do spike_value = .Spike_S
if spike.facing == .Left do spike_value = .Spike_W
tiles[u32(coords.y) * level_header.width + u32(coords.x)] = spike_value
}
bytes.buffer_write(&b, transmute([]u8)tiles)
Save spawners after tile data:
if len(level.spawners) > 0 {
bytes.buffer_write(&b, mem.any_to_bytes(u32(SPAWNERS_MAGIC)))
bytes.buffer_write(&b, mem.any_to_bytes(u32(len(level.spawners))))
for spawner in level.spawners {
coords := coords_from_pos(spawner.pos)
index, ok := slice.linear_search(entity_names[:], spawner.type)
if ok {
bytes.buffer_write(&b, mem.any_to_bytes(u32(index)))
bytes.buffer_write(&b, mem.any_to_bytes(i32(coords.x)))
bytes.buffer_write(&b, mem.any_to_bytes(i32(coords.y)))
}
}
}
Load File Format
Update world_data_load to read the entity table:
// Construct Entity Table
entity_table_magic: u32
bytes.reader_read(&r, mem.any_to_bytes(entity_table_magic))
entity_count: u32
bytes.reader_read(&r, mem.any_to_bytes(entity_count))
name_lengths := make([dynamic]u32, context.temp_allocator)
names := make([dynamic]string, context.temp_allocator)
for _ in 0..< entity_count {
name_length: u32
bytes.reader_read(&r, mem.any_to_bytes(u32(name_length)))
append(&name_lengths, name_length)
}
for i in 0..< entity_count {
name := make([]u8, name_lengths[i], context.temp_allocator)
bytes.reader_read(&r, name)
append(&names, string(name))
}
Update tile loading to handle the new format:
for y in 0 ..< level_header.height {
for x in 0 ..< level_header.width {
tile_byte: u8
bytes.reader_read(&r, mem.any_to_bytes(tile_byte))
tile_value := transmute(Tile_Value)tile_byte
pos := level.pos + pos_from_coords({i32(x), i32(y)})
switch tile_value {
case .None:
case .Solid:
append(&level.tiles, Tile {pos = pos})
case .Spike_N:
append(&level.spikes, Spike {collider = {pos.x, pos.y + SPIKE_DIFF, SPIKE_BREADTH, SPIKE_DEPTH}, facing = .Up})
case .Spike_E:
append(&level.spikes, Spike {collider = {pos.x, pos.y, SPIKE_DEPTH, SPIKE_BREADTH}, facing = .Right})
case .Spike_S:
append(&level.spikes, Spike {collider = {pos.x, pos.y, SPIKE_BREADTH, SPIKE_DEPTH}, facing = .Down})
case .Spike_W:
append(&level.spikes, Spike {collider = {pos.x + SPIKE_DIFF, pos.y, SPIKE_DEPTH, SPIKE_BREADTH}, facing = .Left})
}
}
}
Load spawners if present:
spawners_magic: u32
bytes.reader_read(&r, mem.any_to_bytes(spawners_magic))
if spawners_magic == SPAWNERS_MAGIC {
spawner_count: u32
bytes.reader_read(&r, mem.any_to_bytes(spawner_count))
for _ in 0..< spawner_count {
index: u32
x, y: i32
bytes.reader_read(&r, mem.any_to_bytes(index))
bytes.reader_read(&r, mem.any_to_bytes(x))
bytes.reader_read(&r, mem.any_to_bytes(y))
append(&level.spawners, Spawner {
pos = Vec2{f32(x), f32(y)} * TILE_SIZE,
type = strings.clone(names[index])
})
}
} else {
bytes.reader_unread_byte(&r)
bytes.reader_unread_byte(&r)
bytes.reader_unread_byte(&r)
bytes.reader_unread_byte(&r)
}
Update Level Loading
In level_load, spawn entities from the spawners:
for spawner in level.spawners {
entity_spawn(spawner.type, spawner.pos)
}
Visual Feedback
In game_update, draw spawners as orange squares during gameplay (for debugging):
for spawner in gs.level.spawners {
rl.DrawRectangleLinesEx({spawner.pos.x, spawner.pos.y, 16, 16}, 1, rl.ORANGE)
}
Summary
We've successfully implemented a flexible entity spawner system that:
- Stores entity type names in a global entity table
- Allows placing and removing spawners in the editor
- Saves spawner data as an optional section in level files
- Automatically spawns entities when levels are loaded
- Provides visual feedback for spawner locations