PVG
38. Resize Levels — Program Video Games

38. Resize Levels

[[programvideogames]]First thing's first. We are creating a new tool, so we need a new enum in Editor_Tool:

Editor_Tool :: enum {
    Tile,
    Spike,
    Level,
    Level_Resize,
}

Next, we'll create an enum for the 8 directions:

Dir8 :: enum {
    NE,
    SE,
    SW,
    NW,
    N,
    E,
    S,
    W,
}

The values are ordered this way so iteration gives priority to corners. This will be useful later.

Resize_Cursor := [Dir8]rl.MouseCursor {
    .N  = .RESIZE_NS,
    .NE = .RESIZE_NESW,
    .E  = .RESIZE_EW,
    .SE = .RESIZE_NWSE,
    .S  = .RESIZE_NS,
    .SW = .RESIZE_NESW,
    .W  = .RESIZE_EW,
    .NW = .RESIZE_NWSE,
}

Here we use an enum-array with Raylib's MouseCursor type to create a simple lookup table Dir8 -> rl.MouseCursor.

Editor_State :: struct {
    // ...
    resize_level_dir: Dir8,
    resize_rect:      Rect,
    resize_start_pos: Vec2,
}

Finally, we add some state values to make this all work.

Now that we've added a new tool type, the compiler will complain that we haven't added a switch case for .Level_Resize, so let's do that.

Down in editor_update, add the new case:

case .Level_Resize:
    if rl.IsMouseButtonReleased(.LEFT) {
        world_pos := rl.GetScreenToWorld2D(mouse_pos, gs.camera)

        gs.level.pos, gs.level.size = calculate_resize(
            gs.level,
            world_pos,
            es.resize_start_pos,
            es.resize_level_dir,
        )

        es.tool = .Level
    }

We are going to enable the tool transition from .Level -> .Level_Resize, so we'll adjust the code in the .Level case:

// Modified loop (was at the end of the case's scope)
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)
        }
    }
}

// Everything below here is new

// Reset the rectangle so it doesn't appear if we change tools
// Setting a struct to {} will "zero" all it's fields
// Strings -> "", Numbers -> 0, Pointers -> nil
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)

// Iterate over all directions
for dir in Dir8 {
    resize_rect := calculate_resize_rect(level_rect, dir)
    if rl.CheckCollisionPointRec(mouse_pos, resize_rect) {
        // Use that look-up table
        rl.SetMouseCursor(Resize_Cursor[dir])
        es.resize_rect = resize_rect

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

        // Early exit favours corners first, because we listed them first
        // Makes sure we don't get, for example, East or North when
        // trying to click North-East
        break
    }
}

Alright, now that's out of the way, let's flip back up to the top of editor_update:

