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
+682
View File
@@ -0,0 +1,682 @@
extends CanvasLayer
class_name PlayerHUD
## The first-person HUD, in the same hand as the rest of the game.
##
## It is one place now. The reticle used to be five white ColorRects pasted into
## three different level runtimes, the vitals were two stock ProgressBars with a
## flat colour override buried 1200 lines into the movement controller, and the
## ammo count — the single number a shooter's player looks at most — was not on
## screen at all. Nothing shared the ink edge, the lean or the palette that every
## menu in the game is built from, so crossing from the main menu into a match
## looked like crossing into a different product.
##
## The layout follows the one rule that matters for a HUD: the player is looking
## at the CENTRE of the screen, so everything the HUD says is arranged by how
## urgently it needs to interrupt that.
##
## centre the reticle, and confirmations, and the reload ring. Read
## constantly, without moving the eye.
## bottom left vitals, and the movement chain above them. Glanced at between
## engagements.
## bottom right ammo, grenades, ability cooldowns. Same.
## top left the debug readout, and only when a setting asks for it.
## nowhere else everything that is not one of those things.
##
## Match state — timer, score, killfeed, scoreboard — belongs to ui/match_hud.gd
## and is deliberately not duplicated here.
##
## ── Why this owns the abilities and the chain ────────────────────────────────
##
## Those used to be built by the LEVEL, in three separate runtime scripts that
## each carried their own byte-identical copy of a black rounded panel. A level
## cannot know a player's dash cooldown without reaching down into that player's
## state machine every frame, which is what all three did, and it cannot show the
## right thing in a split-screen or spectator case at all. More immediately: the
## level's ammo panel and this one's both existed, so the screen showed the ammo
## count twice, in two different styles, overlapping.
##
## The rule is that anything describing THE PLAYER belongs to the player's HUD,
## and a level owns the level.
## The player this HUD belongs to. Set before adding to the tree.
var player: Node = null
var crosshair: Crosshair
var reload_ring: ReloadRing
var death_screen: Control
var _health: VitalBar
var _shield: VitalBar
var _health_num: Label
var _shield_num: Label
var _shield_row: Control
var _ammo_num: Label
var _ammo_max: Label
var _weapon_name: Label
var _ammo_card: PanelContainer
var _grenade_row: HBoxContainer
var _chain_row: Control
var _chain_num: Label
var _chain_bonus: Label
var _shown_chain: int = 0
var _dash_chip: AbilityChip
var _grapple_chip: AbilityChip
var _debug_box: VBoxContainer
var _fps_line: Label
var _speed_line: Label
var _state_line: Label
var _wman: Node = null
var _machine: Node = null
## Crosshair bloom sources, all 0..1, combined as the largest rather than the sum
## so a sprinting player who fires does not blow the reticle off the screen.
var _fire_bloom: float = 0.0
var _shown_ammo: int = -1
## Speed at which movement alone fully blooms the reticle.
const BLOOM_SPEED := 14.0
## How fast a shot's bloom recovers. Roughly a fifth of a second, which is short
## enough to keep pace with an automatic weapon.
const FIRE_RECOVER := 5.0
const SPREAD_SMOOTH := 12.0
var _spread: float = 0.0
## Below this fraction of a magazine the ammo readout goes volt and the count
## starts to matter.
const LOW_AMMO := 0.34
func _ready() -> void:
layer = 1 # above the weapon viewmodel's canvas, which sits at 0
UITheme.apply_global(get_tree())
_build_reticle()
_build_vitals()
_build_ammo()
_build_abilities()
_build_debug()
_build_death_screen()
# ── Public API ───────────────────────────────────────────────────────────────
func set_vitals(hp: float, hp_max: float, sh: float, sh_max: float) -> void:
_health.max_value = hp_max
_health.value = hp
_health_num.text = str(int(ceil(hp)))
# Health goes volt-tinted when it is the last thing standing between the
# player and a respawn. The bar breathes at the same threshold.
var frac: float = hp / maxf(hp_max, 1.0)
_health_num.add_theme_color_override("font_color",
UITheme.VOLT if frac <= VitalBar.LOW_AT else UITheme.PAPER)
_shield.max_value = sh_max
_shield.value = sh
_shield_num.text = str(int(ceil(sh)))
# A depleted shield is not information worth a permanent row — it hides, and
# reappears the moment it starts recharging. One less thing on screen.
_shield_row.visible = sh > 0.5
## A landed shot. `fatal` gets the louder kill confirmation.
func confirm_hit(fatal: bool = false) -> void:
if crosshair:
crosshair.confirm(fatal)
# ── Build ────────────────────────────────────────────────────────────────────
func _build_reticle() -> void:
crosshair = Crosshair.new()
crosshair.name = "Crosshair"
add_child(crosshair)
reload_ring = load("res://ui/reload_ring.gd").new()
reload_ring.name = "ReloadRing"
# Deliberately NOT re-anchored here. The ring centres itself inside its own
# full rect; a PRESET_CENTER here ran after its `_ready` and undid that,
# which is how it ended up as a zero-sized control at (960, 960).
# The ring sits outside the reticle's bloom so a reload in progress never
# obscures the ticks, and it is papaya rather than white so it belongs.
reload_ring.radius = 34.0
reload_ring.thickness = 5.0
reload_ring.color = UITheme.PAPAYA
add_child(reload_ring)
func _build_vitals() -> void:
# Full rect plus margins — see the note in `_build_ammo` for why a corner
# preset collapses.
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 34)
margin.add_theme_constant_override("margin_bottom", 30)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
# Row pushes left, column pushes down — see the note in `_build_ammo`.
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_BEGIN
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
var col := VBoxContainer.new()
col.alignment = BoxContainer.ALIGNMENT_END
col.size_flags_vertical = Control.SIZE_SHRINK_END
col.add_theme_constant_override("separation", 8)
col.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(col)
_build_chain(col)
# Shield above health: it is the layer that goes first, so it reads as the
# outer one. Its whole row hides when empty rather than sitting at zero.
var sh := _vital_row("SHIELD", UITheme.CYAN, 30, 200.0)
_shield = sh[0]
_shield_num = sh[1]
_shield_row = sh[2]
col.add_child(_shield_row)
var hp := _vital_row("HEALTH", UITheme.PAPAYA, 46, 250.0)
_health = hp[0]
_health_num = hp[1]
col.add_child(hp[2])
## One vital: a tag, the bar, and the numeral BESIDE the bar.
##
## Beside, not centred on it — see the note in VitalBar. Returns
## `[bar, numeral, row]`, because the caller needs all three.
func _vital_row(tag: String, col: Color, num_size: int, width: float) -> Array:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
var stack := VBoxContainer.new()
stack.add_theme_constant_override("separation", 1)
row.add_child(stack)
var label := Label.new()
label.text = tag
label.add_theme_font_size_override("font_size", 15)
label.add_theme_color_override("font_color", col)
label.add_theme_color_override("font_outline_color", UITheme.INK)
label.add_theme_constant_override("outline_size", 5)
stack.add_child(label)
var bar := VitalBar.new()
bar.fill_color = col
bar.custom_minimum_size = Vector2(width, 22)
stack.add_child(bar)
var num := Label.new()
num.text = "100"
num.add_theme_font_size_override("font_size", num_size)
num.add_theme_color_override("font_color", UITheme.PAPER)
num.add_theme_color_override("font_outline_color", UITheme.INK)
num.add_theme_constant_override("outline_size", 9)
num.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
row.add_child(num)
return [bar, num, row]
func _build_ammo() -> void:
# FULL RECT plus margins, not a corner preset.
#
# A MarginContainer anchored to BOTTOM_RIGHT has zero size and grows from the
# corner, so its `margin_right` pushes the content INTO a container that is
# not there — the card collapsed to a sliver hanging off the right edge of
# the screen with the ammo count clipped inside it. Owning the whole rect and
# aligning to the end is unambiguous, and it is what the ability row does.
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_right", 34)
margin.add_theme_constant_override("margin_bottom", 30)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
# A BoxContainer's own `alignment` is the reliable way to push content to an
# edge; a SHRINK_END size flag on the box itself is not, because the box's
# minimum width depends on children that may be hidden and it ends up placed
# by its own START. So the corner is built out of two boxes, each aligning on
# the axis it actually controls: the row pushes right, the column pushes
# down. This is the same scaffold the ability row uses.
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_END
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
var col := VBoxContainer.new()
col.alignment = BoxContainer.ALIGNMENT_END
col.size_flags_vertical = Control.SIZE_SHRINK_END
col.add_theme_constant_override("separation", 6)
col.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(col)
# Grenades as pips rather than a number: three of a thing is countable, and
# it matches the segmented vitals rather than introducing a second idiom.
_grenade_row = HBoxContainer.new()
_grenade_row.alignment = BoxContainer.ALIGNMENT_END
_grenade_row.add_theme_constant_override("separation", 5)
col.add_child(_grenade_row)
_ammo_card = UITheme.card()
_ammo_card.mouse_filter = Control.MOUSE_FILTER_IGNORE
col.add_child(_ammo_card)
var inner := VBoxContainer.new()
inner.alignment = BoxContainer.ALIGNMENT_END
inner.add_theme_constant_override("separation", 0)
_ammo_card.add_child(inner)
_weapon_name = Label.new()
_weapon_name.text = ""
_weapon_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_weapon_name.add_theme_font_size_override("font_size", 18)
_weapon_name.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_weapon_name.add_theme_color_override("font_outline_color", UITheme.INK)
_weapon_name.add_theme_constant_override("outline_size", 5)
inner.add_child(_weapon_name)
# The two halves of the count are separate labels at different sizes, so the
# magazine reads as the number and the reserve reads as context. One
# "12 / 30" at a single size makes the player parse a string.
var count := HBoxContainer.new()
count.alignment = BoxContainer.ALIGNMENT_END
count.add_theme_constant_override("separation", 4)
inner.add_child(count)
_ammo_num = Label.new()
_ammo_num.text = "--"
_ammo_num.add_theme_font_size_override("font_size", 58)
_ammo_num.add_theme_color_override("font_color", UITheme.PAPER)
_ammo_num.add_theme_color_override("font_outline_color", UITheme.INK)
_ammo_num.add_theme_constant_override("outline_size", 10)
_ammo_num.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
count.add_child(_ammo_num)
_ammo_max = Label.new()
_ammo_max.text = ""
_ammo_max.add_theme_font_size_override("font_size", 24)
_ammo_max.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_ammo_max.add_theme_color_override("font_outline_color", UITheme.INK)
_ammo_max.add_theme_constant_override("outline_size", 6)
_ammo_max.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
count.add_child(_ammo_max)
## Dash and grapple, above the ammo card on the right.
##
## Right-hand side because they are the other half of "what can I do right now",
## which is the question the ammo count answers. Putting cooldowns on the left
## with the vitals would split that question across the screen.
func _build_abilities() -> void:
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_right", 34)
margin.add_theme_constant_override("margin_bottom", 190)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_END
row.size_flags_vertical = Control.SIZE_SHRINK_END
row.add_theme_constant_override("separation", 8)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
_grapple_chip = AbilityChip.new()
_grapple_chip.label = "GRAPPLE"
_grapple_chip.custom_minimum_size = Vector2(116, 34)
row.add_child(_grapple_chip)
_dash_chip = AbilityChip.new()
_dash_chip.label = "DASH"
row.add_child(_dash_chip)
## The movement chain, above the vitals.
##
## Movement is this game's stated first pillar and chaining mechanics is its
## skill expression, so the chain count is not a debug readout — it is the score
## of the thing the game is about, and it belongs on the HUD at a size that says
## so. It hides at zero, because a chain of nothing is not worth screen space.
func _build_chain(into: Container) -> void:
_chain_row = HBoxContainer.new()
_chain_row.add_theme_constant_override("separation", 8)
_chain_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chain_row.visible = false
into.add_child(_chain_row)
_chain_num = Label.new()
_chain_num.text = "x0"
_chain_num.add_theme_font_size_override("font_size", 40)
_chain_num.add_theme_color_override("font_color", UITheme.VOLT)
_chain_num.add_theme_color_override("font_outline_color", UITheme.INK)
_chain_num.add_theme_constant_override("outline_size", 9)
_chain_num.pivot_offset = Vector2(20, 20)
_chain_row.add_child(_chain_num)
_chain_bonus = Label.new()
_chain_bonus.text = ""
_chain_bonus.add_theme_font_size_override("font_size", 22)
_chain_bonus.add_theme_color_override("font_color", UITheme.PAPAYA)
_chain_bonus.add_theme_color_override("font_outline_color", UITheme.INK)
_chain_bonus.add_theme_constant_override("outline_size", 7)
_chain_bonus.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_chain_row.add_child(_chain_bonus)
## FPS / speed / state, top left, and only when a setting asks for them.
##
## Restyled rather than deleted: they are genuinely useful, and in a game about
## momentum the speed readout is arguably gameplay. What they are not is a reason
## to have neon green on pure black in the corner of an ink-drawn game, which is
## what three copies of this used to be.
func _build_debug() -> void:
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
margin.add_theme_constant_override("margin_left", 18)
margin.add_theme_constant_override("margin_top", 14)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
_debug_box = VBoxContainer.new()
_debug_box.add_theme_constant_override("separation", 0)
_debug_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(_debug_box)
_fps_line = UITheme.caption("", 22)
_fps_line.add_theme_color_override("font_color", UITheme.VOLT)
_debug_box.add_child(_fps_line)
_speed_line = UITheme.caption("", 22)
_speed_line.add_theme_color_override("font_color", UITheme.CYAN)
_debug_box.add_child(_speed_line)
_state_line = UITheme.caption("", 18)
_debug_box.add_child(_state_line)
func _build_death_screen() -> void:
death_screen = Control.new()
death_screen.name = "DeathScreen"
death_screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
death_screen.mouse_filter = Control.MOUSE_FILTER_IGNORE
death_screen.visible = false
add_child(death_screen)
# Ink wash rather than plain black: the whole game's darkness is a violet
# near-black, and a neutral 70% black over it reads as a bug.
var wash := ColorRect.new()
wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.78)
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
death_screen.add_child(wash)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
death_screen.add_child(center)
var col := VBoxContainer.new()
col.alignment = BoxContainer.ALIGNMENT_CENTER
col.add_theme_constant_override("separation", 10)
center.add_child(col)
var title := UITheme.title("DOWNED", 104, -3.0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(title)
var rule := UITheme.divider(0.5)
rule.custom_minimum_size = Vector2(520, 18)
col.add_child(rule)
var prompt := UITheme.heading("PRESS FIRE TO REDEPLOY", 30)
prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(prompt)
var tw := create_tween().set_loops()
tw.set_trans(Tween.TRANS_SINE)
tw.tween_property(prompt, "modulate:a", 0.25, 0.7)
tw.tween_property(prompt, "modulate:a", 1.0, 0.7)
# ── Per-frame ────────────────────────────────────────────────────────────────
func _process(delta: float) -> void:
if not is_instance_valid(player):
return
_update_weapon(delta)
_update_crosshair(delta)
_update_movement()
_update_debug()
## Chain count and ability cooldowns, both read off the movement state machine.
func _update_movement() -> void:
var sm := _state_machine()
if sm == null:
return
var chain := int(sm.chain_count) if "chain_count" in sm else 0
_chain_row.visible = chain > 0
if chain > 0:
_chain_num.text = "x%d" % chain
var bonus: float = float(sm.current_chain_bonus) if "current_chain_bonus" in sm else 0.0
_chain_bonus.text = "+%d%% SPEED" % int(round(bonus * 100.0))
# Each new link punches the number up and lets it settle. Momentum is the
# thing this game rewards, so extending a chain should feel like landing
# something rather than like a counter incrementing.
if chain > _shown_chain:
var tw := create_tween()
tw.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
_chain_num.scale = Vector2(1.35, 1.35)
tw.tween_property(_chain_num, "scale", Vector2.ONE, 0.28)
_shown_chain = chain
var state := String(sm.current_state) if "current_state" in sm else ""
var shooting: bool = "is_grapple_shooting" in sm and sm.is_grapple_shooting
_grapple_chip.active = state == "grapple" or shooting
if sm.has_method("get_dash_cooldown_remaining"):
var rem: float = sm.get_dash_cooldown_remaining()
# Normalised against the params' own cooldown, so retuning the dash
# retunes the readout with it instead of leaving the wipe lying.
var total := 1.0
if "params" in player and player.params and "dash_cooldown" in player.params:
total = maxf(float(player.params.dash_cooldown), 0.001)
_dash_chip.cooldown = clampf(rem / total, 0.0, 1.0)
func _update_debug() -> void:
# Resolved through the tree rather than by the autoload's global identifier.
# A tool script launched with `-s` compiles its dependencies BEFORE autoloads
# are registered, so naming `SettingsManager` directly makes this whole file
# fail to compile under debug/hud_layout_check.gd — which is precisely the
# harness that has to be able to load it.
var settings := _settings()
if settings == null:
return
var sm := _state_machine()
var show_debug: bool = settings.show_debug_ui
_fps_line.visible = settings.show_fps
if _fps_line.visible:
_fps_line.text = "FPS %d" % Engine.get_frames_per_second()
_speed_line.visible = settings.show_movement_speed or show_debug
if _speed_line.visible:
var v: Vector3 = player.velocity if "velocity" in player else Vector3.ZERO
var h := Vector2(v.x, v.z).length()
_speed_line.text = "%.1f m/s" % h if not show_debug \
else "%.1f m/s (total %.1f)" % [h, v.length()]
_state_line.visible = show_debug and sm != null
if _state_line.visible:
_state_line.text = String(sm.current_state).to_upper()
## Ammo, weapon name and grenades, read off whatever is in the player's hands.
##
## Read rather than pushed, because the weapon is the authority on its own ammo
## and there are eleven weapon scripts. A HUD that had to be notified would mean
## eleven places to forget to notify it.
func _update_weapon(_delta: float) -> void:
var w := _active_weapon()
if w == null:
_ammo_card.visible = false
return
_ammo_card.visible = true
# Named by the weapon, or derived from its script if it forgot to say. A HUD
# that silently shows a blank where the weapon's name goes is worse than one
# that shows a slightly ugly name.
if "weapon_name" in w and String(w.weapon_name) != "":
_weapon_name.text = String(w.weapon_name).to_upper()
else:
var src: Script = w.get_script()
_weapon_name.text = src.resource_path.get_file().get_basename() \
.replace("_", " ").to_upper() if src else "WEAPON"
# The shotgun counts shells, everything else counts rounds. Both are "how
# many more times can I pull the trigger", which is the only question the
# number answers.
var now := -1
var cap := -1
if "current_ammo" in w:
now = int(w.current_ammo)
cap = int(w.max_ammo) if "max_ammo" in w else -1
elif "shells" in w:
now = int(w.shells)
cap = int(w.max_shells) if "max_shells" in w else -1
if now < 0:
# A melee weapon has no count. Showing "0" would read as empty.
_ammo_num.text = ""
_ammo_max.text = ""
else:
_ammo_num.text = str(now)
_ammo_max.text = "/ %d" % cap if cap > 0 else ""
var frac: float = float(now) / float(maxi(cap, 1))
var reloading: bool = "reloading" in w and w.reloading
var col: Color = UITheme.PAPER
if now == 0:
col = UITheme.MAGENTA
elif frac <= LOW_AMMO:
col = UITheme.VOLT
if reloading:
col = UITheme.PAPER_DIM
_ammo_num.add_theme_color_override("font_color", col)
# A shot fired blooms the reticle. Detected from the count dropping
# rather than from a signal, for the same reason the count is read.
if _shown_ammo >= 0 and now < _shown_ammo:
_fire_bloom = 1.0
_shown_ammo = now
_update_grenades()
## Grenades as pips, rebuilt only when the count changes.
##
## Built once and then shown/hidden rather than freed, because `queue_free` is
## deferred: a loop that frees down to a target count sees the same child count
## on the next iteration and frees the whole row.
func _update_grenades() -> void:
var count := int(player.grenades) if "grenades" in player else 0
while _grenade_row.get_child_count() < maxi(count, _grenade_pips):
var pip := Panel.new()
pip.custom_minimum_size = Vector2(17, 17)
pip.mouse_filter = Control.MOUSE_FILTER_IGNORE
# A filled chip with the theme's ink edge, so a 17 px pip still reads
# against a bright skybox.
pip.add_theme_stylebox_override("panel", UITheme.row(UITheme.CYAN))
_grenade_row.add_child(pip)
for i in _grenade_row.get_child_count():
(_grenade_row.get_child(i) as Control).visible = i < count
## The most pips ever needed. Grenades only ever go down during a life and back
## up on respawn, so this is the starting count.
const _grenade_pips := 3
## Reticle bloom and the ADS blend.
##
## Sources are combined with `max`, not by adding: a sprinting player who fires
## should see the reticle at its bloomed size, not at twice it.
func _update_crosshair(delta: float) -> void:
_fire_bloom = maxf(_fire_bloom - delta * FIRE_RECOVER, 0.0)
var speed := 0.0
if "velocity" in player:
var v: Vector3 = player.velocity
speed = Vector2(v.x, v.z).length()
var move := clampf(speed / BLOOM_SPEED, 0.0, 1.0)
var state := String(player.synced_movement_state) if "synced_movement_state" in player else ""
# Airborne is the least accurate a player can be, and a reticle that says so
# is what stops mid-air spraying from feeling arbitrary.
var air := 0.75 if state in ["air", "dash", "grapple"] else 0.0
var want: float = maxf(maxf(move, air), _fire_bloom)
_spread = lerpf(_spread, want, 1.0 - exp(-SPREAD_SMOOTH * delta))
crosshair.spread = _spread
var ads := 0.0
if "synced_is_ads" in player and player.synced_is_ads:
ads = 1.0
crosshair.ads = lerpf(crosshair.ads, ads, 1.0 - exp(-14.0 * delta))
# The reload ring reads the weapon directly, same as the ammo count.
var w := _active_weapon()
if w and "reloading" in w and w.reloading and "reload_timer" in w \
and "reload_time" in w and w.reload_time > 0.0:
reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time)
else:
reload_ring.progress = 0.0
## The settings autoload, or null when the HUD is being exercised outside a
## running game. See the note in `_update_debug`.
func _settings() -> Node:
if not is_instance_valid(_settings_node):
_settings_node = get_tree().root.get_node_or_null("SettingsManager")
return _settings_node if is_instance_valid(_settings_node) else null
var _settings_node: Node = null
## The player's movement state machine, or null. Cached, since it is asked for
## several times a frame and never moves.
func _state_machine() -> Node:
if not is_instance_valid(_machine):
_machine = player.get_node_or_null("MovementStateMachine")
return _machine if is_instance_valid(_machine) else null
## Whatever weapon is in the player's hands, or null.
func _active_weapon() -> Node:
if not is_instance_valid(_wman):
var cam = player.get("camera") if "camera" in player else null
if is_instance_valid(cam):
_wman = cam.get_node_or_null("WeaponManager")
if not is_instance_valid(_wman):
return null
var slot = _wman.get("active_slot")
if slot == null or not _wman.weapons.has(slot):
return null
var w = _wman.weapons[slot]
return w if is_instance_valid(w) else null