PVG
33. Binary World Data — Program Video Games

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.