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
}