Commit
Theria
feat: interactive minimap — right-click to move, left-click to pan camera
modified CHANGELOG.md
@@ -61,8 +61,12 @@ protocol version.
- A **minimap** in the bottom-right corner: a scaled plan of the arena with the lanes and river as a
backdrop and live dots for every unit — your team always, enemies only where your team has vision,
so it respects the same fog of war as the main view. Your own hero is ringed so it is easy to find.
*(First pass: display-only — clicking it does nothing yet; pings and click-to-move-camera are a
later slice.)*
- The minimap is **interactive**: **right-click** the plan to send your hero there — the same
auto-pathed, wire-reconciled move order a world right-click makes, only chosen off the map so you
can command clear across the arena — and **left-click** (or left-drag) to pan the camera there for
a free look, snapped back onto your hero with **Space**. Clicking the plan no longer leaks a stray
world order to the ground under the card. *(Pings travel the wire, so they ride a later netcode
pass.)*
## [v0.3.4] — 2026-06-16
modified README.md
@@ -130,7 +130,9 @@ hurt and otherwise firing the reachable ability of their form. Cast its abilitie
form (shown by the ring around it, white or amber), each form a different set of
abilities drawing on its own resource (the bar under the health bar). Each hero appears as
its own animal — a placeholder low-poly model washed in its team colour — so your three
squadmates read apart by species at a glance. Abilities are
squadmates read apart by species at a glance. The corner **minimap** is live: **right-click** it to
send your hero across the map, **left-click** (or drag) to pan the camera for a free look, and press
**Space** to snap back to your hero. Abilities are
cast in a single-machine or hosted match; a joined client moves but does not yet
cast.
modified src/client/main.gd
@@ -77,18 +77,6 @@ const AMBIENT_COLOR := Color(0.52, 0.56, 0.64)
const AMBIENT_ENERGY := 0.5
const LIGHT_ENERGY := 1.1
## Camera follow-rig: a close, LoL-style view trailing the hero. Height and the backward
## offset set the look angle (atan(height / back) ~= 67°) and the zoom (~950 units off the
## hero); eyeball tuning knobs for the windowed playtest.
const CAM_HEIGHT := 880.0
const CAM_BACK := 370.0
## How far the camera closes the gap to its target each tick (0..1) — a smooth trail rather
## than a hard 1:1 lock, so a direction change eases instead of snapping the whole view. At
## 60 Hz, 0.2/tick settles in ~0.25 s: tight enough to stay on the hero, soft enough to take
## the jerk out of a sharp turn or a respawn. Eyeball-tunable alongside the height/back above.
const CAM_LERP := 0.2
## Billboarded HP/resource bars + status label floating above a unit (world units). Every
## body's HP bar floats HERO_BAR_GAP above its own model's measured top (animals, creeps, and
## structures all vary in height), the resource bar a step below and the status label above.
@@ -188,14 +176,9 @@ var _pending_inputs: Array[Dictionary] = []
## Each view holds the node refs `_update_view` mutates — `{root, body, ring?, hp_node,
## hp_fg, res_node?, res_fg?, status?}` — so a unit's nodes are built once, never rebuilt
## while it lives. Filled in `_build_world` / `_sync_world`; see the presentation region.
var _camera: Camera3D = null
## The field point the camera trails. Set to the hero each tick it exists and held at its
## last value while the hero is gone (dead, pre-spawn), so the view eases to a rest on the
## last sighting instead of snapping to the arena centre. Seeded at the arena centre.
var _cam_target: Vector2 = Vector2.ZERO
## False until the camera has been placed once: the first placement snaps (no glide-in from
## the world origin), every one after eases toward the target by CAM_LERP.
var _cam_ready: bool = false
## The follow-rig — the Camera3D, its eased target, and the free-look state — lifted into its own
## class to keep this file under the line cap. Built in `_build_world`, trailed each tick.
var _cam: MatchCamera = null
var _ground: MeshInstance3D = null
## The shared map-decor material (JungleDecor); fed the hero's world position each frame so the
## growth over the player's hero fades and the character stays visible.
@@ -642,13 +625,10 @@ func _build_world() -> void:
add_child(_ground)
MapView.build(self)
_foliage_mat = JungleDecor.build(self)
_camera = Camera3D.new()
_camera.far = 20000.0
_camera.current = true
add_child(_camera)
_cam_target = MapData.BOUNDS.get_center()
_point_camera(_cam_target)
_player_input = PlayerInput.new(_camera)
_cam = MatchCamera.new(Callable(self, "_world"))
add_child(_cam.node)
_cam.place(MapData.BOUNDS.get_center())
_player_input = PlayerInput.new(_cam.node)
_move_marker = MoveMarker.new()
add_child(_move_marker)
# The screen-space UI (HUD, kill feed, chat, death screen) draws over the zoomed game camera,
@@ -657,6 +637,10 @@ func _build_world() -> void:
if not _is_headless():
_overlays = MatchOverlays.new()
add_child(_overlays)
# The minimap projects a click back to a world point and emits it; wire one to the player's
# order pipeline and one to the camera pan, so the panel itself owns no game state.
_overlays.minimap.order_requested.connect(_on_minimap_order)
_overlays.minimap.look_requested.connect(_on_minimap_look)
_fog = FogOverlay.build(self)
@@ -694,29 +678,16 @@ func _sync_world() -> void:
_update_overlays(state)
## Trails the camera on the player's hero — a fixed height and pitch, eased toward it each
## tick (CAM_LERP) rather than locked, so the view glides. With no hero (none spawned yet,
## or gone from a snapshot) the target holds its last value, so the camera rests where the
## hero last stood instead of jumping to the arena centre; seeded there for the menu backdrop.
## Trails the camera on the player's hero — re-pinned to it each tick it exists, held at its last
## sighting while it is gone (dead, pre-spawn), unless free-look holds a minimap-panned point that
## the re-centre key (SPACE, ignored while typing) drops. MatchCamera owns the easing; this hands it
## the hero's point and feeds the map decor the framed spot so growth over it fades to its outline.
func _follow_camera(state: SimState) -> void:
var hero := _camera_focus(state)
if hero != null:
_cam_target = hero.position
_point_camera(_cam_target)
# Tell the map decor where the hero is, so any growth standing over it fades to its outline.
var recenter := Input.is_physical_key_pressed(KEY_SPACE) and not _chat_typing()
_cam.follow(hero.position if hero != null else Vector2.ZERO, hero != null, recenter)
if _foliage_mat != null:
_foliage_mat.set_shader_parameter("hero_pos", _world(_cam_target))
## Eases the camera toward a pose above and behind a field point, looking down at it. The
## first placement snaps; each tick after closes CAM_LERP of the remaining gap, so the view
## trails smoothly. look_at always aims at the live focus, so the hero stays framed mid-glide.
func _point_camera(focus: Vector2) -> void:
var ground := _world(focus)
var goal := ground + Vector3(0.0, CAM_HEIGHT, CAM_BACK)
_camera.position = goal if not _cam_ready else _camera.position.lerp(goal, CAM_LERP)
_cam_ready = true
_camera.look_at(ground)
_foliage_mat.set_shader_parameter("hero_pos", _world(_cam.target()))
## The unit the camera trails: the player's own hero. LOCAL drives `_hero_id`; a CLIENT
@@ -729,6 +700,18 @@ func _camera_focus(state: SimState) -> SimEntity:
return null
## A right-click on the minimap: issue the player's move/attack order at that world point, through
## the same pipeline a world right-click uses, so it auto-paths and reconciles over the wire.
func _on_minimap_order(point: Vector2) -> void:
_player_input.order_at(_visible_state(), _player_hero_entity(), _player_team(), point)
## A left-click (or left-drag) on the minimap: pan the camera there for a free look, holding it off
## the hero until the player re-centres.
func _on_minimap_look(point: Vector2) -> void:
_cam.look_at_point(point)
## Reconciles the whole screen-space UI each tick: the HUD, kill feed, and death screen all
## read off the player's focus hero (the camera's hero — sim-driven in LOCAL/HOST, read out of
## the snapshot on a CLIENT), so every overlay shows exactly what the player is driving. The
@@ -972,10 +955,17 @@ func _hero_color(entity: SimEntity) -> Color:
## fires its QWER bind.
func _sample_player_input() -> InputCommand:
return _player_input.sample(
_visible_state(), _player_hero_entity(), _player_team(), _sim != null and not _chat_typing()
_visible_state(), _player_hero_entity(), _player_team(),
_sim != null and not _chat_typing(), _pointer_over_minimap()
)
## Whether the cursor sits over the minimap this tick — the world right-click order is skipped when
## it does, so the panel's own order is the only one (no stray move under the card). False headless.
func _pointer_over_minimap() -> bool:
return _overlays != null and _overlays.minimap.contains_pointer()
## The state the player acts on: the live sim where this client owns authority (LOCAL/HOST),
## or the latest snapshot on a pure CLIENT. Null before one exists.
func _visible_state() -> SimState:
added src/client/match_camera.gd
@@ -0,0 +1,85 @@
class_name MatchCamera
extends RefCounted
## The match follow-rig: a close, LoL-style camera trailing the player's hero, lifted out of
## `main.gd` to keep that file under the line cap (the same reason PlayerInput, MoveMarker, and the
## overlays were lifted). It owns the `Camera3D`, its eased target, and the free-look state — a
## minimap left-click pans the view off the hero, and the re-centre key drops it back on.
##
## It holds no sim knowledge: each tick the driver hands it the hero's field point (or signals there
## is none yet) and whether the re-centre key is down, and it eases the camera. The sim→3D mapping
## comes in as a Callable so the rig and the driver agree on one projection.
## Follow-rig geometry: height and the backward offset set the look angle (atan(height / back) ~=
## 67°) and the zoom (~950 units off the hero); eyeball tuning knobs for the windowed playtest.
const HEIGHT := 880.0
const BACK := 370.0
## How far the camera closes the gap to its target each tick (0..1) — a smooth trail rather than a
## hard 1:1 lock, so a direction change eases. At 60 Hz, 0.2/tick settles in ~0.25 s.
const LERP := 0.2
## The Camera3D itself — the driver adds it to the scene and hands it to PlayerInput for the ground
## ray. Made current so it is the view the moment the match opens.
var node: Camera3D
## The field point the camera trails. Set to the hero each tick it exists and held at its last value
## while the hero is gone (dead, pre-spawn), so the view rests on the last sighting rather than
## snapping to the arena centre. Seeded at the arena centre for the menu backdrop.
var _target: Vector2
## False until the camera has been placed once: the first placement snaps (no glide-in from the
## world origin), every one after eases toward the target by LERP.
var _ready: bool = false
## Free-look: true while the player has panned the camera off their hero with a minimap left-click,
## so `follow` holds the panned target instead of re-pinning it to the hero. Cleared by the
## re-centre key, back to following the hero.
var _free: bool = false
## The sim→3D ground projection, shared with the driver so both agree on one mapping.
var _to_world: Callable
func _init(to_world: Callable) -> void:
_to_world = to_world
node = Camera3D.new()
node.far = 20000.0
node.current = true
## Snaps the camera onto a point with no glide — the build-time placement that frames the arena
## centre for the menu backdrop before any hero exists. Call once the node is in the tree (look_at
## needs a global transform); every placement after eases.
func place(point: Vector2) -> void:
_target = point
_point()
## The field point the camera is framing this tick — the hero, or the free-look point. The driver
## reads it to fade the foliage standing over the framed spot.
func target() -> Vector2:
return _target
## Pans the camera to a point chosen off the minimap and holds it there (free look) until the player
## re-centres on their hero.
func look_at_point(point: Vector2) -> void:
_target = point
_free = true
## Trails the hero for this tick: re-pins to its field point (held at the last while the hero is
## gone), unless free-look holds a panned point. `recenter` — the re-centre key — drops free-look
## back onto the hero. `has_focus` is false before a hero spawns or once it leaves the world.
func follow(focus: Vector2, has_focus: bool, recenter: bool) -> void:
if _free and recenter:
_free = false
if not _free and has_focus:
_target = focus
_point()
## Eases the camera toward a pose above and behind the target, looking down at it. The first
## placement snaps; each tick after closes LERP of the remaining gap, so the view trails smoothly.
func _point() -> void:
var ground: Vector3 = _to_world.call(_target)
var goal := ground + Vector3(0.0, HEIGHT, BACK)
node.position = goal if not _ready else node.position.lerp(goal, LERP)
_ready = true
node.look_at(ground)
added src/client/match_camera.gd.uid
@@ -0,0 +1 @@
uid://d3gaqpm12xveg
modified src/client/minimap.gd
@@ -1,5 +1,10 @@
class_name Minimap
extends Control
## A right-click on the plan: issue the player's move/attack order at this world point.
signal order_requested(world_point: Vector2)
## A left-click or left-drag on the plan: pan the camera to this world point (free look).
signal look_requested(world_point: Vector2)
## The corner minimap: a scaled top-down plan of the arena in the bottom-right, drawn over the
## game camera like the rest of the match UI. It shows the static terrain (lanes, river, the map
## frame) as a backdrop and the live units as dots — friendly always, enemies only where the
@@ -11,10 +16,17 @@ extends Control
## the plan cannot drift from the played map. Built in code on the UiTheme palette, like every
## other overlay; no `.tscn`, no editor pass.
##
## v1 is display-only: clicking the minimap does nothing (movement is a right-click in the world,
## and right-clicking over the minimap still issues a world move — the click-to-ping and
## click-to-move-camera interactions are a later slice). It does not capture the mouse, so it never
## steals a click from the HUD beside it.
## Interactive: a click on the plan reads as a command at the world point under it (the inverse of
## `map_point`). A **right-click** issues the player's move/attack order there — the same order a
## world right-click makes, so it auto-paths and reconciles over the wire identically, only chosen
## off the map so the hero can be sent clear across the arena. A **left-click** (or a left-drag,
## scrubbing) pans the camera there for a free look, until the player re-centres on their hero. The
## panel captures the pointer within its own square, so a click on the plan no longer leaks a stray
## world order under the card; clicks elsewhere still fall through to the world untouched.
##
## It owns no movement or camera state itself — it only projects the click back to a world point and
## emits it; the driver wires `order_requested`/`look_requested` to PlayerInput and the camera. Ping
## markers are a later slice (they travel the wire, so they ride their own netcode pass).
## The square plan's side and its inset from the screen corner, in pixels.
const SIZE := 280.0
@@ -63,8 +75,30 @@ func _ready() -> void:
offset_top = -(SIZE + MARGIN)
offset_right = -MARGIN
offset_bottom = -MARGIN
# Display-only: never capture the pointer, so a click falls through to the world/HUD as before.
mouse_filter = Control.MOUSE_FILTER_IGNORE
# Capture the pointer over the plan so a click here is a map command, not a stray world order
# under the card. Only this square stops the pointer; clicks elsewhere fall through as before.
mouse_filter = Control.MOUSE_FILTER_STOP
## Routes a click on the plan to a world command. A right-click emits a move/attack order, a
## left-click (or a left-drag, so the camera scrubs as the pointer moves) a camera look — both at
## the world point under the cursor, projected back through `unmap_point`. The event position is
## panel-local already, so it maps straight into the plan square.
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
var at := unmap_point(event.position, size)
if event.button_index == MOUSE_BUTTON_RIGHT:
order_requested.emit(at)
elif event.button_index == MOUSE_BUTTON_LEFT:
look_requested.emit(at)
elif event is InputEventMouseMotion and (event.button_mask & MOUSE_BUTTON_MASK_LEFT) != 0:
look_requested.emit(unmap_point(event.position, size))
## Whether the pointer is over the plan right now — the driver reads this to suppress the world
## right-click order while the cursor sits on the minimap, so the panel's own order is the only one.
func contains_pointer() -> bool:
return get_global_rect().has_point(get_global_mouse_position())
## Reconciles the plan against this tick's world. `state` is the world to draw, `focus` the player's
@@ -92,6 +126,14 @@ static func map_point(p: Vector2, panel_size: Vector2) -> Vector2:
return Vector2(n.x * panel_size.x, n.y * panel_size.y)
## The inverse: a panel pixel back to the sim-field point under it, so a click on the plan becomes a
## world command. Static and pure, the exact inverse of `map_point` — a round trip is the identity.
static func unmap_point(panel_point: Vector2, panel_size: Vector2) -> Vector2:
var bounds := MapData.BOUNDS
var n := Vector2(panel_point.x / panel_size.x, panel_point.y / panel_size.y)
return bounds.position + n * bounds.size
func _draw() -> void:
var panel := Rect2(Vector2.ZERO, size)
draw_rect(panel, PANEL_BG)
modified src/client/player_input.gd
@@ -54,14 +54,19 @@ func _init(camera: Camera3D) -> void:
## before one spawns); `team` is their team. `cast_abilities` is true only with a local
## authoritative sim (LOCAL/HOST) — a pure CLIENT casts nothing yet, as the wire carries
## movement alone.
func sample(state: SimState, hero: SimEntity, team: int, cast_abilities: bool) -> InputCommand:
func sample(
state: SimState, hero: SimEntity, team: int, cast_abilities: bool, pointer_over_ui := false
) -> InputCommand:
var command := InputCommand.new()
if hero != null and hero.is_dead():
# Down and behind the death screen: ignore input and drop any standing order, so the
# hero respawns idle at base rather than marching off toward a pre-death click.
_halt()
return command
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
# Skip the world right-click order while the cursor is over the minimap (or other UI): that
# panel issues its own order from the click, and the camera ray under the card would otherwise
# fire a second, garbage move to wherever it pierces the ground.
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) and not pointer_over_ui:
_issue_order(state, hero, team, _mouse_world_point())
if Input.is_physical_key_pressed(STOP_KEY):
_halt()
@@ -71,6 +76,13 @@ func sample(state: SimState, hero: SimEntity, team: int, cast_abilities: bool) -
return command
## Issues a move/attack order at a world point chosen off the minimap rather than the cursor — the
## same order a world right-click makes (auto-pathed, wire-reconciled), only the point comes from
## the plan. The driver projects the minimap click and hands the sim point straight in.
func order_at(state: SimState, hero: SimEntity, team: int, point: Vector2) -> void:
_issue_order(state, hero, team, point)
## Resolves a right-click into an order: clicking on an enemy attacks it (the hero closes to
## attack range, then the combat step strikes it), clicking open ground walks there. One button
## both moves and engages — lighter than LoL's separate attack-move key.
modified test/unit/test_minimap.gd
@@ -26,3 +26,39 @@ func test_build_and_update_run_over_a_real_world() -> void:
# Before a hero spawns the driver passes a null focus — must not error.
minimap.update(sim.state, 0, null, [Color.RED, Color.BLUE], true)
assert_eq(minimap.custom_minimum_size, Vector2(Minimap.SIZE, Minimap.SIZE), "the panel is sized")
func test_unmap_point_is_the_inverse_of_map_point() -> void:
var panel := Vector2(Minimap.SIZE, Minimap.SIZE)
var bounds := MapData.BOUNDS
assert_eq(Minimap.unmap_point(Vector2.ZERO, panel), bounds.position, "0 maps back to top-left")
assert_eq(Minimap.unmap_point(panel, panel), bounds.end, "size maps back to bottom-right")
assert_eq(Minimap.unmap_point(panel * 0.5, panel), bounds.get_center(), "centre back to centre")
# A non-trivial sim point survives map → unmap unchanged (the click projection is lossless).
var p := bounds.position + bounds.size * Vector2(0.3, 0.7)
var round_trip := Minimap.unmap_point(Minimap.map_point(p, panel), panel)
assert_almost_eq(round_trip, p, Vector2(0.01, 0.01), "map → unmap is the identity")
func test_a_click_on_the_plan_emits_a_command_at_the_world_point_under_it() -> void:
var minimap := Minimap.new()
autofree(minimap)
var panel := Vector2(Minimap.SIZE, Minimap.SIZE)
minimap.size = panel # pin a known size so the projection is independent of a layout pass
watch_signals(minimap)
var local := panel * Vector2(0.25, 0.6)
var world := Minimap.unmap_point(local, panel)
var right := InputEventMouseButton.new()
right.button_index = MOUSE_BUTTON_RIGHT
right.pressed = true
right.position = local
minimap._gui_input(right)
assert_signal_emitted_with_parameters(minimap, "order_requested", [world])
var left := InputEventMouseButton.new()
left.button_index = MOUSE_BUTTON_LEFT
left.pressed = true
left.position = local
minimap._gui_input(left)
assert_signal_emitted_with_parameters(minimap, "look_requested", [world])