ajhahn.de
← Theria
GDScript 364 lines
class_name MatchHud
extends Control
## The in-match heads-up display for the player's own hero: a bottom-centre cluster
## carrying the hero's name + active form, its HP and resource bars, and the QWER
## ability bar with live cooldowns. Pure presentation — each tick the driver hands it
## the player's hero entity and it reconciles every readout, or hides while the hero is
## absent or down (the death screen covers that). It owns no simulation and reads only
## the entity's fields (hp, resource, form, the equipped kit, and the per-ability
## cooldowns), exactly as `death_overlay` reads `respawn_ticks`. Built in code on the
## shared `UiTheme` palette — no `.tscn`, no editor pass — like the rest of the client.
##
## Layout is the MOBA-standard bottom bar: a portrait panel (name + form badge), the
## HP/resource bars, and a four-slot ability row, so a player coming from the genre reads
## it at a glance. The four cells map one-to-one to PlayerInput's QWER binds; a hero fills
## only three slots per form (the fourth is the other form's), so one cell shows empty —
## that gap is the shapeshifter's two-kits-in-one identity, kept visible on purpose.
##
## A settings button sits in the top-right corner. It is a placeholder: pressing it fires
## `settings_pressed`, which the driver is free to leave unhandled for now — the in-match
## settings menu itself is a later slice, this is only the entry point reserved in the layout.

## Fired when the player clicks the settings button. A placeholder hook — there is no
## settings menu yet; the driver may connect a stub until that slice lands.
signal settings_pressed

## The QWER bind letters, one per ability slot 0..3 — the on-cell label, matching the
## order of `PlayerInput.ABILITY_KEYS` so the cell a key fires is the cell it is drawn on.
const SLOT_KEYS: Array[String] = ["Q", "W", "E", "R"]

## Ability-cell geometry (pixels): a square cell, the gap between cells, and the bar/panel
## sizing. The HP and resource bars span the same width as the four-cell row so the cluster
## reads as one block.
const CELL := 60.0
const CELL_SEP := 10.0
const BAR_HEIGHT := 22.0
const BAR_SPACING := 6.0
const CLUSTER_SEP := 18.0
const BOTTOM_MARGIN := 22.0
## Settings button geometry: a small square pinned the corner margin in from the top-right.
const SETTINGS_SIZE := 44.0
const SETTINGS_MARGIN := 16.0

## Cell face colour by ability effect (AbilitySpec.EFFECT_*): a warm strike, a green
## restore, the amber transform — the one accent the rest of the UI already uses for focus.
const DAMAGE_COLOR := Color(0.74, 0.34, 0.26)
const HEAL_COLOR := Color(0.34, 0.58, 0.36)
const TRANSFORM_COLOR := Color(0.62, 0.46, 0.20)
const EMPTY_COLOR := Color(0.10, 0.12, 0.12)  # the slot the active form does not fill

## Cell border by cast state: amber when the ability is ready, a cool tint when it is only
## blocked on resource (a hint that it is the pool, not the timer, that gates it), muted
## otherwise (on cooldown, empty).
const READY_BORDER := Color(0.95, 0.69, 0.26)  # UiTheme.ACCENT
const NO_RESOURCE_BORDER := Color(0.35, 0.52, 0.78)
const MUTED_BORDER := Color(0.22, 0.25, 0.24)

## The dark wash drawn over the unready portion of a cell, draining as the cooldown ticks
## down so the cell brightens from the bottom up as it readies.
const COOLDOWN_WASH := Color(0.0, 0.0, 0.0, 0.62)

const HP_FILL := Color(0.40, 0.72, 0.40)
const RESOURCE_FILL := Color(0.35, 0.60, 0.95)
const BAR_BG := Color(0.05, 0.06, 0.06, 0.85)

const NAME_FONT_SIZE := 22
const FORM_FONT_SIZE := 15
const BAR_FONT_SIZE := 14
const KEY_FONT_SIZE := 15
const COOLDOWN_FONT_SIZE := 26
const ABILITY_NAME_FONT_SIZE := 11

