ajhahn.de
← Theria commits

Commit

Theria

feat: in-match HUD, kill feed, and chat overlays

ajhahnde · Jun 2026 · ff3b635a171e78324cfbced1d2c0907e1da9a1f6 · parent: d20f049 · view on GitHub →

modified CHANGELOG.md
@@ -25,6 +25,22 @@ protocol version.
## [Unreleased]
### Added
- An **in-match HUD** for the player's hero: a bottom cluster with the hero's name
and active form, HP and resource bars, and the **QWER ability bar** — each cell
coloured by its effect, with a draining cooldown and its remaining seconds, and a
border that marks ready / blocked-on-resource. The built ability layer is now
legible and playable on screen.
- A **kill feed** (top-right) announcing each hero takedown, and an **all/team chat
box** (bottom-left) — while typing in chat the ability casts are suppressed, so a
letter in a message never fires its bind. Chat is local-echo for now; delivery to
other players follows with the networking work. *(First pass: the kill feed names
the victim only — attributing the killer needs the simulation to record the lethal
blow.)*
- A **settings button** (top-right) and a **death-recap** panel on the death screen,
both placeholders that reserve their spot in the layout for later slices.
## [v0.3.3] — 2026-06-16
### Changed
modified src/client/death_overlay.gd
@@ -17,6 +17,15 @@ const TITLE_FONT_SIZE := 96
const TIMER_COLOR := Color(0.92, 0.93, 0.96)
const TIMER_FONT_SIZE := 52
## The death-recap card is a placeholder: a real recap names the killer and breaks the lethal
## damage down by source, amount, and type, which needs the sim to attribute every hit to its
## dealer (and a damage-type axis the combat layer does not carry yet) — its own slice. For now
## the card reserves the spot on the death screen and states what it will hold.
const RECAP_TITLE := "DEATH RECAP"
const RECAP_PLACEHOLDER := "Killer and per-attacker damage and type will appear here."
const RECAP_TITLE_FONT_SIZE := 24
const RECAP_BODY_FONT_SIZE := 18
var _timer_label: Label
@@ -55,9 +64,36 @@ func _ready() -> void:
_timer_label.add_theme_color_override("font_color", TIMER_COLOR)
box.add_child(_timer_label)
box.add_child(_build_recap())
hide()
## The placeholder death-recap card: a framed panel on the shared palette naming what a full
## recap will show (killer + per-source damage and type). Static for now — wired in so the slice
## that records the lethal-damage breakdown only has to fill it, not find a place for it.
func _build_recap() -> Control:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", UiTheme.card_style())
var column := VBoxContainer.new()
column.alignment = BoxContainer.ALIGNMENT_CENTER
column.add_theme_constant_override("separation", 10)
panel.add_child(column)
var heading := Label.new()
heading.text = RECAP_TITLE
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
heading.add_theme_font_size_override("font_size", RECAP_TITLE_FONT_SIZE)
heading.add_theme_color_override("font_color", UiTheme.ACCENT)
column.add_child(heading)
var body := Label.new()
body.text = RECAP_PLACEHOLDER
body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
body.add_theme_font_size_override("font_size", RECAP_BODY_FONT_SIZE)
body.add_theme_color_override("font_color", UiTheme.TEXT_MUTED)
column.add_child(body)
return panel
## Shows the death screen with the respawn countdown, or hides it when the hero is alive
## (0 ticks). The remaining ticks are rounded up to whole seconds so the timer counts the way
## a player reads it — "3… 2… 1…" — and reaches 0 on the tick the hero actually respawns.
added src/client/kill_feed.gd
@@ -0,0 +1,99 @@
class_name KillFeed
extends VBoxContainer
## The top-right kill feed: a short stack of recent takedown lines, newest on top, each
## fading out after a few seconds so the feed stays a glance, not a wall. Pure presentation —
## the driver decides a kill happened and calls `push`; this owns only the on-screen list and
## its expiry, like the other code-built client overlays, on the shared `UiTheme` palette.
##
## First pass shows the victim alone ("X was slain"), because the simulation records no killer
## today — `_resolve_deaths` only zeroes hp and starts the respawn timer. Attributing the kill
## ("X slew Y") is a later sim slice (record the dealer of the lethal blow, and carry it on the
## wire for a networked feed); the `push` signature already takes the full line so that slice is
## a driver change, not a rework here.
## How many lines stay on screen at once; older lines drop off the bottom as new ones arrive.
const MAX_ENTRIES := 5
## How long (seconds) a line lingers before it removes itself.
const LIFETIME := 6.0
const FONT_SIZE := 16
const MARGIN := 16.0
## Where the feed sits below the top-right settings button, so the two do not overlap.
const TOP_OFFSET := 70.0
## Which heroes were down last tick (id -> true), so `observe` fires one line on the
## alive -> down edge rather than every tick a hero stays dead.
var _down_last_tick: Dictionary = {}
func _ready() -> void:
set_anchors_preset(Control.PRESET_TOP_RIGHT)
grow_horizontal = Control.GROW_DIRECTION_BEGIN
offset_top = TOP_OFFSET
offset_right = -MARGIN
alignment = BoxContainer.ALIGNMENT_BEGIN
add_theme_constant_override("separation", 4)
mouse_filter = Control.MOUSE_FILTER_IGNORE
## Adds a takedown line, newest at the top, trimming the oldest past the cap and scheduling
## the line to fade itself out after LIFETIME. `color` tints the line (the victim's team) so a
## glance reads which side fell. Safe before the node is in a tree — the timer is only armed
## once it is, so a headless or pre-ready caller just gets the label without an expiry.
func push(text: String, color: Color) -> void:
var label := Label.new()
label.text = text
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
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
add_child(label)
move_child(label, 0)
while get_child_count() > MAX_ENTRIES:
# remove_child drops the count synchronously (queue_free alone does not), so detach the
# oldest first to bound the loop, then free the now-orphaned line.
var oldest := get_child(get_child_count() - 1)
remove_child(oldest)
oldest.queue_free()
_expire(label)
## Scans the state for heroes that went down this tick — the alive -> down edge against last
## tick's record — and posts one feed line each, tinted by the team's colour (`team_colors`
## indexed by team id). `ally_team` only picks the Ally/Enemy fallback name when a hero's kit is
## unknown (a pure CLIENT hero). First pass names the victim alone; the killer is unknown until
## the sim attributes the lethal blow. Owns the death-edge tracking so the driver just hands it
## the state each tick.
func observe(state: SimState, ally_team: int, team_colors: Array) -> void:
var down_now: Dictionary = {}
for id in state.entities:
var entity: SimEntity = state.entities[id]
if not entity.is_hero:
continue
var down := entity.is_dead()
down_now[id] = down
if down and not _down_last_tick.get(id, false):
push("%s was slain" % _victim_name(entity, ally_team), team_colors[entity.team])
_down_last_tick = down_now
## A downed hero's name for the feed: its kit, capitalised, or an Ally/Enemy fallback when the
## kit is unknown (not carried on the wire for a pure CLIENT hero).
func _victim_name(hero: SimEntity, ally_team: int) -> String:
if hero.kit_id != "":
return hero.kit_id.capitalize()
return "Ally" if hero.team == ally_team else "Enemy"
## Arms a line to remove itself after LIFETIME. A scene-tree timer needs the node in a tree;
## outside one (a unit test that never adds the feed) the line simply persists, which is all a
## push assertion needs.
func _expire(label: Label) -> void:
if not is_inside_tree():
return
var timer := get_tree().create_timer(LIFETIME)
timer.timeout.connect(func() -> void: _remove(label))
func _remove(label: Label) -> void:
if is_instance_valid(label):
label.queue_free()
added src/client/kill_feed.gd.uid
@@ -0,0 +1 @@
uid://dqmbuw0ut0ybx
modified src/client/main.gd
@@ -198,9 +198,10 @@ var _cam_target: Vector2 = Vector2.ZERO
var _cam_ready: bool = false
var _ground: MeshInstance3D = null
var _views: Dictionary = {}
## The death screen, on its own screen-space layer over the 3D world. Built once, hidden, and
## shown by `_update_death_overlay` while the player's hero is down. Null on a headless run.
var _death_overlay: DeathOverlay = null
## The match's screen-space UI — the hero HUD, the kill feed, the chat box, and the death
## screen — built and driven as one layer by `MatchOverlays`, reconciled each tick in
## `_sync_world`. Null on a headless run (no display to draw it on).
var _overlays: MatchOverlays = null
func _ready() -> void:
@@ -641,13 +642,12 @@ func _build_world() -> void:
_player_input = PlayerInput.new(_camera)
_move_marker = MoveMarker.new()
add_child(_move_marker)
# The death screen rides its own CanvasLayer so it draws in screen space over the zoomed
# game camera, exactly like the connect menu. A headless smoke has no display to raise it on.
# The screen-space UI (HUD, kill feed, chat, death screen) draws over the zoomed game camera,
# exactly like the connect menu. MatchOverlays owns its canvas layers; a headless smoke has no
# display to raise it on, so it is built only with one.
if not _is_headless():
_death_overlay = DeathOverlay.new()
var death_layer := CanvasLayer.new()
death_layer.add_child(_death_overlay)
add_child(death_layer)
_overlays = MatchOverlays.new()
add_child(_overlays)
## Reconciles the view pool against the live state, then trails the camera. Called each
@@ -677,7 +677,7 @@ func _sync_world() -> void:
else:
_move_marker.clear()
_follow_camera(state)
_update_death_overlay(state)
_update_overlays(state)
## Trails the camera on the player's hero — a fixed height and pitch, eased toward it each
@@ -712,16 +712,22 @@ func _camera_focus(state: SimState) -> SimEntity:
return null
## Raises or hides the death screen for the player's own hero. While the hero is down its
## respawn timer drives the on-screen countdown; alive — or not yet spawned — the screen stays
## hidden. The hero is the camera's focus, sim-driven in LOCAL/HOST and read out of the snapshot
## on a CLIENT, so the countdown ticks down straight off the same entity the world draws.
func _update_death_overlay(state: SimState) -> void:
if _death_overlay == null:
## 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
## kill feed also takes the two team colours for its lines.
func _update_overlays(state: SimState) -> void:
if _overlays == null:
return
var hero := _camera_focus(state)
var ticks := hero.respawn_ticks if hero != null else 0
_death_overlay.set_respawn(ticks, SimCore.TICK_RATE)
_overlays.update(
_camera_focus(state), state, _player_team(), [HERO_COLOR, BOT_COLOR], SimCore.TICK_RATE
)
## Whether the player is typing in chat — the driver suppresses ability casts while they are, so
## the letters of a message never fire the QWER bar. Movement (a mouse click) is left alone.
func _chat_typing() -> bool:
return _overlays != null and _overlays.is_chat_typing()
## Builds an entity's pooled view: a primitive body (capsule unit, box structure), a
@@ -943,10 +949,12 @@ func _hero_color(entity: SimEntity) -> Color:
## This tick's player command — delegated to PlayerInput, handed the world the player acts on,
## their hero, their team, and whether to sample casts (only with a local authoritative sim).
## their hero, their team, and whether to sample casts. Casts are sampled only with a local
## authoritative sim and while the player is not typing in chat, so a letter in a message never
## fires its QWER bind.
func _sample_player_input() -> InputCommand:
return _player_input.sample(
_visible_state(), _player_hero_entity(), _player_team(), _sim != null
_visible_state(), _player_hero_entity(), _player_team(), _sim != null and not _chat_typing()
)
added src/client/match_chat.gd
@@ -0,0 +1,166 @@
class_name MatchChat
extends Control
## The in-match chat box, bottom-left: a short scrollback of recent lines over an input
## field, with an all / team scope toggle, in the genre-standard corner. Pure presentation
## on the shared `UiTheme` palette, like the other code-built overlays.
##
## First pass is local only: a sent line is echoed straight into this client's own log and
## announced on `message_sent` — it does not yet travel to other players. Real all/team
## delivery is a v0.2 networking slice (the chat wire rides the same session as the snapshot
## stream); `message_sent` already carries the scope so that slice subscribes here without a
## rework. The input also gates the game keys: while the player is typing, `is_typing` is true
## and the driver suppresses ability casts, so a "q" in a message never fires Q.
## Fired when the player sends a line — `scope` is Scope.ALL/Scope.TEAM, `text` the message.
## The hook a later networking slice connects to deliver the line to the other clients.
signal message_sent(scope: int, text: String)
enum Scope { ALL, TEAM }
## How many lines the scrollback keeps; older lines drop off the top.
const MAX_LINES := 8
const WIDTH := 380.0
const MARGIN := 18.0
## Clears the input above the bottom HUD cluster so the two do not stack on the same row.
const BOTTOM_OFFSET := 150.0
const FONT_SIZE := 15
const ALL_COLOR := Color(0.88, 0.89, 0.90)
const TEAM_COLOR := Color(0.45, 0.78, 0.62)
var _scope: int = Scope.ALL
var _typing: bool = false
var _log: VBoxContainer
var _input: LineEdit
var _scope_button: Button
func _ready() -> void:
set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
grow_vertical = Control.GROW_DIRECTION_BEGIN
offset_left = MARGIN
offset_bottom = -BOTTOM_OFFSET
custom_minimum_size = Vector2(WIDTH, 0.0)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build()
func _build() -> void:
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 4)
column.custom_minimum_size = Vector2(WIDTH, 0.0)
add_child(column)
_log = VBoxContainer.new()
_log.add_theme_constant_override("separation", 2)
_log.mouse_filter = Control.MOUSE_FILTER_IGNORE
column.add_child(_log)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
column.add_child(row)
_scope_button = Button.new()
_scope_button.theme = UiTheme.make()
_scope_button.custom_minimum_size = Vector2(72.0, 0.0)
_scope_button.pressed.connect(toggle_scope)
row.add_child(_scope_button)
_input = LineEdit.new()
_input.theme = UiTheme.make()
_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_input.max_length = 120
_input.visible = false
_input.text_submitted.connect(_on_submitted)
row.add_child(_input)
_refresh_scope_button()
# --- State ------------------------------------------------------------------
## Whether the player is currently typing a message — read by the driver to suppress ability
## casts so the letters of a message never fire the QWER bar.
func is_typing() -> bool:
return _typing
## Opens the input for typing and focuses it, so the next keystrokes land in the message rather
## than the game. Idempotent — opening while already open just keeps the caret.
func open() -> void:
_typing = true
_input.visible = true
_input.grab_focus()
## Closes the input, drops focus, and clears any half-typed text, handing the keyboard back to
## the game. Called on send and on cancel.
func close() -> void:
_typing = false
_input.visible = false
_input.text = ""
_input.release_focus()
## Flips the send scope between all-chat and team-chat, updating the toggle label. The next
## sent line carries the new scope.
func toggle_scope() -> void:
_scope = Scope.TEAM if _scope == Scope.ALL else Scope.ALL
_refresh_scope_button()
# --- Input ------------------------------------------------------------------
## Opens chat on Enter when not already typing, and cancels on Escape while typing. Submitting
## a line is the LineEdit's own `text_submitted` (also Enter), so an open input never re-opens.
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.pressed or event.echo:
return
if _typing:
if event.keycode == KEY_ESCAPE:
close()
accept_event()
elif event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER:
open()
accept_event()
## A submitted line: echo it into this client's own log and announce it, then close the input.
## A blank line just closes (the genre's "open, change your mind, hit enter" gesture).
func _on_submitted(text: String) -> void:
var trimmed := text.strip_edges()
if trimmed != "":
append_line("You", trimmed, _scope)
message_sent.emit(_scope, trimmed)
close()
# --- Log --------------------------------------------------------------------
## Appends a chat line to the scrollback, tagged by scope and tinted to match, trimming the
## oldest past the cap. Public so the later networking slice can drop remote players' lines in
## through the same path the local echo uses.
func append_line(speaker: String, text: String, scope: int) -> void:
var label := Label.new()
label.text = "[%s] %s: %s" % [_scope_tag(scope), speaker, text]
label.add_theme_font_size_override("font_size", FONT_SIZE)
label.add_theme_color_override("font_color", _scope_color(scope))
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.custom_minimum_size = Vector2(WIDTH, 0.0)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_log.add_child(label)
while _log.get_child_count() > MAX_LINES:
var oldest := _log.get_child(0)
_log.remove_child(oldest)
oldest.queue_free()
func _refresh_scope_button() -> void:
_scope_button.text = _scope_tag(_scope)
_scope_button.add_theme_color_override("font_color", _scope_color(_scope))
func _scope_tag(scope: int) -> String:
return "TEAM" if scope == Scope.TEAM else "ALL"
func _scope_color(scope: int) -> Color:
return TEAM_COLOR if scope == Scope.TEAM else ALL_COLOR
added src/client/match_chat.gd.uid
@@ -0,0 +1 @@
uid://dj7qntr3iumf6
added src/client/match_hud.gd
@@ -0,0 +1,363 @@
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
added src/client/match_hud.gd.uid
@@ -0,0 +1 @@
uid://b707ujhpqwjs1
added src/client/match_overlays.gd
@@ -0,0 +1,53 @@
class_name MatchOverlays
extends CanvasLayer
## The match's screen-space UI layer, lifted out of `main.gd` to keep that file under the line
## cap (the same reason PlayerInput and MoveMarker were lifted). It *is* the canvas layer — a
## direct child of the match scene, exactly like the connect menu and the old death layer, the
## proven way a code-built Control draws in screen space over the zoomed 3D camera. It owns the
## four overlays — the hero HUD, the kill feed, the chat box, and the death screen — and drives
## them from one `update` the driver calls each tick, so main holds one field and one call.
##
## The four are added in draw order: HUD, feed, and chat first, the death screen last, so the
## death dim falls over the rest when the player's hero is down. Built only when there is a
## display (main skips it on a headless smoke), so every overlay here may assume a screen.
var hud: MatchHud
var kill_feed: KillFeed
var chat: MatchChat
var death: DeathOverlay
func _ready() -> void:
hud = MatchHud.new()
hud.settings_pressed.connect(_on_settings_pressed)
kill_feed = KillFeed.new()
chat = MatchChat.new()
death = DeathOverlay.new()
# Draw order: the death screen is added last so its dim layers over the HUD when shown.
add_child(hud)
add_child(kill_feed)
add_child(chat)
add_child(death)
## Reconciles every overlay against this tick's world. `focus` is the player's own hero (null
## before it spawns); `team_colors` are the team draw colours indexed by team id, for the kill
## feed; `tick_rate` converts the respawn ticks the death screen counts down into seconds.
func update(
focus: SimEntity, state: SimState, player_team: int, team_colors: Array, tick_rate: int
) -> void:
hud.refresh(focus)
kill_feed.observe(state, player_team, team_colors)
death.set_respawn(focus.respawn_ticks if focus != null else 0, tick_rate)
## Whether the player is typing in chat — the driver reads this to suppress ability casts so a
## letter in a message never fires its QWER bind.
func is_chat_typing() -> bool:
return chat.is_typing()
## The settings button placeholder: there is no in-match settings menu yet, so this only notes
## the click. The menu is a later slice; the entry point is wired so adding it is a swap here.
func _on_settings_pressed() -> void:
print("settings: menu not built yet (placeholder)")
added src/client/match_overlays.gd.uid
@@ -0,0 +1 @@
uid://8tdpcq3pk4v8
added test/unit/test_death_overlay.gd
@@ -0,0 +1,39 @@
extends GutTest
## Behavioural checks on the death screen — the dim + countdown shown while the player's hero
## is down. They verify the countdown shows/hides off the respawn ticks and that the placeholder
## death-recap card is built into the screen, so a regression that drops the recap is caught
## headlessly rather than only in a playtest.
func _overlay() -> DeathOverlay:
var overlay := DeathOverlay.new()
add_child_autoqfree(overlay)
return overlay
# Whether any Label in the subtree carries `text` — the recap card is found by its heading.
func _has_label(node: Node, text: String) -> bool:
if node is Label and (node as Label).text == text:
return true
for child in node.get_children():
if _has_label(child, text):
return true
return false
func test_hidden_when_alive() -> void:
var overlay := _overlay()
overlay.set_respawn(0, 60)
assert_false(overlay.visible, "the death screen hides while the hero is alive")
func test_shows_countdown_when_down() -> void:
var overlay := _overlay()
overlay.set_respawn(120, 60)
assert_true(overlay.visible, "the death screen shows while the hero is down")
assert_eq(overlay._timer_label.text, "Respawning in 2", "the countdown rounds ticks to seconds")
func test_recap_card_is_present() -> void:
var overlay := _overlay()
assert_true(_has_label(overlay, DeathOverlay.RECAP_TITLE), "the death screen carries the recap")
added test/unit/test_death_overlay.gd.uid
@@ -0,0 +1 @@
uid://cbwhindrnrp1k
added test/unit/test_kill_feed.gd
@@ -0,0 +1,31 @@
extends GutTest
## Behavioural checks on the kill feed — the top-right takedown stack. They verify a pushed
## line lands on the feed and that the feed caps its length, dropping the oldest. Expiry by
## timer is time-driven and not asserted here; the cap and the push are the whole of the
## list logic the driver depends on.
func _feed() -> KillFeed:
var feed := KillFeed.new()
add_child_autoqfree(feed)
return feed
func test_push_adds_a_line() -> void:
var feed := _feed()
feed.push("Lion was slain", Color.WHITE)
assert_eq(feed.get_child_count(), 1, "a pushed kill lands on the feed")
func test_push_caps_the_feed_length() -> void:
var feed := _feed()
for i in KillFeed.MAX_ENTRIES + 3:
feed.push("kill %d" % i, Color.WHITE)
assert_eq(feed.get_child_count(), KillFeed.MAX_ENTRIES, "the feed drops the oldest past the cap")
func test_newest_line_sits_on_top() -> void:
var feed := _feed()
feed.push("first", Color.WHITE)
feed.push("second", Color.WHITE)
assert_eq((feed.get_child(0) as Label).text, "second", "the newest line is on top")
added test/unit/test_kill_feed.gd.uid
@@ -0,0 +1 @@
uid://dxgx7sx4u34se
added test/unit/test_match_chat.gd
@@ -0,0 +1,63 @@
extends GutTest
## Behavioural checks on the chat box — the bottom-left all/team chat. They verify the typing
## gate (open / close drives `is_typing`, which the driver reads to suppress casts), the scope
## toggle, and that a sent line is echoed locally and announced while a blank send is dropped.
## Network delivery is a later slice and is not exercised here — the local echo and the gate are
## the whole of the first-pass logic.
func _chat() -> MatchChat:
var chat := MatchChat.new()
add_child_autoqfree(chat)
return chat
func test_starts_not_typing() -> void:
assert_false(_chat().is_typing(), "chat starts closed, so the game keeps the keyboard")
func test_open_enters_typing() -> void:
var chat := _chat()
chat.open()
assert_true(chat.is_typing(), "opening chat captures typing so a key does not also cast")
func test_close_releases_typing() -> void:
var chat := _chat()
chat.open()
chat.close()
assert_false(chat.is_typing(), "closing hands the keyboard back to the game")
func test_toggle_scope_flips_all_and_team() -> void:
var chat := _chat()
assert_eq(chat._scope, MatchChat.Scope.ALL, "chat defaults to all-chat")
chat.toggle_scope()
assert_eq(chat._scope, MatchChat.Scope.TEAM, "toggling switches to team-chat")
chat.toggle_scope()
assert_eq(chat._scope, MatchChat.Scope.ALL, "toggling again switches back")
func test_sending_echoes_locally_and_announces() -> void:
var chat := _chat()
chat.open()
watch_signals(chat)
chat._on_submitted("hello")
assert_eq(chat._log.get_child_count(), 1, "a sent line is echoed into the local log")
assert_signal_emitted_with_parameters(chat, "message_sent", [MatchChat.Scope.ALL, "hello"])
assert_false(chat.is_typing(), "sending closes the input")
func test_blank_send_is_dropped() -> void:
var chat := _chat()
chat.open()
chat._on_submitted(" ")
assert_eq(chat._log.get_child_count(), 0, "a blank line is not logged")
assert_false(chat.is_typing(), "a blank send still closes the input")
func test_log_caps_its_length() -> void:
var chat := _chat()
for i in MatchChat.MAX_LINES + 4:
chat.append_line("You", "line %d" % i, MatchChat.Scope.ALL)
assert_eq(chat._log.get_child_count(), MatchChat.MAX_LINES, "the log drops the oldest line")
added test/unit/test_match_chat.gd.uid
@@ -0,0 +1 @@
uid://dws4g024fnbd1
added test/unit/test_match_hud.gd
@@ -0,0 +1,127 @@
extends GutTest
## Behavioural checks on the in-match HUD — the bottom cluster that surfaces the player's
## own hero (name, form, HP/resource, the QWER cooldown bar). They verify it binds to the
## live entity, hides when there is no hero to show, reads the per-form ability bar, and
## reflects the three cast states (ready, on cooldown, blocked on resource). The HUD owns no
## simulation, so this entity-in / readout-out behaviour is the whole of its logic; the
## floating world bars and the camera are the driver's job, not these unit tests.
const LION_HUMAN_Q := 10 # Sunfire Lash — the Lion's human slot-0 ability id
# Builds the HUD in the scene tree so `_ready` lays out its cluster, and frees it at test end.
func _hud() -> MatchHud:
var hud := MatchHud.new()
add_child_autoqfree(hud)
return hud
# A living, kitted hero straight from the sim: equips the kit so the resource pool, the
# per-form ability bar, and the starting form are all set exactly as a real match seats them.
func _hero(kit: String = "lion") -> SimEntity:
var sim := SimCore.new()
var id := sim.add_hero(0, Vector2.ZERO, 200.0)
sim.equip_kit(id, kit)
return sim.state.get_entity(id)
func test_hides_with_no_hero() -> void:
var hud := _hud()
hud.refresh(null)
assert_false(hud.visible, "the HUD hides when there is no hero to show")
func test_hides_for_a_dead_hero() -> void:
var hud := _hud()
var hero := _hero()
hero.respawn_ticks = 120 # down and counting to respawn — the death screen owns the screen
hud.refresh(hero)
assert_false(hud.visible, "the HUD hides while the hero is down")
func test_shows_for_a_living_hero() -> void:
var hud := _hud()
hud.refresh(_hero())
assert_true(hud.visible, "the HUD shows for a living hero")
func test_settings_button_emits_its_signal() -> void:
var hud := _hud()
watch_signals(hud)
hud._settings_button.pressed.emit()
assert_signal_emitted(hud, "settings_pressed", "the settings button re-emits as settings_pressed")
func test_bottom_cluster_lays_out_on_screen() -> void:
# Guards the layout regression where the bottom frame, pinned by hand-set anchors on a
# Container, collapsed to zero size and the whole HUD bar vanished. Container-pinned now,
# so after a layout pass the frame has a real on-screen size.
var hud := _hud()
hud.refresh(_hero())
await get_tree().process_frame
await get_tree().process_frame
assert_gt(hud._frame.size.x, 0.0, "the ability cluster lays out to a real width")
assert_gt(hud._frame.size.y, 0.0, "the ability cluster lays out to a real height")
func test_binds_name_and_form() -> void:
var hud := _hud()
hud.refresh(_hero("lion"))
assert_eq(hud._name_label.text, "Lion", "the portrait names the hero's kit")
assert_eq(hud._form_label.text, "HUMAN", "a hero starts in human form")
func test_hp_and_resource_fill_full() -> void:
var hud := _hud()
hud.refresh(_hero())
assert_eq(hud._hp["frac"], 1.0, "a fresh hero is at full HP")
assert_eq(hud._resource["frac"], 1.0, "a kitted hero starts with a full pool")
func test_resource_fraction_tracks_the_pool() -> void:
var hud := _hud()
var hero := _hero()
hero.resource = hero.resource_max / 2
hud.refresh(hero)
assert_almost_eq(hud._resource["frac"], 0.5, 0.02, "the resource bar tracks the pool")
func test_human_slots_show_the_kit_abilities() -> void:
var hud := _hud()
hud.refresh(_hero("lion"))
# Lion human form fills Q (Sunfire Lash), W (Mane Guard), R (Lion Form); E is the
# animal form's slot and reads empty here.
assert_eq(hud._cells[0]["name"].text, "Sunfire Lash", "Q carries the human slot-0 ability")
assert_eq(hud._cells[3]["name"].text, "Lion Form", "R carries the transform")
assert_eq(hud._cells[2]["name"].text, "", "E is the other form's slot — empty in human form")
func test_ready_ability_wears_the_ready_border() -> void:
var hud := _hud()
hud.refresh(_hero("lion"))
var style: StyleBoxFlat = hud._cells[0]["style"]
assert_eq(style.border_color, MatchHud.READY_BORDER, "ready when off cooldown and affordable")
func test_cooldown_shows_seconds_and_drains() -> void:
var hud := _hud()
var hero := _hero("lion")
hero.ability_cooldowns[LION_HUMAN_Q] = SimCore.TICK_RATE # one second of cooldown left
hud.refresh(hero)
var cell: Dictionary = hud._cells[0]
assert_true((cell["wash"] as ColorRect).visible, "the cooldown wash shows while recharging")
assert_eq((cell["cooldown"] as Label).text, "1", "the readout rounds the cooldown to seconds")
assert_gt(cell["frac"], 0.0, "the wash fraction tracks the remaining cooldown")
func test_unaffordable_ability_flags_the_resource() -> void:
var hud := _hud()
var hero := _hero("lion")
hero.resource = 0 # off cooldown but the pool is empty
hud.refresh(hero)
var style: StyleBoxFlat = hud._cells[0]["style"]
assert_eq(
style.border_color,
MatchHud.NO_RESOURCE_BORDER,
"an ability blocked only on resource flags the pool, not the timer",
)
added test/unit/test_match_hud.gd.uid
@@ -0,0 +1 @@
uid://dcn6p2eb3shpw