Camera Controls

The RTS camera supports nine ways to move or restore the view. The same control definitions feed the in-battle legend and field manual, while automated tests cover the underlying geometry and input rules. This page explains the complete control model and ends with the manual regression pass needed for layouts and interactions that unit tests cannot fully reproduce.

The nine camera controls

Control How Implementation
Edge scroll Push the cursor into a screen edge ui/qml/Main.qml, edge_scroll_overlay
Keyboard pan Arrow keys or WASD; Shift for a double step rts.camera_pan_* in ui/input_bindings.cpp
Drag pan Hold the right mouse button and drag ui/qml/GameView.qml, renderArea mouse area
Zoom Mouse wheel, or PgUp / PgDown rts.camera_zoom_*
Rotate Q / E; Shift for a larger step rts.camera_rotate_*
Tilt Ctrl+Up / Ctrl+Down; Shift for a larger step rts.camera_tilt_*
Minimap jump Left-click or drag the minimap ui/qml/HUDTop.qml, minimapMouse
Follow Button in the top bar ui/qml/HUDTop.qml
Reset Home, or the Reset button in the top bar rts.camera_reset

ui/qml/CameraGuide.qml is the single descriptive list used by every help surface: the compact in-battle legend in CameraLegend.qml, the Camera tab in HelpPanel.qml, and the live edge-scroll status shown by both.

Adding or renaming a camera control should therefore start in CameraGuide.qml rather than by duplicating text in several interfaces.

Pan, rotate, and tilt are different operations

The three basic camera motions should remain distinct in both code and player-facing language:

Camera::orbit(yaw, pitch) remains the lower-level two-axis primitive used by both rotation and tilt.

Tilt is bound to Ctrl+Up and Ctrl+Down. R is reserved for the commander rally action, avoiding a contextual collision between camera motion and rally placement.

The pitch sign convention

CameraService::tilt accepts a direction where positive means raise the camera, but it passes the opposite sign to the lower-level orbit function.

That inversion is intentional. The camera's pitch describes the elevation of the view direction: a near-overhead view is around -85, while a near-level view is around -5. Raising the camera therefore moves the numerical pitch downward.

Tests assert the camera's height above its target rather than reading pitch directly, which protects the intended user-facing direction from sign confusion.

Two chords per command

InputBindings stores a primary and alternate chord for each action through InputBindings::Slot. This is how keyboard panning can support both the arrow keys and WASD as first-class bindings.

Alternate bindings are persisted under the action ID with an |alt suffix, rebound from their own button in Settings → Controls, and resolved by actions_for_key alongside the primary chord.

Using WASD for camera pan required moving conflicting RTS commands: Attack uses C and Stop uses Z. Within one context, a key must have one unambiguous meaning.

Saved keymaps survive action renames. Migration maps stored chords according to behavior, not merely old labels. For example, the former rts.camera_orbit_left action actually lowered the camera, so its binding migrates to rts.camera_tilt_down. Preserving the physical key while reversing the action would be worse than dropping the binding.

Reset framing follows the map

Every map authors a camera view appropriate to its scale. Small maps and large battlefields therefore need different reset distances.

Game::reset_framing in game/camera_framing.h derives reset framing from the map's authored camera:

Authored tilt and yaw are preserved.

GameConfig::camera_reset_framing applies this rule and falls back to the built-in default only when no map is loaded.

The same framing function is consumed by:

Sharing one calculation prevents opening framing and reset framing from drifting apart.

Camera speed settings

Keyboard pan, wheel zoom, and Q / E rotation each have a user-adjustable speed scale from one quarter to three times the designed pace.

Values persist through App::Core::UserSettings and are propagated to the atomics in game/render_bridge/camera_speeds.h. CameraService::move, CameraService::zoom, and CameraService::yaw read those values on every call.

Drag pan, minimap jumps, and tilt remain unscaled because their displacement is already determined by the gesture itself. Applying another multiplier would make the same physical motion encode two layers of speed.

Edge-scroll geometry

