feat(ui): the first-person HUD is one thing, in the game's own hand

The reticle was five white ColorRects, pasted byte-identically into three
level runtime scripts. The vitals were two stock ProgressBars with a flat
colour override, inlined 1200 lines into the movement controller. The ammo
count — the number a shooter's player looks at most — was drawn by the LEVEL,
in a black rounded panel that shared nothing with the menus, and it had a
special case in it (`elif active_weapon is DoubleBarrelShotgun`) because that
weapon never declared a name or a capacity.

Everything that describes A PLAYER now belongs to ui/player_hud.gd, and a
level owns the level. The immediate symptom that fixed: the screen had two
ammo panels on it at once, in two different styles, overlapping in the corner.

What each piece now says, rather than merely shows:

  ui/crosshair.gd    one drawn reticle instead of five rectangles, so it can
                     BLOOM — open with speed, airtime and each shot, snap shut
                     on ADS. That is the accuracy readout of the whole game and
                     five ColorRects could not express it. Every stroke is
                     drawn twice, ink underneath, because a 2 px white line
                     disappears over pale concrete exactly when aim matters.
                     The hit confirmation is the same cross at 45 degrees, so
                     it lands where the eye already is.
  ui/vital_bar.gd    segmented, so remaining health can be COUNTED rather than
                     estimated, with a drain ghost that holds the old value for
                     a beat — the gap between fill and ghost is the size of the
                     hit, which a bar that merely gets shorter never tells you.
  ui/ability_chip.gd dash and grapple as a wipe across a chip rather than a
                     tinted JPEG with a 12 px number under it. A shape changing
                     size is readable in peripheral vision; 12 px type is not.
  chain meter        promoted out of the debug panel. Movement is this game's
                     first stated pillar and chaining is its skill expression,
                     so the count is a score, not a diagnostic.

The numerals moved OFF the bars and beside them. Text centred on a two-tone
bar cannot be given a colour that beats both the fill and the trough — that is
the 2.4:1 debug/ui_contrast_check.gd measured on the old HUD — so this fixes it
at the source rather than leaning on an outline to rescue it.

debug/hud_layout_check.gd measures where every element actually lands, which is
how three real bugs were found rather than squinted at: `set_anchors_preset`
moves the anchors and LEAVES THE OFFSETS, so the reticle spanned the viewport
with a size of exactly (0,0) and drew itself in the top-left corner; a
PRESET_CENTER applied after the ring's own `_ready` undid its centring; and a
BOX CONTAINER's own `alignment` is what pushes content to an edge, not a
SHRINK_END flag on the box, whose minimum width depends on children that may be
hidden. The ammo card was hanging off the right edge of the screen because of
the last one.

DoubleBarrelShotgun now declares `weapon_name` and `max_shells` like every
other weapon, and the four inline `2`s are gone.

spawn smoke 0 failures, 11/11 movement tests, contrast 108/108, layout PASS.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-28 11:51:22 -04:00
co-authored by Claude Opus 5
parent 414026f001
commit c4bcbc7fd1
17 changed files with 1396 additions and 1086 deletions
+110
View File
@@ -0,0 +1,110 @@
extends SceneTree
## Dev tool: build a PlayerHUD against a stand-in player, let it lay out, and
## print where every element actually landed.
##
## godot --headless --path . -s res://debug/hud_layout_check.gd
##
## A HUD element that lands at (0, 0) with zero size is the single most common
## thing to get wrong here, and it is invisible in a headless smoke test and
## easy to miss in a screenshot. This prints the rects so the answer is a number.
var _frames := 0
var _hud: PlayerHUD
func _initialize() -> void:
var stand_in := CharacterBody3D.new()
stand_in.name = "StandInPlayer"
root.add_child(stand_in)
_hud = PlayerHUD.new()
_hud.player = stand_in
root.add_child(_hud)
func _process(_delta: float) -> bool:
_frames += 1
# A hidden Control keeps its last rect and contributes nothing to its
# container's minimum size, so measuring the ammo card while the stand-in
# player holds no weapon reads a stale position from a collapsed column and
# reports a layout bug that is not there. Force the states that matter to be
# visible, then give the containers a frame to re-sort before measuring.
if _frames == 4:
# Freeze the HUD first. Its own `_process` hides the ammo card whenever
# the player holds no weapon, so anything forced visible here is undone
# again before the measurement and what gets read is the stale rect of a
# hidden control inside a collapsed column.
_hud.set_process(false)
_hud._ammo_card.visible = true
_hud._ammo_num.text = "30"
_hud._ammo_max.text = "/ 30"
_hud._weapon_name.text = "DOUBLE BARREL SHOTGUN"
_hud._chain_row.visible = true
for pip in _hud._grenade_row.get_children():
(pip as Control).visible = true
if _frames < 8:
return false
print("viewport = ", root.get_visible_rect().size)
if OS.has_environment("HUD_TREE"):
_dump(_hud, 0)
var fails := 0
for node in _hud.find_children("*", "Control", true, false):
var c := node as Control
# Only the elements that are supposed to occupy space. Containers that
# legitimately shrink to their content are not interesting.
if c.name in ["Crosshair", "ReloadRing", "DeathScreen"]:
var r := c.get_global_rect() # GLOBAL: get_rect() is parent-relative and lies about where a nested element really sits
print(" %-12s pos=%s size=%s" % [c.name, r.position, r.size])
if r.size.x < 100.0 or r.size.y < 100.0:
print(" FAIL: expected to fill the viewport")
fails += 1
# The ammo card, which must hug the RIGHT edge inside its margin. It sat
# flush against the left instead, because a VBoxContainer hands each child
# its own horizontal placement and the alignment flag was on the column.
var vr := root.get_visible_rect().size
var card := _find_by_class("PanelContainer")
if card == null:
print(" MISSING ammo card")
fails += 1
else:
var r := card.get_global_rect()
var right_gap := vr.x - (r.position.x + r.size.x)
print(" %-12s pos=%s size=%s right_gap=%.0f"
% ["ammo", r.position, r.size, right_gap])
if r.size.x < 60.0:
print(" FAIL: collapsed to nothing")
fails += 1
elif right_gap < 0.0:
print(" FAIL: runs off the right edge of the screen")
fails += 1
elif right_gap > 120.0:
print(" FAIL: not anchored to the right edge (gap %.0f)" % right_gap)
fails += 1
print("HUD LAYOUT: %s" % ("PASS" if fails == 0 else "FAIL (%d)" % fails))
quit(1 if fails > 0 else 0)
return true
## The whole Control tree with GLOBAL rects. `HUD_TREE=1` to switch on — when a
## container collapses, the answer is always visible in its parent chain and
## never in the leaf you noticed the problem on.
func _dump(node: Node, depth: int) -> void:
for child in node.get_children():
if child is Control:
var c := child as Control
var r := c.get_global_rect()
print("%s%s [%s] pos=%s size=%s vis=%s"
% [" ".repeat(depth + 1), c.name, c.get_class(),
r.position, r.size, c.visible])
_dump(child, depth + 1)
func _find_by_class(cls: String) -> Control:
for n in _hud.find_children("*", cls, true, false):
return n as Control
return null