## Source of the tick rate cooldowns are counted in, so a remaining-tick count renders as
## the whole seconds a player reads ("3… 2… 1…"), rounded up like the respawn timer.
const TICK_RATE := SimCore.TICK_RATE

var _settings_button: Button
## The bottom cluster's frame, kept so a test can assert it lays out to a real on-screen size.
var _frame: PanelContainer
var _name_label: Label
var _form_label: Label
## Each bar as `{root, fill, label, frac}`; `frac` is the last fill fraction, kept so a test
## can read the bound value without waiting on a container layout pass.
var _hp: Dictionary = {}
var _resource: Dictionary = {}
## The four ability cells, slot 0..3. Each is `{root, style, wash, key, cooldown, name, frac}`
## — the nodes `_update_cell` mutates plus the last cooldown fraction, again for tests.
var _cells: Array[Dictionary] = []


func _ready() -> void:
	set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	# The match plays under the HUD: never eat a click, so click-to-move and casts on the
	# field below pass straight through the empty space around the cluster.
	mouse_filter = Control.MOUSE_FILTER_IGNORE
	var frame := _build_frame()
	var cluster := HBoxContainer.new()
	cluster.add_theme_constant_override("separation", CLUSTER_SEP)
	cluster.alignment = BoxContainer.ALIGNMENT_CENTER
	frame.add_child(cluster)
	cluster.add_child(_build_portrait())
	cluster.add_child(_build_center())
	_build_settings_button()


## The settings entry point: a gear button pinned top-right, on the shared menu theme so it
## reads as the same product. A placeholder — it only re-emits `settings_pressed`; the menu it
## will open is a later slice. Built last so it layers over the rest of the (empty) HUD canvas.
func _build_settings_button() -> void:
	_settings_button = Button.new()
	_settings_button.text = "⚙"
	_settings_button.theme = UiTheme.make()
	_settings_button.add_theme_font_size_override("font_size", 22)
	_settings_button.custom_minimum_size = Vector2(SETTINGS_SIZE, SETTINGS_SIZE)
	add_child(_settings_button)
	# Pin to the top-right corner with explicit offsets (a preset sets anchors but not offsets,
	# leaving a zero-size box at the corner): a fixed square the corner margin in from the edge.
	_settings_button.set_anchors_preset(Control.PRESET_TOP_RIGHT)
	_settings_button.offset_left = -SETTINGS_SIZE - SETTINGS_MARGIN
	_settings_button.offset_right = -SETTINGS_MARGIN
	_settings_button.offset_top = SETTINGS_MARGIN
	_settings_button.offset_bottom = SETTINGS_MARGIN + SETTINGS_SIZE
	_settings_button.pressed.connect(func() -> void: settings_pressed.emit())


# --- Build ------------------------------------------------------------------


## The bottom-centre panel the cluster sits in. Pinned by containers rather than by hand: a
## full-rect column bottom-aligns its one row, the row centres the frame, and the frame sizes
## to its content — so it hugs the bottom-centre of any window without the manual anchor math
## that collapses a Container to zero size. Framed on the shared palette like the menus.
func _build_frame() -> PanelContainer:
	var column := VBoxContainer.new()
	column.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	column.offset_bottom = -BOTTOM_MARGIN
	column.alignment = BoxContainer.ALIGNMENT_END
	column.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(column)
	var row := HBoxContainer.new()
	row.alignment = BoxContainer.ALIGNMENT_CENTER
	row.mouse_filter = Control.MOUSE_FILTER_IGNORE
	column.add_child(row)
	_frame = PanelContainer.new()
	_frame.add_theme_stylebox_override("panel", _panel_style())
	_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
	row.add_child(_frame)
	return _frame


## The portrait column: the hero's name over a form badge. The badge names the active form
## — the human stance or, in beast form, the creature the hero shifts into — so the player
## always sees which kit the QWER bar currently casts.
func _build_portrait() -> Control:
	var box := VBoxContainer.new()
	box.alignment = BoxContainer.ALIGNMENT_CENTER
	box.custom_minimum_size = Vector2(150.0, 0.0)
	_name_label = _label("", NAME_FONT_SIZE, UiTheme.TEXT)
	_form_label = _label("", FORM_FONT_SIZE, UiTheme.ACCENT)
	box.add_child(_name_label)
	box.add_child(_form_label)
	return box