The edge-scroll calculation lives in ui/edge_scroll.cpp so it can be tested independently by tests/ui/edge_scroll_test.cpp.

Its important rules are:

Scaling by interface size keeps the target usable on high-resolution displays. A 26-logical-pixel band should not collapse into a tiny physical sliver while every other UI target doubles at 200% scale.

Horizontal and vertical edges deliberately use the same geometry and response curve so top and bottom scrolling feel as strong as left and right scrolling.

Where the edge-scroll cursor comes from

EdgeScroll.cursorIn(item) reads QCursor::pos() and maps it into the edge-scroll overlay. A 16 ms timer in the overlay polls that position.

The system intentionally does not depend on the overlay's own hover events.

The full-screen edge overlay sits below the HUD (z: 0.5 versus the HUD's z: 1) so HUD controls retain hover states and tooltips. In Qt, hover delivery stops at the first item that accepts the event. An overlay above the HUD would swallow HUD interaction; an overlay below it would fail to receive pointer movement whenever a HUD element was under the cursor.

Polling the platform cursor solves both sides of the problem: HUD controls receive their own events, while edge scrolling can still see every physical screen edge.

The minimap suppresses edge scroll explicitly

The minimap is itself a camera-control surface, so edge scrolling must not compete with it.

Suppression is based on state rather than a hand-tuned geometric margin:

This lets the edge band be sized for usability rather than constrained by a fragile one-pixel clearance around the minimap.

What suppresses edge scrolling

mainWindow.edge_scroll_disabled is a derived property:

readonly property bool edge_scroll_disabled: gameViewItem.camera_pan_active
    || !mainWindow.active || mainWindow.overlay_active
    || hud.commander_rpg_mode || hud.minimap_drag_active

camera_pan_active is also derived from live input state:

renderArea.key_pan_count > 0 || renderArea.mouse_pan_active

Keeping these values derived prevents them from becoming latched when input is interrupted. For example, a modal opening or the application losing focus during a right-drag must not leave edge scrolling disabled for the rest of the session.

Polling the platform cursor means the overlay no longer learns suppression indirectly by losing hover events. Every state that should block edge scrolling therefore needs to be represented explicitly in the derived condition.

The overlay timer is likewise controlled by a live condition rather than started only by an enter event. If a panel closes while the cursor is already resting at an edge, scrolling should resume without requiring the pointer to leave and re-enter.

HUD zones and pointer ownership

Two similar-sounding rules serve different purposes and should not be merged.

in_hud_zone() blocks world hover

edge_scroll_overlay.in_hud_zone() decides where world hover stops. It reads hud.top_panel_height and hud.bottom_panel_height, using the exact snake-case property names.

Those full-width zones prevent world units and placement previews from responding to a pointer that is actually interacting with the HUD.

blocks_edge_scroll() blocks camera scrolling

HUD.blocks_edge_scroll() is much narrower and covers only the minimap.

Camera edge scrolling intentionally continues behind the top and bottom HUD panels so every physical screen edge remains useful. Folding the world-pointer and camera-scroll zones together would make the top and bottom edges dead.

The overlay accepts Qt.NoButton and sits beneath the HUD, so it does not consume clicks intended for HUD controls.

Manual regression checklist

Automated tests cover geometry, binding behavior, and legend contents, but several interactions depend on real windowing, focus, scale, and pointer behavior. Run this checklist when changing camera controls or edge scrolling.

Layouts to cover

  1. Windowed, 1280×720, interface scale 100%
  2. Fullscreen, native resolution, interface scale 100%
  3. High resolution (2560×1440 or 3840×2160), interface scale 100%
  4. Interface scale 150% and 200% through Settings → Accessibility → Interface size
  5. Right-to-left language through Settings → Language → العربية

Edge-scroll checks for every layout

Interaction checks

First-run legend and help

Bindings and framing

The camera system is easiest to maintain when every movement has one name, every help surface reads one source of truth, and edge scrolling is controlled by explicit geometry and state rather than incidental event delivery.