PVG
40. Auto Tileset — Program Video Games

40. Auto Tileset

[[programvideogames]]In this lecture we're going to set up a fully automated tiling system.

I want to outline again that we aren't making a general purpose editor for any game.

We are making an editor specifically for this game.

As such, we can implement only what's required.

Let's get started with our new types in either editor.odin:

// Dir8 -> Vec2i mapping
Dir8_Coords := [Dir8]Vec2i {
    .NE = Vec2i{1, -1},
    .SE = Vec2i{1, 1},
    .SW = Vec2i{-1, 1},
    .NW = Vec2i{-1, -1},
    .N  = Vec2i{0, -1},
    .E  = Vec2i{1, 0},
    .S  = Vec2i{0, 1},
    .W  = Vec2i{-1, 0},
}

// Used for auto-tiling algorithm
Tile_Neighbors :: bit_set[Dir8]
TILE_NEIGHBORS_ALL: Tile_Neighbors : {.N, .E, .S, .W, .NE, .SE, .SW, .NW}

// Not currently used
Tile_Flip :: enum {
    None,
    X,
    Y,
    Both,
}

Rule_Flags :: enum {
    Match_Exact,
    Terminate,
}

// When autotiling, we run these rules over every tile in the scene
Tileset_Rule :: struct {
    src:           Vec2,
    neighbors:     Tile_Neighbors,
    not_neighbors: Tile_Neighbors,
    flip:          Tile_Flip,
    flags:         bit_set[Rule_Flags],
}

Tileset :: struct {
    texture: rl.Texture2D,
    rules:   []Tileset_Rule,
}

Now we need to setup our tileset. This is a test tileset that's equivalent to the "dirt" tileset that we were using in LDtk.

To get values of each src field, open the tileset image in Aseprite (or a similar editor) and check the coordinates of the top-left corner of the tile you want.

The src field refers to the pixel position inside the texture associated with the Tileset

The rules are similar evaluated from top to bottom.

make_tileset :: proc() -> Tileset {
    tileset: Tileset

    rules := make([dynamic]Tileset_Rule)

    // Centre
    append(&rules, Tileset_Rule{src = {48, 48}})

    // NW, NE, SE, SW
    append(&rules, Tileset_Rule{neighbors = {.E, .S}, not_neighbors = {.N}, src = {16, 16}})
    append(&rules, Tileset_Rule{neighbors = {.W, .S}, not_neighbors = {.N}, src = {80, 16}})
    append(&rules, Tileset_Rule{neighbors = {.W, .N}, not_neighbors = {.S}, src = {80, 80}})
    append(&rules, Tileset_Rule{neighbors = {.E, .N}, not_neighbors = {.S}, src = {16, 80}})

    // N, E, S, W
    append(&rules, Tileset_Rule{neighbors = {.E, .W, .S}, not_neighbors = {.N}, src = {48, 16}})
    append(&rules, Tileset_Rule{neighbors = {.W, .SW, .NW}, not_neighbors = {.E}, src = {80, 48}})
    append(&rules, Tileset_Rule{neighbors = {.E, .W, .N}, not_neighbors = {.S}, src = {48, 80}})
    append(&rules, Tileset_Rule{neighbors = {.E, .SE, .NE}, not_neighbors = {.W}, src = {16, 48}})

    // Outcropping E, W
    append(&rules, Tileset_Rule{neighbors = {.W}, not_neighbors = {.N, .E, .S}, src = {16, 320}})
    append(&rules, Tileset_Rule{neighbors = {.W}, not_neighbors = {.N, .S}, src = {16, 320}})
    append(&rules, Tileset_Rule{neighbors = {.E}, not_neighbors = {.N, .W, .S}, src = {16, 320}})
    append(&rules, Tileset_Rule{neighbors = {.E}, not_neighbors = {.N, .S}, src = {16, 320}})

    // Outcropping N, S
    append(&rules, Tileset_Rule{neighbors = {.S}, not_neighbors = {.E, .N, .W}, src = {16, 320}})
    append(&rules, Tileset_Rule{neighbors = {.S}, not_neighbors = {.E, .W}, src = {16, 320}})
    append(&rules, Tileset_Rule{neighbors = {.N}, not_neighbors = {.E, .S, .W}, src = {16, 320}})
    append(&rules, Tileset_Rule{neighbors = {.N}, not_neighbors = {.E, .W}, src = {16, 320}})

    // Outcropping Corners
    append(&rules, Tileset_Rule{neighbors = {.N, .E}, src = {16, 320}, flags = {.Match_Exact}})
    append(&rules, Tileset_Rule{neighbors = {.S, .E}, src = {16, 320}, flags = {.Match_Exact}})
    append(&rules, Tileset_Rule{neighbors = {.S, .W}, src = {16, 320}, flags = {.Match_Exact}})
    append(&rules, Tileset_Rule{neighbors = {.N, .W}, src = {16, 320}, flags = {.Match_Exact}})

    // Outcropping Join
    append(&rules, Tileset_Rule{neighbors = {.N, .E, .S, .SW, .W, .NW}, src = {80, 48}, flags = {.Match_Exact}})
    append(&rules, Tileset_Rule{neighbors = {.N, .NE, .E, .SE, .S, .W}, src = {16, 48}, flags = {.Match_Exact}})
    append(&rules, Tileset_Rule{neighbors = {.N, .E, .SE, .S, .SW, .W}, src = {112, 16}, flags = {.Match_Exact}})
    append(&rules, Tileset_Rule{neighbors = {.N, .E, .NE, .S, .NW, .W}, src = {48, 80}, flags = {.Match_Exact}})

    // Outcropping Cross
    append(&rules, Tileset_Rule{neighbors = {.E, .S, .W}, src = {16, 320}, flags = {.Match_Exact}})
    append(&rules, Tileset_Rule{neighbors = {.E, .N, .W}, src = {16, 320}, flags = {.Match_Exact}})
    append(&rules, Tileset_Rule{neighbors = {.E, .N, .S}, src = {16, 320}, flags = {.Match_Exact}})
    append(&rules, Tileset_Rule{neighbors = {.W, .N, .S}, src = {16, 320}, flags = {.Match_Exact}})

    tileset.texture = gs.tileset_texture
    tileset.rules = rules[:]

    return tileset
}

