PVG
43. Edit Mode First Pass — Program Video Games

43. Edit Mode First Pass

In order to author content in an easier fashion, we'll add a simple "Edit Mode". In Edit Mode, we can fly around the scene and click somewhere to select a point in 3D space.

The reason this is a First Pass, is because after using the tool, it's clear that we can do better with some consideration--more on that later.

It's important to show the first pass rather than skip straight to the "better" version; so you can judge for yourself which workflow you prefer.

Keeping track of just a few pieces of state will suffice for this tool:

// new file: game/editor.odin
package main

import "vendor:raylib"

EDITOR_MOVESPEED :: 3
EDITOR_SENSITIVITY :: 0.003

// Add instance of this to Game_State as 'editor'
Editor_State :: struct {
    is_active:      bool,
    free_mouse:     bool,
    selected_point: Vec3,
}

We're going to have a fly-mode in which the camera is a first person and we can fly around. When free_mouse is false then we can no longer move the camera with the mouse, but we get access to the cursor so we can select a point.

Before we create the update and render procedures for the editor, let's update the Input_State to record when our keys are released:

// Add these fields to Input_State
debug_edit_mode_released: bool,
debug_toggle_cursor_lock_released: bool,

Now in input_update, we'll update these values:

is.debug_edit_mode_released = rl.IsKeyReleased(.F5)
is.debug_toggle_cursor_lock_released = rl.IsKeyReleased(.F)

Each frame in which the editor is active, we call editor_update:

// in game_update:

// Update the last argument to console_handle_input
// to take the selected_point rather than the
// player's position
if gs.console.is_active {
    if !console_handle_input(&gs.console, gs.scene.scene_name, gs.editor.selected_point) {
        return
    }
}

if gs.input.debug_edit_mode_released {
    gs.editor.is_active = !gs.editor.is_active
    if gs.editor.is_active {
        rl.HideCursor()
    }
}

if gs.editor.is_active {
    editor_update(
        &gs.editor, // update free mouse
        gs.input, // needs to know inputs
        &gs.rendering.camera, // fly-camera
        gs.assets.models[gs.scene.scene_name], // raycast
        gs.time.delta, // fly-camera
    )
    return
}

The editor update procedure requires access to some relevant state, some read-only and some passed by pointer for mutation.

editor_update :: proc(
    es: ^Editor_State,
    is: Input_State,
    camera: ^raylib.Camera,
    model: raylib.Model,
    dt: f32,
) {
    if is.debug_toggle_cursor_lock_released {
        es.free_mouse = !es.free_mouse
        if es.free_mouse {
            rl.EnableCursor()
        } else {
            rl.DisableCursor()
        }
    }

    if es.free_mouse {
        if is.clicking {
            es.selected_point = max(f32)
            // fire ray into scene geometry
            // if hit, selection is set
            ray := rl.GetScreenToWorldRay(is.cursor, camera^)
            for i in 0 ..< model.meshCount {
                hit := rl.GetRayCollisionMesh(ray, model.meshes[i], raylib.Matrix(1))
                if hit.hit {
                    es.selected_point = hit.point
                    break
                }
            }
        }
    } else {
        rl.CameraYaw(camera, -is.move.x * EDITOR_SENSITIVITY, false)
        rl.CameraPitch(camera, -is.move.y * EDITOR_SENSITIVITY, true, false, false)
    }

    forward := rl.GetCameraForward(camera)
    right := rl.GetCameraRight(camera)
    up := rl.GetCameraUp(camera)

    move: Vec3
    if is.up do move += forward
    if is.down do move -= forward
    if is.left do move -= right
    if is.right do move += right
    if is.debug_hoist do move += up
    if is.debug_lower do move -= up

    if rl.Vector3Length(move) > 0 {
        move = rl.Vector3Normalize(move) * EDITOR_MOVESPEED * dt
        camera.position += move
        camera.target += move
    }
}

Here's the entire game_render procedure. We draw the edit mode before the console so the console shows up on top:

@(export)
game_render :: proc(gs: ^Game_State) {
    switch gs.scene_kind {
    case .MAIN_MENU:
        render_main_menu()
    case .FIELD, .BATTLE:
        render_field()
    }

    // new
    if gs.editor.is_active {
        editor_render(&gs.editor, gs.rendering.camera, gs.assets.models[gs.scene.scene_name])
    }

    if gs.console.is_active {
        console_render(&gs.console, gs.window_width)
        debug_text_flush()
    }
}

And the editor_render procedure itself is straightforward:

editor_render :: proc(es: ^Editor_State, camera: raylib.Camera, model: raylib.Model) {
    // Clear what's already rendered and produce
    // a wireframe view of the scene
    rl.ClearBackground(rl.BLACK)
    rl.BeginMode3D(camera)
    rl.DrawModelWires(model, {}, 1, rl.WHITE)
    rl.DrawSphereWires(es.selected_point, 0.2, 5, 5, rl.YELLOW)
    rl.EndMode3D()
}

That's all for the first pass of the editor.

Try it out, see how it feels.

The current procedure is:

  1. F5 to start Edit Mode.
  2. F to enable the cursor.
  3. Click somewhere to move the selected point.
  4. Grave/Tilde to open the console.
  5. Type door DoorName to spawn a door.
  6. Type load flat to reload the scene.
  7. Door should be visible as blue bounding box.
  8. If you want to link, same as before.

After using this a few times, you may come to realise what I did. It doesn't feel great.

It's both imprecise with the placement and unwieldy with the console commands.

In the upcoming lessons, we'll take a step back, ask ourselves what our specific game needs and then produce custom tools tailored specifically for our JRPG.