GDScript 192 lines
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
## player's team has vision, so it honours the same fog of war the world view does ([[Vision]]).
##
## Pure presentation, reconciled each tick from the snapshot the HUD already consumes: it owns no
## simulation and reads only entity position/team/kind plus the player's own hero (highlighted).
## Geometry comes straight from MapData — the one source the sim, bots, and world decor share — so
## the plan cannot drift from the played map. Built in code on the UiTheme palette, like every
## other overlay; no `.tscn`, no editor pass.
##
## 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
const MARGIN := 16.0
## Backdrop: a dark translucent panel with a faint frame, so the plan reads as a card over the
## world without hiding it outright.
const PANEL_BG := Color(0.05, 0.07, 0.06, 0.82)
const PANEL_BORDER := UiTheme.PANEL_BORDER
const BORDER_WIDTH := 2.0
## Terrain backdrop tones, dimmer than the world so the unit dots pop: the lane dirt and the river.
const LANE_COLOR := Color(0.30, 0.26, 0.18)
const LANE_WIDTH := 3.0
const RIVER_COLOR := Color(0.20, 0.32, 0.46)
const RIVER_WIDTH := 3.0
## Unit dot sizing (pixels): a hero reads largest, a creep is a speck, a structure a small square
## (the nexus larger). The player's own hero wears an amber ring so it is found at a glance.
const HERO_RADIUS := 4.5
const CREEP_RADIUS := 2.0
const TOWER_HALF := 3.5
const NEXUS_HALF := 5.0
const OWN_RING_RADIUS := 7.5
const OWN_RING_WIDTH := 2.0
const CREEP_DARKEN := 0.25 # a creep dot sits a shade under its team hue, as in the world view
var _state: SimState = null
var _team: int = 0
var _focus_id: int = 0
var _colors: Array = []
## Whether to filter enemies by vision here: true with local authority (the state is the full
## world), false on a pure CLIENT whose snapshot is already filtered to its team.
var _filter: bool = false
var _visible: Dictionary = {}
func _ready() -> void:
custom_minimum_size = Vector2(SIZE, SIZE)
# Pin a SIZE square into the bottom-right corner, MARGIN in from each edge.
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -(SIZE + MARGIN)
offset_top = -(SIZE + MARGIN)
offset_right = -MARGIN
offset_bottom = -MARGIN
# 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
## own hero (null before it spawns), `team_colors` the per-team dot colours indexed by team id, and
## `hide_fogged` filters enemies by the player's vision (set only with local authority — a pure
## CLIENT's snapshot is already team-filtered). Recomputes the visible set once here, not per dot.
func update(
state: SimState, player_team: int, focus: SimEntity, team_colors: Array, hide_fogged: bool
) -> void:
_state = state
_team = player_team
_focus_id = focus.id if focus != null else 0
_colors = team_colors
_filter = hide_fogged
_visible = Vision.visible_ids(state, player_team) if (hide_fogged and state != null) else {}
queue_redraw()
## Maps a sim-field point into the panel's local pixel space: the arena bounds scaled to the SIZE
## square, sim x → right and sim y → down (the same top-down orientation as the world camera).
## Static and pure so the mapping is unit-testable without drawing.
static func map_point(p: Vector2, panel_size: Vector2) -> Vector2:
var bounds := MapData.BOUNDS
var n := (p - bounds.position) / bounds.size
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)
if _state != null:
_draw_terrain()
_draw_units()
draw_rect(panel, PANEL_BORDER, false, BORDER_WIDTH)
## The static backdrop — the lane corridors and the river — drawn dim so the unit dots read over it.
func _draw_terrain() -> void:
for lane in MapData.LANES:
draw_polyline(_scaled(lane), LANE_COLOR, LANE_WIDTH)
draw_polyline(_scaled(MapData.RIVER), RIVER_COLOR, RIVER_WIDTH)
## The live units as dots: friendly always, enemies only where the team has vision. A structure is a
## square (nexus larger), a creep a speck, a hero a disc; the player's own hero gets an amber ring.
func _draw_units() -> void:
for id in _state.entities:
var entity: SimEntity = _state.entities[id]
if _filter and entity.team != _team and not _visible.has(id):
continue
var at := map_point(entity.position, size)
var color := _team_color(entity.team)
if entity.is_nexus:
_draw_square(at, NEXUS_HALF, color)
elif entity.is_structure:
_draw_square(at, TOWER_HALF, color)
elif entity.is_creep:
draw_circle(at, CREEP_RADIUS, color.darkened(CREEP_DARKEN))
else:
draw_circle(at, HERO_RADIUS, color)
if id == _focus_id:
draw_arc(at, OWN_RING_RADIUS, 0.0, TAU, 20, UiTheme.ACCENT, OWN_RING_WIDTH)
## A team's dot colour from the passed palette, falling back to white if a team index is unmapped.
func _team_color(team: int) -> Color:
if team >= 0 and team < _colors.size():
return _colors[team]
return Color.WHITE
## A filled square centred on `at`, half-side `half` — the structure dot.
func _draw_square(at: Vector2, half: float, color: Color) -> void:
draw_rect(Rect2(at - Vector2(half, half), Vector2(half, half) * 2.0), color)
## A polyline of sim points mapped into panel space — the terrain backdrop helper.
func _scaled(points: Array) -> PackedVector2Array:
var out := PackedVector2Array()
for p in points:
out.append(map_point(p, size))
return out