editor_update :: proc(gs: ^Game_State) {
    mouse_pos := rl.GetMousePosition() // New

    rl.SetMouseCursor(.DEFAULT) // New

    scroll := rl.GetMouseWheelMove()
    if scroll != 0 && es.tool != .Level_Resize { // New Condition
        mouse_world_pos := rl.GetScreenToWorld2D(mouse_pos, gs.camera)

        gs.camera.zoom = clamp(gs.camera.zoom + scroll * 0.25, 0.25, 8)

        mouse_world_pos_new := rl.GetScreenToWorld2D(mouse_pos, gs.camera)

        gs.camera.target += (mouse_world_pos - mouse_world_pos_new)

        es.resize_rect = {} // New
    }

    if es.tool == .Level || es.tool == .Level_Resize { // New Condition
        if gs.camera.zoom >= 1 {
            es.tool = es.previous_tool
        }
    // ...

I've commented the new lines and conditions.

We reset the cursor every update, then when the logic to check if we are resizing fires, we change it back.

There is no perceptible change to the user as this happens before re-drawing.

We want to disable zooming while resizing - it keeps things simpler.

Below, I'll reproduce the procedures used in full, with comments:

// Calculate the yellow rectangle when hovering over edges or corners
calculate_resize_rect :: proc(level_rect: Rect, dir: Dir8) -> Rect {
    result: Rect
    thickness :: 12
    half_t :: thickness / 2

    switch dir {
    case .N:
        result = {level_rect.x, level_rect.y - half_t, level_rect.width, thickness}
    case .S:
        result = {
            level_rect.x,
            level_rect.y + level_rect.height - half_t,
            level_rect.width,
            thickness,
        }
    case .E:
        result = {
            level_rect.x + level_rect.width - half_t,
            level_rect.y,
            thickness,
            level_rect.height,
        }
    case .W:
        result = {level_rect.x - half_t, level_rect.y, thickness, level_rect.height}
    case .NW:
        result = {level_rect.x - half_t, level_rect.y - half_t, thickness, thickness}
    case .NE:
        result = {
            level_rect.x + level_rect.width - half_t,
            level_rect.y - half_t,
            thickness,
            thickness,
        }
    case .SE:
        result = {
            level_rect.x + level_rect.width - half_t,
            level_rect.y + level_rect.height - half_t,
            thickness,
            thickness,
        }
    case .SW:
        result = {
            level_rect.x - half_t,
            level_rect.y + level_rect.height - half_t,
            thickness,
            thickness,
        }
    }

    return result
}

// Calculate new level position and size based on the current position, size, and resize direction
calculate_resize :: proc(
    level: ^Level,
    world_pos: Vec2,
    start_pos: Vec2,
    dir: Dir8,
) -> (
    new_pos: Vec2,
    new_size: Vec2,
) {
    // This is 40x23 tiles assuming 640x360 w/ 16x16 tiles
    min_width := math.ceil(f32(RENDER_WIDTH) / TILE_SIZE) * TILE_SIZE
    min_height := math.ceil(f32(RENDER_HEIGHT) / TILE_SIZE) * TILE_SIZE

    new_pos = level.pos
    new_size = level.size

    // Always snap to tile sizes
    delta := world_pos - start_pos
    snapped_delta := linalg.round(delta / TILE_SIZE) * TILE_SIZE

    switch dir {
    case .N:
        height_delta := -snapped_delta.y
        new_size.y = max(level.size.y + height_delta, min_height)
        new_pos.y = level.pos.y - (new_size.y - level.size.y)
    case .S:
        new_size.y = max(level.size.y + snapped_delta.y, min_height)
    case .E:
        new_size.x = max(level.size.x + snapped_delta.x, min_width)
    case .W:
        width_delta := -snapped_delta.x
        new_size.x = max(level.size.x + width_delta, min_width)
        new_pos.x = level.pos.x - (new_size.x - level.size.x)
    case .NE:
        height_delta := -snapped_delta.y
        new_size = {
            max(level.size.x + snapped_delta.x, min_width),
            max(level.size.y + height_delta, min_height),
        }
        new_pos.y = level.pos.y - (new_size.y - level.size.y)
    case .NW:
        height_delta := -snapped_delta.y
        width_delta := -snapped_delta.x
        new_size = {
            max(level.size.x + width_delta, min_width),
            max(level.size.y + height_delta, min_height),
        }
        new_pos = {
            level.pos.x - (new_size.x - level.size.x),
            level.pos.y - (new_size.y - level.size.y),
        }
    case .SE:
        new_size = {
            max(level.size.x + snapped_delta.x, min_width),
            max(level.size.y + snapped_delta.y, min_height),
        }
    case .SW:
        width_delta := -snapped_delta.x
        new_size = {
            max(level.size.x + width_delta, min_width),
            max(level.size.y + snapped_delta.y, min_height),
        }
        new_pos.x = level.pos.x - (new_size.x - level.size.x)
    }

    return new_pos, new_size
}

Alright, let's draw the yellow resize rectangle and the new size, add this to the end of editor_draw:

if es.tool == .Level_Resize {
    world_pos := rl.GetScreenToWorld2D(rl.GetMousePosition(), gs.camera)

    preview_pos, preview_size := calculate_resize(
        gs.level,
        world_pos,
        es.resize_start_pos,
        es.resize_level_dir,
    )

    preview_rect := rect_from_pos_size(preview_pos - gs.camera.target, preview_size)
    preview_rect = rect_scale_all(preview_rect, gs.camera.zoom)

    rl.DrawRectangleLinesEx(preview_rect, 2, rl.WHITE)
}

rl.DrawRectangleRec(es.resize_rect, rl.YELLOW)

The reason we want to draw the rectangle either way, is it should be visible in the .Level case. Our resetting the rect using {} makes sure this isn't visible at the wrong times.

To make sure we draw the one_screen_rect during the resizing, we'll have to modify the editor_draw procedure a bit:

// This is near the top of the procedure
if (place || remove) {
    rect := rect_from_coords_any_orientation(es.area_begin, es.area_end)
    // I have created this rect_pos_add utility procedure
    // As we are commonly modifying the x and y components of Rect
    rect = rect_pos_add(rect, -gs.camera.target)
    rect = rect_scale_all(rect, gs.camera.zoom)

    if es.tool == .Level || es.tool == .Level_Resize { // New
        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 { // New clause, moved from above
        rl.DrawRectangleLinesEx(rect, 4, place ? rl.WHITE : rl.RED)
    }
}

The utility procedure:

rect_pos_add :: #force_inline proc(rect: Rect, v: Vec2) -> Rect {
    return Rect{rect.x + v.x, rect.y + v.y, rect.width, rect.height}
}

In editor_place_level, we want to change the current level to the newly placed one:

// ...
append(&gs.levels, level)
gs.level = level_from_id(gs.levels[:], level.id)

Finally, we want to fix the spike problem and make sure we can draw tiles and spikes in different levels (even if we can't save them yet).

In is_tile_at_pos:

// Subtract l.pos
rect := rect_from_pos_size(pos_from_coords(coords) - l.pos, TILE_SIZE)

Since we are storing Spikes relative to the Level position, we have to modify code where we use spike.collider.

In main.odin and player.odin, look for spike.collider and change the code to rect_pos_add(spike.collider, gs.level.pos) - that will modify the relative position to be a world position again.

That's it for resizing!

Next up, we'll be creating an Undo/Redo system using a Command Buffer.