PVG
34. Tile Editing — Program Video Games

34. Tile Editing

[[programvideogames]]In this lesson we'll add the ability to do real-time tile placing and removing.

First, we're going to add some procs in util.odin:

import "core:math/linalg"

rect_scale_all :: #force_inline proc(r: Rect, s: f32) -> Rect {
    return Rect{r.x * s, r.y * s, r.width * s, r.height * s}
}

// pos, coords: relative to the current level
// always starts at 0,0 and goes until level_max/columns,rows

coords_from_pos :: proc(pos: Vec2) -> Vec2i {
    coordsf := linalg.floor(pos) / TILE_SIZE
    return Vec2i{i32(coordsf.x), i32(coordsf.y)}
}

pos_from_coords :: proc(coords: Vec2i) -> Vec2 {
    return Vec2{f32(coords.x), f32(coords.y)} * TILE_SIZE
}

These will help us convert between tile coords and positions.

The rect_scale_all is a convenience proc as we can't do vector math on the Rect type (though I will show you a cool way to get around this).

Over in main.odin we need a couple of types:

Vec2 :: rl.Vector2
Vec4 :: rl.Vector4 // New
Rect :: rl.Rectangle
Vec2i :: [2]i32 // New

We'll use Vec2i for our tile coordinates, and the Vec4 for that trick I alluded to.

Let's add a quick bool to track whether our editor is currently active:

Game_State :: struct {
    editor_enabled: bool,
    // ...
    // colliders: [dynamic]Rect // DELETE THIS
}

bool is false by default, so we don't have to initialise this

When writing the editor code, I realised we had needlessly duplicated colliders. Delete the one in Game_State.

Update anywhere that uses gs.colliders to gs.level.colliders in both main.odin and demon_boss.odin.

While we're here, let's fix a bug in the combine_level_colliders proc:

for i in 1 ..< len(solid_tiles) {
    rect := solid_tiles[i]

    // New condition on the next line
    if rect.x == wide_rect.x + wide_rect.width && rect.y == wide_rect.y {
        wide_rect.width += TILE_SIZE
    } else {
        append(&wide_rects, wide_rect)
        wide_rect = rect
    }
}

rect.y == wide_rect.y - make sure that the tiles we combine are on the same row.

We didn't witness this bug so far due to the way our data was organised by LDtk.

New data means a new problem, requiring a new (or altered) solution!

Now, I've opted to use the F5 key to enable/disable the editor:

// In the main loop
dt := rl.GetFrameTime()

if rl.IsKeyPressed(.F5) { // New
    gs.editor_enabled = !gs.editor_enabled
}

// Make sure to get a new pointer every frame in case the entities
// array is moved in memory
player := entity_get(gs.player_id) // Changed: Hoisted up to the main loop

if gs.editor_enabled { // New
    editor_update(gs)
} else {
    if rl.IsKeyPressed(.TAB) {
        gs.game_menu_state.menu_type += cast(Game_Menu_Type)1
        if gs.game_menu_state.menu_type == .Count {
            gs.game_menu_state.menu_type = .None
        }
    }
    // ...
}

rl.BeginDrawing()
// ...

We wrap all the current update code in an else block and call editor_update if the editor is enabled.

We needed to "hoist" the player variable up out of that block so we can continue to use it in the drawing code below.

