PVG
44. Wrapping up the Level Tool — Program Video Games

44. Wrapping up the Level Tool

In this lecture we are going to add the following commands:

  • Level Move
  • Level New
  • Level Delete
  • Level Restore (opposite of delete)
  • Level Resize

With that in mind, we need some state and some types:

Editor_State :: struct {
    // New below ...
    is_dragging:       bool,
    drag_start:        Vec2,
    drag_end:          Vec2,
    deleted_levels:    [dynamic]Level,
}

Cmd :: union {
    // New below ...
    Cmd_Level_Move,
    Cmd_Level_New,
    Cmd_Level_Delete,
    Cmd_Level_Restore,
    Cmd_Level_Resize,
}

+Cmd_Level_Move :: struct {
+       level_id: u32,
+       old_pos:  Vec2,
+       new_pos:  Vec2,
+}
+
+Cmd_Level_New :: struct {
+       pos:      Vec2,
+       size:     Vec2,
+       level_id: u32,
+}
+
+Cmd_Level_Delete :: struct {
+       level_id: u32,
+}
+
+Cmd_Level_Restore :: struct {
+       level_id: u32,
+}
+
+Cmd_Level_Resize :: struct {
+       level_id:       u32,
+       old_pos:        Vec2,
+       old_size:       Vec2,
+       new_pos:        Vec2,
+       new_size:       Vec2,
+       removed_tiles:  []Tile,
+       removed_spikes: []Spike,
+}

I think by now we have a good handle on how this all works. The standout case here is Resize which must account for tiles and spikes - as the user may shrink the level and cut out some content.

In editor_command_execute, we must handle the new cases:

case Cmd_Level_Move:
    level_load(gs, v.level_id, 0)

    delta := v.new_pos - v.old_pos

    gs.level.pos = gs.level.pos + delta

    for &tile in gs.level.tiles {
        tile.pos += delta
    }
case Cmd_Level_New:
    level: Level
    level.id = v.level_id
    level.name = strings.clone(fmt.tprintf("level_%d", level.id))
    level.pos = v.pos
    level.player_spawn = level.pos
    level.size = v.size
    append(&gs.levels, level)

    level_load(gs, level.id, 0)
case Cmd_Level_Delete:
    append(&es.deleted_levels, level_from_id(gs.levels[:], v.level_id)^)
    deleted_level_index := level_index_from_id(gs.levels[:], v.level_id)
    // Since we don't clear the dynamic memory, it still exists even after this
    unordered_remove(&gs.levels, deleted_level_index)

    // Load first non-deleted level
    // Prevents artifacts from level data sticking around
    for l in gs.levels {
        if l.id != v.level_id {
            level_load(gs, l.id, 0)
        }
    }
case Cmd_Level_Restore:
    append(&gs.levels, level_from_id(es.deleted_levels[:], v.level_id)^)
    restored_level_index := level_index_from_id(es.deleted_levels[:], v.level_id)
    unordered_remove(&es.deleted_levels, restored_level_index)

    level_load(gs, v.level_id, 0)
case Cmd_Level_Resize:
    level_load(gs, v.level_id, 0)

    // We are reducing the size, therefore we may need to remove some tiles/spikes/(TODO)entities
    // NOTE: I am comparing coords in case *somehow* there are floating point precision issues
    // Even though we set values to absolute numbers, we may do something in the future
    // that means we break == comparison
    if gs.level.size.x > v.new_size.x || gs.level.size.y > v.new_size.y {
        for tile in v.removed_tiles {
            tile_coords := coords_from_pos(tile.pos)
            #reverse for level_tile, i in gs.level.tiles {
                level_tile_coords := coords_from_pos(level_tile.pos)
                if tile_coords == level_tile_coords {
                    ordered_remove(&gs.level.tiles, i)
                }
            }
        }

        for spike in v.removed_spikes {
            spike_coords := coords_from_pos({spike.collider.x, spike.collider.y})
            #reverse for level_spike, i in gs.level.spikes {
                level_spike_coords := coords_from_pos({level_spike.collider.x, level_spike.collider.y})
                if spike_coords == level_spike_coords {
                    ordered_remove(&gs.level.spikes, i)
                }
            }
        }
    } else {
        // NOTE: This is an "Undo"
        for tile in v.removed_tiles {
            append(&gs.level.tiles, tile)
        }

        for spike in v.removed_spikes {
            append(&gs.level.spikes, spike)
        }
    }

    gs.level.pos = v.new_pos
    gs.level.size = v.new_size

Likewise we must handle the cases in editor_command_construct:

case Cmd_Level_Move:
    forward := Cmd_Level_Move {
        level_id = gs.level.id,
        old_pos  = es.drag_start,
        new_pos  = es.drag_end,
    }

    inverse := Cmd_Level_Move {
        level_id = forward.level_id,
        old_pos  = forward.new_pos,
        new_pos  = forward.old_pos,
    }

    return {forward, inverse}, true