This procedure is used to calculate the neighbors of a specific tile.

We store the value in a bit_map.

autotile_calculate_neighbors :: proc(coords: Vec2i, l: ^Level) -> Tile_Neighbors {
    result: Tile_Neighbors

    if is_tile_at(coords + Dir8_Coords[.N], l) do result += {.N}
    if is_tile_at(coords + Dir8_Coords[.E], l) do result += {.E}
    if is_tile_at(coords + Dir8_Coords[.S], l) do result += {.S}
    if is_tile_at(coords + Dir8_Coords[.W], l) do result += {.W}

    if is_tile_at(coords + Dir8_Coords[.NE], l) do result += {.NE}
    if is_tile_at(coords + Dir8_Coords[.SE], l) do result += {.SE}
    if is_tile_at(coords + Dir8_Coords[.SW], l) do result += {.SW}
    if is_tile_at(coords + Dir8_Coords[.NW], l) do result += {.NW}

    return result
}

This procedure runs the autotile. We iterate over every tile for every rule.

We optimise this in the next lecture. Can you guess how?

autotile_run :: proc(l: ^Level) {
    for &tile in gs.level.tiles {
        tile.src = 0
    }

    for rule in es.tileset.rules {
        for &tile in gs.level.tiles {
            coords := coords_from_pos(tile.pos)
            neighbors := autotile_calculate_neighbors(coords, l)

            if .Match_Exact in rule.flags {
                if rule.neighbors == neighbors && rule.not_neighbors & neighbors == {} {
                    tile.src = rule.src
                }
            } else {
                if rule.neighbors <= neighbors && rule.not_neighbors & neighbors == {} {
                    tile.src = rule.src
                }
            }
        }
    }
}

We need somewhere to store our Tileset.

Editor_State :: struct {
    // ...
     command_history:   [dynamic]Cmd_Entry,
     undo_count:        int,
     spike_orientation: Direction,
    tileset:           Tileset,
 }

Down in editor_command_execute, we want to call the following procedures after every change (after the switch).

This is an optimisation as we are currently calling recreate_colliders on every individual tile insert. So, placing a 3x3 tile area runs recreate_colliders 9 times. We'll be deleting that shortly.

editor_command_execute :: proc(cmd: Cmd) {
    // ...
    recreate_colliders(gs.level.pos, &gs.colliders, gs.falling_logs[:], gs.level.tiles[:])
    autotile_run(gs.level)
 }

In editor_init, we'll call our make_tileset procedure.

editor_init :: proc() {
    // ...
    es.tileset = make_tileset()
 }

At the end of editor_draw, optionally draw the rectangles that overlay green or red onto the surrounding tiles based on solid or air.

editor_draw :: proc(gs: ^Game_State) {
    // ...
    coords := coords_from_pos(rl.GetScreenToWorld2D(rl.GetMousePosition(), gs.camera))

    rl.BeginMode2D(gs.camera)

    for dir in Dir8_Coords {
        pos := pos_from_coords(coords + dir)
        if is_tile_at(coords + dir, gs.level) {
            rl.DrawRectangleV(pos, 16, {0, 200, 0, 128})
        } else {
            rl.DrawRectangleV(pos, 16, {200, 0, 0, 128})
        }
    }

    rl.EndMode2D()
 }

Make sure to delete recreate_colliders from editor_tile_insert and editor_tile_remove!

Let's head over to main.odin. First, swap where we load gs.tileset_texture and gs.item_texture.

game_init :: proc(gs: ^Game_State) {
    // ...
     gs.player_texture = rl.LoadTexture("assets/textures/player_120x80.png")
    gs.item_texture = rl.LoadTexture("assets/textures/items_16x16.png") // New
    // ...
}

Move editor_init() in main below where we load gs.tileset_texuture.

main :: proc() {
     gs.font_48 = rl.LoadFontEx("assets/fonts/Gogh-ExtraBold.ttf", 48, nil, 256)
     gs.font_64 = rl.LoadFontEx("assets/fonts/Gogh-ExtraBold.ttf", 64, nil, 256)
 
    gs.tileset_texture = rl.LoadTexture("assets/textures/tileset.png")

    editor_init()
 
     gs.camera = rl.Camera2D { // ...

Alright, that's all we needed to get pretty decent autotiling in our game!

Next lecture we'll optimise it a bit and work on level transitions.