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
srcfield refers to the pixel position inside the texture associated with theTileset
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_colliderson every individual tile insert. So, placing a 3x3 tile area runsrecreate_colliders9 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_collidersfromeditor_tile_insertandeditor_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.