case Cmd_Level_New:
    mouse_pos := rl.GetMousePosition()
    world_pos := rl.GetScreenToWorld2D(mouse_pos, gs.camera)
    // Round to tile corner
    coords := coords_from_pos(world_pos)
    pos := pos_from_coords(coords)

    // Default level size is one "screen"
    size := linalg.ceil(Vec2{RENDER_WIDTH, RENDER_HEIGHT} / TILE_SIZE) * TILE_SIZE
    rect := rect_from_pos_size(pos, size)

    for l in gs.levels {
        def_rect := rect_from_pos_size(l.pos, l.size)
        if rl.CheckCollisionRecs(rect, def_rect) {
            // Invalid position
            return {}, false
        }
    }

    level_id := get_next_level_id()

    forward := Cmd_Level_New {
        level_id = level_id,
        pos      = pos,
        size     = size,
    }

    inverse := Cmd_Level_Delete {
        level_id = level_id,
    }

    return {forward, inverse}, true
case Cmd_Level_Delete:
    forward := Cmd_Level_Delete {
        level_id = gs.level.id,
    }

    inverse := Cmd_Level_Restore {
        level_id = gs.level.id,
    }

    return {forward, inverse}, true
case Cmd_Level_Restore:
    forward := Cmd_Level_Restore {
        level_id = gs.level.id,
    }

    inverse := Cmd_Level_Delete {
        level_id = gs.level.id,
    }

    return {forward, inverse}, true
case Cmd_Level_Resize:
    mouse_pos := rl.GetMousePosition()
    world_pos := rl.GetScreenToWorld2D(mouse_pos, gs.camera)
    new_pos, new_size := calculate_resize(gs.level, world_pos, es.resize_start_pos, es.resize_level_dir)

    removed_tiles := make([dynamic]Tile)
    removed_spikes := make([dynamic]Spike)

    new_rect := rect_from_pos_size(new_pos, new_size)

    for tile in gs.level.tiles {
        // Shrink slightly to account for floating point precision
        rect := Rect{tile.pos.x + 1, tile.pos.y + 1, TILE_SIZE - 2, TILE_SIZE - 2}
        if !rl.CheckCollisionRecs(new_rect, rect) {
            append(&removed_tiles, tile)
        }
    }

    for spike in gs.level.spikes {
        // Shrink slightly to account for floating point precision
        rect := rect_pos_add(spike.collider, 1)
        rect.width -= 2
        rect.height -= 2
        if !rl.CheckCollisionRecs(new_rect, rect) {
            append(&removed_spikes, spike)
        }
    }

    forward := Cmd_Level_Resize {
        level_id       = gs.level.id,
        old_pos        = gs.level.pos,
        old_size       = gs.level.size,
        new_pos        = new_pos,
        new_size       = new_size,
        removed_tiles  = removed_tiles[:],
        removed_spikes = removed_spikes[:],
    }

    inverse := Cmd_Level_Resize {
        level_id       = forward.level_id,
        old_pos        = forward.new_pos,
        old_size       = forward.new_size,
        new_pos        = forward.old_pos,
        new_size       = forward.old_size,
        removed_tiles  = removed_tiles[:],
        removed_spikes = removed_spikes[:],
    }

    return {forward, inverse}, true

Since we are turning the level changes into commands, we must update the input code in editor_update. I have included the entire Level and Level_Resize cases here:

case .Level:
    if es.is_dragging {
        es.drag_end = rl.GetScreenToWorld2D(mouse_pos, gs.camera)
        es.drag_end = linalg.round(es.drag_end / TILE_SIZE) * TILE_SIZE

        if rl.IsMouseButtonReleased(.LEFT) {
            es.is_dragging = false

            start_coords := coords_from_pos(es.drag_start)
            end_coords := coords_from_pos(es.drag_end)

            if start_coords != end_coords {
                editor_command_dispatch(Cmd_Level_Move)
            }
        }
    } else {
        for level in gs.levels {
            level_rect := rect_from_pos_size(level.pos - gs.camera.target, level.size)
            level_rect = rect_scale_all(level_rect, gs.camera.zoom)
            if rl.CheckCollisionPointRec(mouse_pos, level_rect) {
                if rl.IsMouseButtonPressed(.LEFT) {
                    level_load(gs, level.id, 0)
                } else if rl.IsMouseButtonDown(.LEFT) {
                    es.is_dragging = true
                    es.drag_start = rl.GetScreenToWorld2D(mouse_pos, gs.camera)
                    es.drag_start = linalg.round(es.drag_start / TILE_SIZE) * TILE_SIZE
                }
            }
        }
    }

    if rl.IsMouseButtonPressed(.LEFT) || rl.IsMouseButtonPressed(.RIGHT) {
        es.area_begin = coords
    }

    if rl.IsMouseButtonDown(.LEFT) || rl.IsMouseButtonDown(.RIGHT) {
        es.area_end = coords
    }

    if rl.IsMouseButtonReleased(.LEFT) {
        editor_command_dispatch(Cmd_Level_New)
    }

    if rl.IsMouseButtonReleased(.RIGHT) {
        rect := rect_from_coords_any_orientation(es.area_begin, es.area_end)
        editor_remove_level(gs, rect)
    }

    if rl.IsKeyPressed(.DELETE) {
        if len(gs.levels) > 1 {
            editor_command_dispatch(Cmd_Level_Delete)
        }
    }

    es.resize_rect = {}

    level_rect := rect_from_pos_size(gs.level.pos - gs.camera.target, gs.level.size)
    level_rect = rect_scale_all(level_rect, gs.camera.zoom)

    for dir in Dir8 {
        resize_rect := calculate_resize_rect(level_rect, dir)
        if rl.CheckCollisionPointRec(mouse_pos, resize_rect) {
            rl.SetMouseCursor(Resize_Cursor[dir])
            es.resize_rect = resize_rect

            if rl.IsMouseButtonPressed(.LEFT) {
                es.resize_level_dir = dir
                es.tool = .Level_Resize
                es.area_begin = coords_from_pos(gs.level.pos)
                es.resize_start_pos = rl.GetScreenToWorld2D(mouse_pos, gs.camera)
            }

            break
        }
    }
