ajhahn.de
← Theria
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