Game Dev Log: Camera & Focus
A big piece of building a tactical RPG is movement on the map during a battle, and players use a cursor to navigate around. As the battle progresses different units are completing actions that can affect units all across the map, and the player needs to be able to see all of it. The problem? Map sizes will vary across a wide spectrum. Interior small maps, long tunnel maps, tall mountainside maps, sweeping plains….you get it. The point? The entirety of a map will very often not be visible inside of the rendered viewport, and you end up with your map working like below.
How do we solve that? We need a system that mimics a camera and a focus point to allow us to pan around the map. This will enable us to make every area of a map visible at the time we need it to by building in a set of events and rules for camera focus and movement. Once we have that built out map will work like below.
There are a LOT of events during a battle that will trigger a change in camera focus, but for this log I'm going to just cover the most basic trigger and will come back and cover others as I build them.
To get started I need to add the camera data to the game state
type GameState { GameState( camera: Camera ) } type Camera { Camera( focus: Coord width: Int height: Int ) }
The focus is the current location we want centered in our viewport, and the width and height are what we use to define that.
As I already mentioned there is a long list of events that would trigger the need for the camera focus to change.
- Ranged attack results
- Turn transitions
- Dialogue sequences
- Multi-unit status changes
in this log we are going to just cover moving the cursor around the map manually to explore and gather information about enemy units.
This should give us all we need to setup the system and the functions we need to handle all of the unique focus changes later on.
We have already implemented cursor movement in the update loop log. So let's start with our previous implementation and and build on top of it.
type Cursor { Cursor( coords: Coord ) } type Coord { Coord(x: Int, y: Int, z: Int) } type Event { UserMovedCursor(Direction) } type Direction { Up Down Left Right } fn apply_event(game_state: GameState, event: Event) { case event { MoveCursor(direction) -> { let coords = direction |> coord.move(game_state.cursor.coord, game_state.map) GameState( ..game_state, cursor: { ..game_state.cursor, coords: coords, } ) } } }
A lesson learned
Since writing the update loop log and the basic functionality to move the cursor around I have needed to make some adjustments to the original. We are going to move our cursor movement logic into it's own function.
pub fn move_cursor() { let position_key = coord.move_in_direction(direction) case tile.check_passability(game_state.map.tiles, position_key) { Ok(t) -> { let position = coord.set_elevation(position_key, t.elevation) GameState( ..game_state, camera:, cursor: cursor.Cursor(..game_state.cursor, position:), ) } Error(_) -> game_state } }
Our new move cursor accounts for the passability of the tile that the cursor is trying to move to. For now we handle that by saying, if the tile is not passable then the cursor does nothing.
This may change later. I may want logic to be able to jump over impassable tiles or empty tiles if there are tiles on the other side, but for now it feels like the right starting place that the cursor will only be able to move in the way a unit can.
Okay NOW we have our starting point. The first thing we will need is to have some method for setting up a boundary. Why? As the cursor moves around the map there needs to be a bounding box so that when the boundary is crossed we reposition the camera focus to show more of the map in the direction that the cursor is moving.
In our case we want to always move the focus when the cursor moves more than 2 tiles in the Y or 3 in the X.
const bound_x = 3 const bound_y = 2 fn in_bounds(camera: Camera, target: Coord) { let dx = target.x - camera.focus.x let dy = target.y - camera.focus.y let dz = target.z - camera.focus.z let screen_x = Int.absolute_value(dx - dy) let screen_y = Int.absolute_value(dx + dy + dz) screen_x < bound_x && screen_y < bound_y } fn set_focus(camera: Camera, focus: Coord) { Camera(..camera, focus: focus) }
We take the same equation we discussed in the render loop log for converting our Coord
type to an isometric Vector
, but here we are running a check to see if the absolute_value
(aka it makes negative numbers the positive version) falls outside of our boundary we have defined.
Now we just take this function and apply it into our move cursor function.
pub fn move_cursor() { let position_key = coord.move_in_direction(direction) case tile.check_passability(game_state.map.tiles, position_key) { Ok(t) -> { let position = coord.set_elevation(position_key, t.elevation) let camera = case // [!code ++] camera.in_bounds(game_state.camera, position) // [!code ++] { // [!code ++] True -> game_state.camera // [!code ++] False -> { // [!code ++] game_state.camera.focus // [!code ++] |> coord.move_in_direction(direction) // [!code ++] |> coord.set_elevation(t.elevation) // [!code ++] |> camera.set_focus(game_state.camera, _) // [!code ++] } // [!code ++] } // [!code ++] GameState( ..game_state, camera:, // [!code ++] cursor: cursor.Cursor(..game_state.cursor, position:), ) } Error(_) -> game_state } }
If our new cursor position is outside the boundary we adjust the camera focus Coord
in the same direction as our cursor Coord
.
And that's it. We now have a camera viewport, a center focus, and the method to move that focus around triggered by the cursor moving outside the boundary of focus we have specified.
As I implement more features in the game engine I will come back and update the sections below with how the camera and focus system will work for that context.
Turn Change
Coming sometime...
Unit Movement
Coming sometime...
Attack Selection
Coming sometime...
Attack Confirmation
Coming sometime...
Attacks on distant units
Coming sometime...
Dialogue Sequences
Coming sometime...
See ya next time! 👋
I hope you enjoyed
There is a lot more coming...
If you want to get updates when I publish new guides, demos, and more just put your email in below.