## The centre column: the HP bar, the resource bar, then the four-cell ability row, stacked
## so the vital readouts sit directly over the keys that spend them.
func _build_center() -> Control:
	var box := VBoxContainer.new()
	box.add_theme_constant_override("separation", BAR_SPACING)
	_hp = _make_bar(HP_FILL)
	_resource = _make_bar(RESOURCE_FILL)
	box.add_child(_hp["root"])
	box.add_child(_resource["root"])
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", CELL_SEP)
	for slot in SLOT_KEYS.size():
		var cell := _make_cell(SLOT_KEYS[slot])
		_cells.append(cell)
		row.add_child(cell["slot_box"])
	box.add_child(row)
	return box


## A value bar (HP, resource): a dark track, a coloured fill sized each tick by fraction,
## and a centred `current / max` readout. Returned as the node refs the per-tick update
## mutates; the fill is positioned by hand (not a container child) so its width is the value.
func _make_bar(fill_color: Color) -> Dictionary:
	var root := Control.new()
	root.custom_minimum_size = Vector2(0.0, BAR_HEIGHT)
	root.mouse_filter = Control.MOUSE_FILTER_IGNORE
	var bg := ColorRect.new()
	bg.color = BAR_BG
	bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
	root.add_child(bg)
	var fill := ColorRect.new()
	fill.color = fill_color
	fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
	root.add_child(fill)
	var label := _label("", BAR_FONT_SIZE, UiTheme.TEXT)
	label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	root.add_child(label)
	return {"root": root, "fill": fill, "label": label, "frac": 0.0}


## One ability cell: a framed square whose face colours by effect and whose border marks
## cast state, with the bind letter in the corner, a draining cooldown wash, the remaining
## seconds over it, and the ability's name beneath. Returned as the refs the update mutates.
func _make_cell(key: String) -> Dictionary:
	var style := StyleBoxFlat.new()
	style.set_corner_radius_all(8)
	style.set_border_width_all(2)
	var cell := Panel.new()
	cell.custom_minimum_size = Vector2(CELL, CELL)
	cell.add_theme_stylebox_override("panel", style)
	cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
	var wash := ColorRect.new()
	wash.color = COOLDOWN_WASH
	wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
	cell.add_child(wash)
	var key_label := _label(key, KEY_FONT_SIZE, UiTheme.TEXT)
	key_label.position = Vector2(5.0, 2.0)
	cell.add_child(key_label)
	var cooldown := _label("", COOLDOWN_FONT_SIZE, UiTheme.TEXT)
	cooldown.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	cooldown.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	cooldown.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	cell.add_child(cooldown)
	var name_label := _label("", ABILITY_NAME_FONT_SIZE, UiTheme.TEXT_MUTED)
	name_label.custom_minimum_size = Vector2(CELL, 0.0)
	name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	var slot_box := VBoxContainer.new()
	slot_box.add_theme_constant_override("separation", 3)
	slot_box.add_child(cell)
	slot_box.add_child(name_label)
	return {
		"slot_box": slot_box,
		"style": style,
		"wash": wash,
		"cooldown": cooldown,
		"name": name_label,
		"frac": 0.0,
	}


# --- Per-tick update --------------------------------------------------------


## Reconciles the HUD with the player's hero: hidden while the hero is absent (not yet
## spawned) or down (the death screen owns the screen then), otherwise every readout bound
## to the live entity. The single entry point the driver calls each tick.
func refresh(hero: SimEntity) -> void:
	if hero == null or hero.is_dead():
		hide()
		return
	show()
	_name_label.text = hero.kit_id.capitalize() if hero.kit_id != "" else "—"
	_form_label.text = _form_text(hero)
	_set_bar(_hp, hero.hp, hero.max_hp)
	_set_bar(_resource, hero.resource, hero.resource_max)
	var bar: Dictionary = hero.kit.get(hero.form, {})
	for slot in _cells.size():
		_update_cell(_cells[slot], hero, bar.get(slot, 0))