Now, right at the end of game_update, we want to call our editor draw code (we'll make this shortly).

rl.EndMode2D()

if gs.editor_enabled {
    editor_draw(gs)
}

rl.EndDrawing()

We are going to need one more proc in main.odin before we head over to create the editor code.

This one pairs with combine_level_colliders:

recreate_level_colliders :: proc(l: ^Level) {
    clear(&l.colliders)
    solid_tiles := make([dynamic]Rect, context.temp_allocator)
    for t in l.tiles {
        append(&solid_tiles, Rect{t.pos.x, t.pos.y, TILE_SIZE, TILE_SIZE})
    }
    combine_level_colliders(solid_tiles[:], l)
}

Below I will reproduce the entire new src/editor.odin file with comments.

package main

import "core:fmt"
import "core:math/linalg"
import "core:slice"
import rl "vendor:raylib"

Editor_Tool :: enum {
    None,
    Tile,
}

Editor_State :: struct {
    tool:       Editor_Tool,
    area_begin: Vec2i,
    area_end:   Vec2i,
}

// Local state for the editor only
@(private = "file")
es: Editor_State

editor_update :: proc(gs: ^Game_State) {
    if rl.IsKeyPressed(.T) {
        es.tool = .Tile
    }

    // TODO: Handle level offset
    pos := rl.GetMousePosition() + gs.camera.target * gs.camera.zoom
    pos /= gs.camera.zoom
    coords := coords_from_pos(pos)

    switch es.tool {
    case .None:
    case .Tile:
        // Begin the place/remove area
        if rl.IsMouseButtonPressed(.LEFT) || rl.IsMouseButtonPressed(.RIGHT) {
            es.area_begin = coords
        }

        // Set the place/remove area end
        if rl.IsMouseButtonDown(.LEFT) || rl.IsMouseButtonDown(.RIGHT) {
            es.area_end = coords
        }

        place := rl.IsMouseButtonReleased(.LEFT)
        remove := rl.IsMouseButtonReleased(.RIGHT)

        if place || remove {
            es.area_end = coords

            // Get the absolute width and height of rect
            diff := es.area_end - es.area_begin
            size := linalg.abs(diff)
            diff_f32 := Vec2{f32(diff.x), f32(diff.y)}
            // Use sign to ensure non top-left orientation works
            // Sign will be +1 for right, +1 for down, -1 for left, -1 for up
            sign := Vec2i{i32(linalg.sign(diff_f32.x)), i32(linalg.sign(diff_f32.y))}

            // Iterate through the tile coords, placing or removing
            for y in 0 ..= size.y {
                for x in 0 ..= size.x {
                    if place {
                        editor_place_tile(es.area_begin + {i32(x), i32(y)} * sign, gs.level)
                    } else {
                        editor_remove_tile(es.area_begin + {i32(x), i32(y)} * sign, gs.level)
                    }
                }
            }
        }
    }
}

editor_draw :: proc(gs: ^Game_State) {
    // Draw Editor UI
    rl.DrawTextEx(gs.font_48, "EDITOR MODE", {8, 8}, 48, 0, rl.WHITE)
    rl.DrawTextEx(gs.font_48, fmt.ctprintf("Tool: %s", es.tool), {8, 48}, 48, 0, rl.WHITE)

    place := rl.IsMouseButtonDown(.LEFT)
    remove := rl.IsMouseButtonDown(.RIGHT)

    if place || remove {
        rect := rect_from_coords_any_orientation(es.area_begin, es.area_end)
        rect.x -= gs.camera.target.x
        rect.y -= gs.camera.target.y

        // One way
        // v4 := transmute(^Vec4)&rect
        // v4^ *= gs.camera.zoom

        // Other way
        rect = rect_scale_all(rect, gs.camera.zoom)
        rl.DrawRectangleLinesEx(rect, 4, place ? rl.WHITE : rl.RED)
    }
}

is_tile_at_pos :: proc(pos: Vec2, l: ^Level) -> bool {
    for tile in l.tiles {
        rect := Rect{tile.pos.x, tile.pos.y, TILE_SIZE, TILE_SIZE}
        if rl.CheckCollisionPointRec(pos, rect) {
            return true
        }
    }
    return false
}

is_tile_at_coords :: proc(coords: Vec2i, l: ^Level) -> bool {
    return is_tile_at(pos_from_coords(coords), l)
}

is_tile_at :: proc {
    is_tile_at_pos,
    is_tile_at_coords,
}

editor_place_tile :: proc(coords: Vec2i, l: ^Level) {
    if !is_tile_at(coords, l) {
        append(&l.tiles, Tile{pos = pos_from_coords(coords)})
        slice.sort_by(l.tiles[:], proc(a, b: Tile) -> bool {
            if a.pos.y != b.pos.y do return a.pos.y < b.pos.y
            return a.pos.x < b.pos.x
        })

        recreate_level_colliders(l)
    }
}

try_remove_tile_at :: proc(coords: Vec2i, l: ^Level) -> bool {
    for tile, index in l.tiles {
        if coords_from_pos(tile.pos) == coords {
            ordered_remove(&l.tiles, index)
            return true
        }
    }
    return false
}

editor_remove_tile :: proc(coords: Vec2i, l: ^Level) {
    if try_remove_tile_at(coords, l) {
        slice.sort_by(l.tiles[:], proc(a, b: Tile) -> bool {
            if a.pos.y != b.pos.y do return a.pos.y < b.pos.y
            return a.pos.x < b.pos.x
        })

        recreate_level_colliders(l)
    }
}

rect_from_coords_any_orientation :: proc(a, b: Vec2i) -> Rect {
    top := f32(min(a.y, b.y)) * TILE_SIZE
    left := f32(min(a.x, b.x)) * TILE_SIZE
    bottom := f32(max(a.y, b.y)) * TILE_SIZE
    right := f32(max(a.x, b.x)) * TILE_SIZE

    return Rect{left, top, right - left + TILE_SIZE, bottom - top + TILE_SIZE}
}

Finally, in src/encoding.odin:

// in world_data_load
if tile_type_index > 0 {
    pos := Vec2{f32(x), f32(y)} * TILE_SIZE
    append(&solid_tiles, Rect{pos.x, pos.y, TILE_SIZE, TILE_SIZE})

    // We now append to level.tiles
    // TODO: Type of tile, orientation
    append(&level.tiles, Tile{pos = pos})
}