33. Binary World Data
[[programvideogames]]In this lecture we're going to create a decoding procedure and a faux-encoding procedure to draw a simple (but static) level and load it back again. All our own binary format!
We are going to store our data like this:
[world_header][level_1_header][level_1_data][level_2_header][level_2_data]...
Later, we'll modify the layout to support tilesets as well. Perhaps we will append them to the end for easy backwards compatibility.
Create a new file called encoding.odin
package main
import "core:bytes"
import "core:mem"
import "core:os"
import "base:intrinsics"
import "base:runtime"
// This goes at the beginning of our file
World_Data_Header :: struct {
magic: u32, // Identify the file type
version_major: u32, // Versions could have different code-paths
version_minor: u32, // for backwards compatibility
version_patch: u32,
level_count: u32,
tileset_count: u32, // Tilesets won't be implemented for a while yet
}
// TODO
World_Data_Tileset_Header :: struct {}
// This header takes up the bytes at the beginning of each level
World_Data_Level_Header :: struct {
magic: u32,
id: u32,
width: u32,
height: u32,
}
LEVEL_MAGIC :: 0xBEBAFECA // CAFEBABE
HEADER_MAGIC :: 0x57475650 // PVGW
We're gong to use a byte buffer writer to write our data sequentially. It comes with a bunch of procedures to make this a breeze.
world_data_save :: proc() {
b := bytes.Buffer {
buf = make([dynamic]u8, context.temp_allocator),
}
header := World_Data_Header {
magic = HEADER_MAGIC,
version_major = 0,
version_minor = 1,
version_patch = 0,
level_count = 1,
tileset_count = 0,
}
bytes.buffer_write_ptr(&b, &header, size_of(World_Data_Header))
level_1 := World_Data_Level_Header {
magic = LEVEL_MAGIC,
id = 1,
width = 40,
height = 23,
}
bytes.buffer_write_ptr(&b, &level_1, size_of(World_Data_Level_Header))
tiles := make([]u8, level_1.width * level_1.height, context.temp_allocator)
// Static "floor" level for now
for x in 0 ..< level_1.width {
tiles[(22 * level_1.width) + x] = 1
}
bytes.buffer_write(&b, tiles)
if !os.write_entire_file("data/world.dat", bytes.buffer_to_bytes(&b)) {
panic("Failed to write world file")
}
}
Conversely, we use a reader to read the bytes back into a structured form.
world_data_load :: proc() {
data, ok := os.read_entire_file("data/world.dat", context.temp_allocator)
if !ok {
panic("Failed to read world file")
}
header: World_Data_Header
r: bytes.Reader
bytes.reader_init(&r, data)
n_header, err_header := bytes.reader_read(&r, mem.any_to_bytes(header))
assert(n_header == size_of(World_Data_Header) && err_header == nil)
assert(header.magic == HEADER_MAGIC)
for _ in 0 ..< header.level_count {
level: Level
level_header: World_Data_Level_Header
n, err := bytes.reader_read(&r, mem.any_to_bytes(level_header))
assert(err == nil)
assert(n == size_of(World_Data_Level_Header))
assert(level_header.magic == LEVEL_MAGIC)
// NOTE: We are adding level ids as u32s!
// We will phase out the string based ID system
level.id = level_header.id
level.level_max = Vec2{f32(level_header.width), f32(level_header.height)} * TILE_SIZE
solid_tiles := make([dynamic]Rect, context.temp_allocator)
for y in 0 ..< level_header.height {
for x in 0 ..< level_header.width {
tile_type_index: u8
bytes.reader_read(&r, mem.any_to_bytes(tile_type_index))
if tile_type_index > 0 {
append(
&solid_tiles,
Rect{f32(x) * TILE_SIZE, f32(y) * TILE_SIZE, TILE_SIZE, TILE_SIZE},
)
}
}
}
// We are about to move this functionality
// to a dedicated procedure
combine_level_colliders(solid_tiles[:], &level)
// Test level & spawn
level.player_spawn = Vec2{100, 100}
gs.level_definitions["BINARY_TEST"] = level
}
}
Over in main.odin we want to modify the FIRST_LEVEL_IID to load our new level.
We also want to add the id field to the Level struct.
Level :: struct {
id: u32, // New
// ...
}
// ...
FIRST_LEVEL_IID :: "BINARY_TEST"
In level_parse_and_store, copy the code that combined colliders and move it to it's own procedure called combine_level_colliders.
level_parse_and_store :: proc(gs: ^Game_State, level: ^LDtk_Level) {
// ...
// Replace the code with a call to the new procedure
combine_level_colliders(solid_tiles[:], &l)
// Tiles
for auto_tile in layer.autoLayerTiles {
// ...
Here's the "new" procedure:
combine_level_colliders :: proc(solid_tiles: []Rect, l: ^Level) {
wide_rect := solid_tiles[0]
wide_rects := make([dynamic]Rect, context.temp_allocator)
for i in 1 ..< len(solid_tiles) {
rect := solid_tiles[i]
if rect.x == wide_rect.x + wide_rect.width {
wide_rect.width += TILE_SIZE
} else {
append(&wide_rects, wide_rect)
wide_rect = rect
}
}
append(&wide_rects, wide_rect)
slice.sort_by(wide_rects[:], proc(a, b: Rect) -> bool {
if a.x != b.x do return a.x < b.x
return a.y < b.y
})
big_rect := wide_rects[0]
for i in 1 ..< len(wide_rects) {
rect := wide_rects[i]
if rect.x == big_rect.x &&
big_rect.width == rect.width &&
big_rect.y + big_rect.height == rect.y {
big_rect.height += TILE_SIZE
} else {
big_rect.x += l.level_min.x
big_rect.y += l.level_min.y
append(&l.colliders, big_rect)
big_rect = rect
}
}
big_rect.x += l.level_min.x
big_rect.y += l.level_min.y
append(&l.colliders, big_rect)
}
To test this new binary format encoding and decoding, add calls to the new procedures at the beginning of main.
main :: proc() {
rl.SetTargetFPS(60)
rl.InitAudioDevice()
world_data_save() // New
world_data_load() // New
// ...
And that's it! We now have a working binary format for our levels. Pretty neat, if I may say so myself.