## The form badge: the human stance, or in beast form the creature the kit shifts into
## (the hero's own name), so the badge reads as the active kit rather than a generic word.
func _form_text(hero: SimEntity) -> String:
	if hero.form == AbilitySpec.FORM_ANIMAL:
		return hero.kit_id.capitalize().to_upper() if hero.kit_id != "" else "BEAST"
	return "HUMAN"


## Fills a bar to `current / max_value` and writes the readout. Stores the fraction so the
## value is inspectable without a layout pass; sizes the fill from the bar's laid-out width.
func _set_bar(bar: Dictionary, current: int, max_value: int) -> void:
	var frac := 0.0 if max_value <= 0 else clampf(float(current) / float(max_value), 0.0, 1.0)
	bar["frac"] = frac
	var root := bar["root"] as Control
	var fill := bar["fill"] as ColorRect
	fill.position = Vector2.ZERO
	fill.size = Vector2(root.size.x * frac, root.size.y)
	(bar["label"] as Label).text = "%d / %d" % [maxi(current, 0), maxi(max_value, 0)]


## Reconciles one cell with the ability the hero now carries in that slot. An empty slot
## (the other form's) reads as a dimmed blank; otherwise the face colours by effect, the
## border marks ready / blocked-on-resource / on-cooldown, the wash drains with the
## remaining cooldown, and the seconds and name are written. Reads only — no node is built.
func _update_cell(cell: Dictionary, hero: SimEntity, ability_id: int) -> void:
	var style := cell["style"] as StyleBoxFlat
	var wash := cell["wash"] as ColorRect
	if ability_id == 0:
		style.bg_color = EMPTY_COLOR
		style.border_color = MUTED_BORDER
		wash.visible = false
		cell["frac"] = 0.0
		(cell["cooldown"] as Label).text = ""
		(cell["name"] as Label).text = ""
		return
	var spec := AbilityData.spec(ability_id)
	var remaining: int = hero.ability_cooldowns.get(ability_id, 0)
	var total := maxi(spec.cooldown_ticks, 1)
	var on_cooldown := remaining > 0
	var affordable := hero.resource >= spec.cost
	var ready := not on_cooldown and affordable and not hero.is_stunned()
	var face := _effect_color(spec.effect)
	style.bg_color = face if ready else face.darkened(0.45)
	if ready:
		style.border_color = READY_BORDER
	elif not on_cooldown and not affordable:
		style.border_color = NO_RESOURCE_BORDER
	else:
		style.border_color = MUTED_BORDER
	var frac := float(remaining) / float(total)
	cell["frac"] = frac
	wash.visible = on_cooldown
	if on_cooldown:
		var w: float = (cell["slot_box"] as Control).size.x
		var width := w if w > 0.0 else CELL
		wash.position = Vector2.ZERO
		wash.size = Vector2(width, CELL * frac)
	var seconds := str(ceili(float(remaining) / float(TICK_RATE)))
	(cell["cooldown"] as Label).text = seconds if on_cooldown else ""
	(cell["name"] as Label).text = spec.name


# --- Helpers ----------------------------------------------------------------


func _effect_color(effect: int) -> Color:
	match effect:
		AbilitySpec.EFFECT_HEAL:
			return HEAL_COLOR
		AbilitySpec.EFFECT_TRANSFORM:
			return TRANSFORM_COLOR
		_:
			return DAMAGE_COLOR


## A configured label — the one place font size and colour are set, so every readout shares
## the build and only its text and placement vary.
func _label(text: String, font_size: int, color: Color) -> Label:
	var label := Label.new()
	label.text = text
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	label.add_theme_font_size_override("font_size", font_size)
	label.add_theme_color_override("font_color", color)
	label.mouse_filter = Control.MOUSE_FILTER_IGNORE
	return label


## The HUD frame's face: the shared panel colour with a faint border and tight inner
## padding — the menu card's look, trimmed to a strip that frames the cluster.
func _panel_style() -> StyleBoxFlat:
	var style := StyleBoxFlat.new()
	style.bg_color = UiTheme.PANEL
	style.set_corner_radius_all(UiTheme.CORNER)
	style.set_border_width_all(1)
	style.border_color = UiTheme.PANEL_BORDER
	style.set_content_margin_all(14.0)
	return style