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