37. Data Layout Foundations
[[programvideogames]]Let's get the Game_State changes out of the way first, then we'll deal with all the cascading changes of that, then we'll add new features.
Game_State :: struct {
level_definitions: map[string]Level, // Changed to
levels: [dynamic]Level,
checkpoint_level_iid: string, // Changed to
checkpoint_level_id: u32,
checkpoint_iid: string, // Changed to
checkpoint_id: u32,
// Delete the following fields
bg_tiles
tiles
spikes
doors
checkpoints
power_ups
collected_power_ups
// ... all other fields remain the same ...
}
We're no longer using the string ids, we'll use simple u32s that begin from 1 - but more on that later.
We remove all data that is "static" level data OR save-file data.
Speaking of save-file data, we will update the Save_Data struct:
Save_Data :: struct {
level_iid: string, // Changed to
level_id: u32,
checkpoint_iid: string, // Changed to
checkpoint_id: u32,
visited_level_ids: [dynamic]string, // Changed to
visited_level_ids: [dynamic]u32,
}
And here is the entire Level struct, re-imagined:
Level :: struct {
id: u32,
pos: Vec2,
size: Vec2,
player_spawn: Maybe(Vec2),
name: string,
enemy_spawns: [dynamic]Enemy_Spawn,
doors: [dynamic]Door,
spikes: [dynamic]Spike,
falling_log_spawns: [dynamic]Falling_Log_Spawn,
checkpoints: [dynamic]Checkpoint,
power_up_spawns: [dynamic]Power_Up_Spawn,
tiles: [dynamic]Tile,
on_enter: proc(gs: ^Game_State),
}
You've probably noticed there are some new types here, so let's create those, as well as modifying existing ones:
Enemy_Type :: enum {
Walker,
Jumper,
Charger,
Orb,
}
Enemy_Spawn :: struct {
type: Enemy_Type,
pos: Vec2,
}
Falling_Log_Spawn :: struct {
rect: Rect,
}
Checkpoint :: struct {
id: u32,
pos: Vec2,
}
Power_Up_Spawn :: struct {
type: Power_Up_Type,
pos: Vec2,
}
Power_Up_Type :: enum {
Dash,
}
Door :: struct {
id: u32,
to_level: u32,
to_id: u32,
rect: Rect,
}
Next up, since we aren't using string identifiers, we'll change this global constant:
FIRST_LEVEL_IID // Changed to
FIRST_LEVEL_ID :: 1
To find levels by the u32 ID field, let's create a simple helper procedure. I put this in main.odin, just above level_load, but feel free to put it anywhere you think makes sense:
level_from_id :: proc(levels: []Levels, id: u32) -> ^Level {
for &l in levels {
if l.id == id {
return &l
}
}
return nil
}
While we're here, let's modify the level_load procedure, but first make sure to import core:log:
import "core:log"
level_load :: proc(gs: ^Game_State, id: u32, player_spawn: Maybe(Vec2) = nil) {
level := level_from_id(gs.levels[:], id)
if level == nil {
log.fatalf("Level with id `%d` could not be found.", id)
}
gs.level = level
// ...
clear(&gs.entities) // This one stays, everything else goes
// Delete the clears, appends, and the power_up loop
spawn_player(gs)
// ... The rest remains the same ...
}
We've changed this to load by ID, as well as adding an optional player_spawn, which we need for compatibility right now.
Most of the data we were copying is no longer required, though we will need to add some code back when we build up the new structure.
Let's add a couple of lines to make use of the player_spawn in spawn_player:
// TODO: What's the point of this being a Maybe?
if _, player_spawn_exists := gs.level.player_spawn.?; !player_spawn_exists {
gs.level.player_spawn = gs.level.pos
}
The reason I've added this TODO here is because what we have been using the player_spawn for is not really what it was originally intended for.
The idea was, if some content got changed or added, we could always spawn the player at the original spawn point.
Later, we used that spawn point to tell the game where the player is appearing when changing levels.
We could just as easily have added this to a field on the Game_State and "thrown it away" after changing levels.
This line here was added to prevent the game from crashing when switching levels in the editor.
That's why the player appears at the top left corner when changing levels in the editor.
We probably don't want this behavior, but at the same time, we have bigger fish to fry.
One of those, is making sure we can create colliders in multiple levels without any issues.
To help keep things sane, I've started removing gs as a parameter, and instead adding the particular fields we need.
With that in mind, let's take a look at recreate_level_colliders which I've renamed to recreate_colliders because the colliders don't exist in the Level anymore.
recreate_colliders :: proc(
world_pos: Vec2,
colliders: ^[dynamic]Rect,
falling_logs: []Falling_Log,
tiles: []Tile,
) {
clear(colliders)
solid_tiles := make([dynamic]Rect, context.temp_allocator)
for t in tiles {
append(&solid_tiles, Rect{t.pos.x, t.pos.y, TILE_SIZE, TILE_SIZE})
}
if len(solid_tiles) > 0 {
combine_colliders(world_pos, solid_tiles[:], &gs.colliders)
}
for falling_log in gs.falling_logs {
if falling_log.state == .Settled {
append(colliders, falling_log.collider)
}
}
}
As you can see, we have more parameters, which makes sense.
We also have the concept of creating a collider when the log falls being handled in this function.
I prefer to have a single code-path that updates the colliders at once - that way if anything funky starts happening with the colliders, we know exactly where to look.
If we update state from anywhere, we have more places to check.
The combine_colliders arguments have changed, too - let's look at the changes there:
// The new signature:
// `colliders` is the array we are appending to
combine_colliders :: proc(world_pos: Vec2, solid_tiles: []Rect, colliders: ^[dynamic]Rect)
The other changes are quite simple:
- Anywhere we have
l.level_min, change toworld_pos - Anywhere we have
append(&l.colliders ...change toappend(colliders ...
The Cascade
These changes are wholly or partly to do with us changing the data structure.
First, let's look at savefile.odin:
save_data_update :: proc(gs: ^Game_State) {
// Delete gs.save_data.collected_power_ups = gs.collected_power_ups
// ...
// iids -> ids
if !slice.contains(gs.save_data.visited_level_ids[:], gs.level.id) {
append(&gs.save_data.visited_level_ids, gs.level.id)
}
}
Now, player.odin:
player_update :: proc(gs: ^Game_State, dt: f32) {
// ...
// for spike in gs.spikes { - Changed to
for spike in gs.level.spikes {
// ...
}
// for door in gs.doors { - Changed to
for door in gs.level.doors {
other_level := level_from_id(gs.levels[:], door.to_level)
// NOTE: We just assume the other level exists.
for other_door in other_level.doors {
if other_door.id == door.to_id {
dir := linalg.normalize0(
Vec2{other_door.rect.x, other_door.rect.y} -
Vec2{door.rect.x, door.rect.y},
)
player_spawn := Vec2{other_door.rect.x, player.y}
if dir.x > 0 {
player_spawn.x += other_door.rect.width
} else if dir.x < 0 {
player_spawn.x -= (other_door.rect.width + player.collider.width)
}
level_load(gs, door.to_level, player_spawn)
break
}
}
}
for power_up in gs.level.power_up_spawns {
if power_up.type not_in gs.save_data.collected_power_ups {
rect := rect_from_pos_size(power_up.pos, 16)
if rl.CheckCollisionRecs(rect, player.collider) {
gs.save_data.collected_power_ups += {power_up.type}
break
}
}
}
// ...
}
try_activate_checkpoint :: proc(gs: ^Game_State, player: ^Entity) {
if rl.IsKeyPressed(.W) {
// gs.checkpoints -> gs.level.checkpoints
for checkpoint in gs.level.checkpoints {
// New utility procedure
rect := rect_from_pos_size(checkpoint.pos, 32)
if rl.CheckCollisionRecs(rect, player.collider) {
// iid -> id
gs.save_data.level_id = gs.level.id
gs.save_data.checkpoint_id = checkpoint.id
save_data_update(gs)
savefile_save(gs.save_data)
}
}
}
}
try_dash :: proc(gs: ^Game_State, player: ^Entity, input_dir: Vec2) {
// collected power ups is stored only in gs.save_data
if .Dash not_in gs.save_data.collected_power_ups do return
// ...
}
player_on_death :: proc(player: ^Entity, gs: ^Game_State) {
// ...
if gs.checkpoint_level_id != 0 && gs.checkpoint_id != 0 {
// If not this level, load the level
if gs.checkpoint_level_id != gs.level.id {
level_load(gs, gs.checkpoint_level_id)
}
// NOTE: Player pointer invalidated now
for checkpoint in gs.level.checkpoints {
if checkpoint.id == gs.checkpoint_id {
spawn_point.x = checkpoint.pos.x
spawn_point.y = checkpoint.pos.y
break
}
}
} else {
level_load(gs, FIRST_LEVEL_ID)
}
// ...
}
Let's quickly create that utility procedure rect_from_pos_size in util.odin:
rect_from_pos_size :: #force_inline proc(pos, size: Vec2) -> Rect {
return Rect{pos.x, pos.y, size.x, size.y}
}
I noticed we needed to create Rect from two Vec2s quite often, so created this to make it a bit easier to type.
The
#force_inlinedirective does what it says: forces the compiler to inline the procedure. Unlike C and C++ where inline is a suggestion.
Let's open up behavior.odin and fix what seems like the most trivial of changes: Level's level_min and level_max fields being changed to pos and size.
I justify this by the fact that Raylib and therefore our code-bases uses x, y, width, height and not x1, y1, x2, y2 for rectangles. The level_min and level_max were vestiges left over from LDtk.
In behavior.odin:
level.level_min->level.poslevel.level_max.x->level.pos.x + level.size.x
In demon_boss.odin, we do the same thing, but also:
level.level_max.y->level.pos.y + level.size.y
Alright, let's update the editor now - editor.odin:
// We need the strings package
import "core:strings"
editor_update :: proc(gs: ^Game_State) {
// ...
switch es.tool {
case .None:
case .Tile:
// ...
if place || remove {
// ...
for y in 0 ..= area.y {
for x in 0 ..= area.x {
rel_coords := es.area_begin + {i32(x), i32(y)} * sign
rel_coords -= coords_from_pos(gs.level.pos)
if place {
editor_place_tile(rel_coords, gs.level)
} else {
editor_remove_tile(rel_coords, gs.level)
}
}
}
}
case .Spike:
// ...
if rl.IsMouseButtonReleased(.LEFT) {
begin := es.area_begin - coords_from_pos(gs.level.pos)
end := es.area_end - coords_from_pos(gs.level.pos)
rect := rect_from_coords_any_orientation(begin, end)
editor_place_spikes(rect, gs.level)
}
if rl.IsMouseButtonReleased(.RIGHT) {
begin := es.area_begin - coords_from_pos(gs.level.pos)
end := es.area_end - coords_from_pos(gs.level.pos)
rect := rect_from_coords_any_orientation(begin, end)
editor_remove_spikes(rect, gs.level)
}
case .Level:
// ...
if rl.IsMouseButtonReleased(.LEFT) {
rect := rect_from_coords_any_orientation(es.area_begin, es.area_end)
editor_place_level(gs, rect)
}
if rl.IsMouseButtonReleased(.RIGHT) {
rect := rect_from_coords_any_orientation(es.area_begin, es.area_end)
editor_remove_level(gs, rect)
}
for level in gs.levels {
level_rect := rect_from_pos_size(level.pos, level.size)
if rl.CheckCollisionPointRec(rel_pos, level_rect) {
if rl.IsMouseButtonPressed(.LEFT) {
level_load(gs, level.id)
}
}
}
// ...
}
Here we are using relative coordinates, so that when we store tiles and other things, they are relative to the position of the level, not an absolute position.
This will come in handy when we add the functionality to move levels around.
For the .Level case, we use world coordinates as before.
Down in the editor_draw procedure, we have to update the level loop - rather than iterating over the keys of gs.level_definitions which doesn't exist anymore:
for l in gs.levels {
level_rect := Rect{l.pos.x, l.pos.y, l.size.x, l.size.y}
level_rect.x -= gs.camera.target.x
level_rect.y -= gs.camera.target.y
level_rect = rect_scale_all(level_rect, gs.camera.zoom)
color := l.id == gs.level.id ? rl.WHITE : rl.GRAY
thickness := f32(1)
if es.tool == .Level {
thickness = 4
text := l.name == "" ? fmt.ctprintf("level_%d", l.id) : fmt.ctprintf("%s", l.name)
text_size := rl.MeasureTextEx(gs.font_48, text, 48, 0)
text_pos :=
Vec2{level_rect.x, level_rect.y} +
({level_rect.width, level_rect.height} - text_size) / 2
rl.DrawTextEx(gs.font_48, text, text_pos, 48, 0, {255, 255, 255, 128})
}
rl.DrawRectangleLinesEx(level_rect, thickness, color)
}
Since we are using relative coordinates, we need to modify the placement of tiles.
editor_place_tile :: proc(coords: Vec2i, l: ^Level) {
// ...
pos := pos_from_coords(coords) - l.pos
// ...
recreate_colliders(l.pos, &gs.colliders, gs.falling_logs[:], l.tiles[:])
}
editor_remove_tile :: proc(coords: Vec2i, l: ^Level) {
// ...
recreate_colliders(l.pos, &gs.colliders, gs.falling_logs[:], l.tiles[:])
// ...
}
[!warning]
Spikes will be broken temporarily!
There is already a lot going on.
We will fix them in due time.
We need a procedure to place new levels. This one is commented:
editor_place_level :: proc(gs: ^Game_State, rect: Rect) {
// 1. Increase size to minimum level size
rect := rect
rect.width = max(rect.width, math.ceil(f32(RENDER_WIDTH) / TILE_SIZE) * TILE_SIZE)
rect.height = max(rect.height, math.ceil(f32(RENDER_HEIGHT) / TILE_SIZE) * TILE_SIZE)
// 2. Determine whether the level is in a valid spot
// - Overlapping other levels is not valid
is_valid_placement := true
for l in gs.levels {
def_rect := rect_from_pos_size(l.pos, l.size)
if rl.CheckCollisionRecs(rect, def_rect) {
is_valid_placement = false
break
}
}
if is_valid_placement {
level_id := gs.level.id
level: Level
level.id = get_next_level_id(gs.levels[:])
level.name = strings.clone(fmt.tprintf("level_%d", level.id))
level.pos = {rect.x, rect.y}
level.size = {rect.width, rect.height}
append(&gs.levels, level)
gs.level = level_from_id(gs.levels[:], level_id)
}
}
// Helper to make sure we don't clobber level IDs
get_next_level_id :: proc(levels: []Level) -> u32 {
id := u32(0)
for level in levels {
if level.id > id {
id = level.id
}
}
return id + 1
}
We're almost done. We need to update our encoding.odin file to match the new structure:
World_Data_Level_Header :: struct {
magic: u32,
id: u32,
x: u32, // New
y: u32, // New
width: u32,
height: u32,
}
world_data_load :: proc() {
// ...
// In the loop, we have pos and size instead of level_max
for _ in 0 ..< header.level_count {
// ...
level.id = level_header.id
level.pos = Vec2{f32(level_header.x), f32(level_header.y)} * TILE_SIZE
level.size = Vec2{f32(level_header.width), f32(level_header.height)} * TILE_SIZE
// ...
// Update due to new parameters
combine_colliders(level.pos, solid_tiles[:], &gs.colliders)
// The same for now
level.player_spawn = Vec2{100, 100}
// Changed from map to array
append(&gs.levels, level)
}
}
Okay, we're on the home stretch now. Time to put it all together back in main.odin:
In main_menu_update replace this entire block.
[!warning]
Checkpoints will be broken as well, for now.
// Save file exists, pass in location and seconds_played
if load_game_item_draw(
i,
panel_pos,
gs.main_menu_state.save_list_scroll_offset,
save_data.location,
save_data.seconds_played,
) {
gs.save_data = save_data
game_init(gs)
// If level doesn't exist, replace it with FIRST_LEVEL_ID
// NOTE: Else break save games during development
level := level_from_id(gs.levels[:], gs.save_data.level_id)
if level == nil {
gs.save_data.level_id = FIRST_LEVEL_ID
}
level_load(gs, gs.save_data.level_id)
}
[!note]
Make sure to replacelevel_loadin the else clause with the correct parameters:(gs, FIRST_LEVEL_ID).
Next up we've got a lot of small changes in game_update:
gs.level.colliders->gs.colliderslevel.level_min->gs.level.poslevel.level_max->gs.level.pos + gs.level.sizegs.tiles->gs.level.tilesgs.doors->gs.level.doorsgs.checkpoints->gs.level.checkpointsCheckpointuses.pos.xinstead of.x, etc
And some more specific changes, in our drawing code:
// Replace the power-up draw code with this
for power_up in gs.level.power_up_spawns {
if power_up.type not_in gs.save_data.collected_power_ups {
rl.DrawRectangleLinesEx({power_up.pos.x, power_up.pos.y, 16, 16}, 1, rl.GOLD)
}
}
Our map drawing code has several changes:
// iid -> id
for id in gs.save_data.visited_level_ids {
// Changed
level := level_from_id(gs.levels[:], id)
if level == nil {
continue
}
base_pos := level.pos // Changed
base_pos -= map_offset
base_pos = linalg.floor(base_pos / 16)
base_pos += Vec2{RENDER_WIDTH, RENDER_HEIGHT} / 2
size := level.size / 16 // Changed
bg_color := id == gs.level.id ? BG_COLOR_CURRENT_LEVEL : BG_COLOR_OTHER_LEVEL // Changed
rl.DrawRectangleV(base_pos, size, bg_color)
rl.DrawRectangleLinesEx({base_pos.x, base_pos.y, size.x, size.y}, 1, rl.WHITE)
for door in level.doors { // Changed
rel_door_pos := Vec2 {
(door.rect.x - level.pos.x) / 16, // Changed
(door.rect.y - level.pos.y) / 16, // Changed
}
door_pos := linalg.floor(base_pos + rel_door_pos)
door_size := Vec2{door.rect.width, door.rect.height} / 16
door_size.x = max(door_size.x, 1)
door_size.y = max(door_size.y, 1)
rl.DrawRectangleV(door_pos, door_size, rl.DARKBLUE)
}
}
And finally, we're going to overwrite our world data again at each launch (temporarily):
main :: proc() {
world_data_save()
// ...
}
That was a big one - resizing and moving levels around coming up next.
Then, we'll add proper saving and loading, then fix all the existing mechanics - and finally be in a pretty robust spot.