case .Level_Resize:
    if rl.IsMouseButtonReleased(.LEFT) {
        editor_command_dispatch(Cmd_Level_Resize)
        es.tool = .Level
    }

Down in editor_draw where we draw the "one_screen_rect", I've added an extra check to make sure we don't show it when dragging levels around:

if es.tool == .Level || es.tool == .Level_Resize {
    if !es.is_dragging {
        one_screen_rect := rect
        one_screen_rect.width = math.ceil(f32(RENDER_WIDTH) / TILE_SIZE) * TILE_SIZE * gs.camera.zoom
        one_screen_rect.height = math.ceil(f32(RENDER_HEIGHT) / TILE_SIZE) * TILE_SIZE * gs.camera.zoom
        rl.DrawRectangleLinesEx(one_screen_rect, 1, rl.DARKGRAY)
    }
} else {
    rl.DrawRectangleLinesEx(rect, 4, place ? rl.WHITE : rl.RED)
}

Followed by some code (just before we end drawing to the camera) to draw the new position while we drag levels around:

if es.is_dragging {
       delta := es.drag_end - es.drag_start
       rl.DrawRectangleLinesEx(rect_from_pos_size(gs.level.pos + delta, gs.level.size), 2, rl.GRAY)
}

// Just before this!
rl.EndMode2D()

Next, we move the debug text into the side panel and draw the side panel at all times:

If you feel the panel is taking up too much space, try binding a key to turn it on and off.

Remove the if es.tool == .Level check before editor_panel(PANEL_WIDTH).

The panel code:

editor_panel(PANEL_WIDTH)

editor_panel_text(fmt.ctprintf("Level ID: %d", gs.level.id))
editor_panel_text(fmt.ctprintf("Tool: %s", es.tool))
editor_panel_text(fmt.ctprintf("History: %d/%d", len(es.command_history) - es.undo_count, len(es.command_history)))
editor_panel_text(fmt.ctprintf("Orientation: %v", es.spike_orientation))
editor_panel_text(fmt.ctprintf("Camera Zoom: %v", gs.camera.zoom))

level_pos_tiles := coords_from_pos(gs.level.pos)
editor_panel_text(fmt.ctprintf("Pos: %d, %d", level_pos_tiles.x, level_pos_tiles.y))

level_size_tiles := coords_from_pos(gs.level.size)
editor_panel_text(fmt.ctprintf("Size: %d, %d", level_size_tiles.x, level_size_tiles.y))

// NOTE: Probably don't need this! I left it here because that's what's in the code
editor_panel_text(fmt.ctprintf("Dragging: %v", es.is_dragging))

editor_panel_text("---")
editor_panel_text("Level On Enter:")

for k, v in level_on_enter_map {
    if gs.level.on_enter == v {
        if editor_panel_button(k, rl.ORANGE) {
            gs.level.on_enter = nil
        }
    } else if editor_panel_button(k) {
        gs.level.on_enter = v
    }
}

Delete editor_place_level and editro_remove_level as we are using the command system now.

The last thing in editor.odin, we'll modify get_next_level_id:

get_next_level_id :: proc() -> u32 {
    id := u32(1)
    for l in es.deleted_levels {
        if l.id > id {
            id = l.id
        }
    }

    for l in gs.levels {
        if l.id > id {
            id = l.id
        }
    }

    return id + 1
}

We must take into account deleted levels so that we don't get levels with the same ID.

Finally, we need a function to get a level id. I put this in main.odin but it could comfortably live in util.odin or even editor.odin:

// Returns -1 on failure
level_index_from_id :: proc(levels: []Level, id: u32) -> int {
    for l, i in levels {
        if l.id == id {
            return i
        }
    }
    return -1
}