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
+1
View File
@@ -0,0 +1 @@
uid://c340r7urelhhx
+5 -307
View File
@@ -4,18 +4,7 @@ class_name TestLevelBuilder
## Builds a full test environment from code — parkour geometry, lighting, player, HUD.
## Attach to TestLevel root, press F5.
var _speed_label: Label
var _state_label: Label
var _chain_label: Label
var _weapon_label: Label
var _grapple_icon: TextureRect
var _dash_icon: TextureRect
var _grapple_label: Label
var _dash_label: Label
var _fps_label: Label
var _standalone_speed_label: Label
var _player: CharacterBody3D
var _debug_ui_panel: PanelContainer
func _ready() -> void:
@@ -24,7 +13,6 @@ func _ready() -> void:
_build_materials()
_build_geometry()
_build_hud()
# Multiplayer Spawning
var spawner = MultiplayerSpawner.new()
@@ -417,298 +405,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
return player
# ── HUD ───────────────────────────────────────────────────────────────────────
func _build_hud() -> void:
var canvas := CanvasLayer.new()
canvas.name = "UI"
add_child(canvas)
# ── Crosshair ─────────────────────────────────────────────────────────
var crosshair := Control.new()
crosshair.name = "Crosshair"
crosshair.set_anchors_preset(Control.PRESET_CENTER)
crosshair.custom_minimum_size = Vector2(20, 20)
canvas.add_child(crosshair)
var ch_dot := ColorRect.new()
ch_dot.name = "Dot"
ch_dot.color = Color(1, 1, 1, 0.8)
ch_dot.size = Vector2(4, 4)
ch_dot.position = Vector2(-2, -2)
crosshair.add_child(ch_dot)
# Crosshair lines
for data in [
{"pos": Vector2(-10, -1), "size": Vector2(6, 2)}, # Left
{"pos": Vector2(4, -1), "size": Vector2(6, 2)}, # Right
{"pos": Vector2(-1, -10), "size": Vector2(2, 6)}, # Top
{"pos": Vector2(-1, 4), "size": Vector2(2, 6)}, # Bottom
]:
var line := ColorRect.new()
line.color = Color(1, 1, 1, 0.6)
line.position = data["pos"]
line.size = data["size"]
crosshair.add_child(line)
# ── Info panel background ─────────────────────────────────────────────
_fps_label = Label.new()
_fps_label.name = "FPSLabel"
_fps_label.text = "FPS: 0"
_fps_label.add_theme_font_size_override("font_size", 24)
_fps_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0.2))
_fps_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_fps_label.add_theme_constant_override("outline_size", 4)
_fps_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_fps_label.position = Vector2(12, 12)
canvas.add_child(_fps_label)
_standalone_speed_label = Label.new()
_standalone_speed_label.name = "StandaloneSpeedLabel"
_standalone_speed_label.text = "Speed: 0.0 m/s"
_standalone_speed_label.add_theme_font_size_override("font_size", 24)
_standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
_standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_standalone_speed_label.add_theme_constant_override("outline_size", 4)
_standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_standalone_speed_label.position = Vector2(12, 45)
canvas.add_child(_standalone_speed_label)
_debug_ui_panel = PanelContainer.new()
_debug_ui_panel.name = "InfoPanel"
_debug_ui_panel.offset_left = 12
_debug_ui_panel.offset_top = 50
_debug_ui_panel.offset_right = 400
_debug_ui_panel.offset_bottom = 160
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0, 0, 0, 0.55)
panel_style.corner_radius_top_left = 8
panel_style.corner_radius_top_right = 8
panel_style.corner_radius_bottom_left = 8
panel_style.corner_radius_bottom_right = 8
panel_style.content_margin_left = 12
panel_style.content_margin_top = 8
panel_style.content_margin_right = 12
panel_style.content_margin_bottom = 8
_debug_ui_panel.add_theme_stylebox_override("panel", panel_style)
canvas.add_child(_debug_ui_panel)
var vbox := VBoxContainer.new()
vbox.name = "InfoVBox"
_debug_ui_panel.add_child(vbox)
_speed_label = Label.new()
_speed_label.name = "SpeedLabel"
_speed_label.text = "Speed: 0.0 m/s"
_speed_label.add_theme_font_size_override("font_size", 18)
_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
vbox.add_child(_speed_label)
_state_label = Label.new()
_state_label.name = "StateLabel"
_state_label.text = "State: ground"
_state_label.add_theme_font_size_override("font_size", 16)
_state_label.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0))
vbox.add_child(_state_label)
_chain_label = Label.new()
_chain_label.name = "ChainLabel"
_chain_label.text = "Chain: 0 (+0%)"
_chain_label.add_theme_font_size_override("font_size", 16)
_chain_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.3))
vbox.add_child(_chain_label)
# ── Weapon & Ammo Panel ───────────────────────────────────────────────
var wp_panel := PanelContainer.new()
wp_panel.name = "WeaponPanel"
wp_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
wp_panel.offset_left = -250
wp_panel.offset_top = -100
wp_panel.offset_right = -20
wp_panel.offset_bottom = -20
var wp_style := StyleBoxFlat.new()
wp_style.bg_color = Color(0, 0, 0, 0.6)
wp_style.corner_radius_top_left = 8
wp_style.corner_radius_top_right = 8
wp_style.corner_radius_bottom_left = 8
wp_style.corner_radius_bottom_right = 8
wp_style.content_margin_left = 16
wp_style.content_margin_top = 12
wp_style.content_margin_right = 16
wp_style.content_margin_bottom = 12
wp_panel.add_theme_stylebox_override("panel", wp_style)
wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(wp_panel)
_weapon_label = Label.new()
_weapon_label.name = "WeaponLabel"
_weapon_label.text = "Unarmed\n0 / 0"
_weapon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_weapon_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_weapon_label.add_theme_font_size_override("font_size", 24)
wp_panel.add_child(_weapon_label)
# ── Utilities Panel ───────────────────────────────────────────────────────
var util_panel := PanelContainer.new()
util_panel.name = "UtilPanel"
util_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
util_panel.offset_left = -200
util_panel.offset_top = -180
util_panel.offset_right = -20
util_panel.offset_bottom = -110
var util_style := StyleBoxFlat.new()
util_style.bg_color = Color(0, 0, 0, 0.6)
util_style.corner_radius_top_left = 8
util_style.corner_radius_top_right = 8
util_style.corner_radius_bottom_left = 8
util_style.corner_radius_bottom_right = 8
util_style.content_margin_left = 12
util_style.content_margin_top = 8
util_style.content_margin_right = 12
util_style.content_margin_bottom = 8
util_panel.add_theme_stylebox_override("panel", util_style)
util_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(util_panel)
var util_hbox := HBoxContainer.new()
util_hbox.name = "UtilHBox"
util_hbox.add_theme_constant_override("separation", 20)
util_hbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_panel.add_child(util_hbox)
var grapple_vbox := VBoxContainer.new()
grapple_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(grapple_vbox)
_grapple_icon = TextureRect.new()
_grapple_icon.texture = load("res://assets/ui/grapple_icon.jpg")
_grapple_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_grapple_icon.custom_minimum_size = Vector2(32, 32)
_grapple_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
grapple_vbox.add_child(_grapple_icon)
_grapple_label = Label.new()
_grapple_label.text = "Grapple"
_grapple_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_grapple_label.add_theme_font_size_override("font_size", 12)
grapple_vbox.add_child(_grapple_label)
var dash_vbox := VBoxContainer.new()
dash_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(dash_vbox)
_dash_icon = TextureRect.new()
_dash_icon.texture = load("res://assets/ui/dash_icon.jpg")
_dash_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_dash_icon.custom_minimum_size = Vector2(32, 32)
_dash_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
dash_vbox.add_child(_dash_icon)
_dash_label = Label.new()
_dash_label.text = "Ready"
_dash_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_dash_label.add_theme_font_size_override("font_size", 12)
dash_vbox.add_child(_dash_label)
# ── Utility: Key Name ─────────────────────────────────────────────────────────
func _get_key_name(action: String) -> String:
if not InputMap.has_action(action):
return "?"
var events = InputMap.action_get_events(action)
for e in events:
if e is InputEventKey:
var code = e.physical_keycode if e.physical_keycode != 0 else e.keycode
return OS.get_keycode_string(code)
elif e is InputEventMouseButton:
if e.button_index == MOUSE_BUTTON_LEFT: return "LClick"
elif e.button_index == MOUSE_BUTTON_RIGHT: return "RClick"
elif e.button_index == MOUSE_BUTTON_MIDDLE: return "MClick"
return "?"
# ── HUD Update ────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
if not _player or not is_instance_valid(_player):
return
if _debug_ui_panel:
_debug_ui_panel.visible = SettingsManager.show_debug_ui
if _fps_label:
_fps_label.visible = SettingsManager.show_fps
if _fps_label.visible:
_fps_label.text = "FPS: %d" % Engine.get_frames_per_second()
var vel: Vector3 = _player.velocity
var hspeed := Vector2(vel.x, vel.z).length()
var total_speed := vel.length()
if _speed_label:
_speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed]
if _standalone_speed_label:
if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui:
_standalone_speed_label.visible = true
_standalone_speed_label.text = "Speed: %.1f m/s" % hspeed
if _fps_label and _fps_label.visible:
_standalone_speed_label.position = Vector2(12, 45)
else:
_standalone_speed_label.position = Vector2(12, 12)
else:
_standalone_speed_label.visible = false
if _state_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_state_label.text = "State: %s" % sm.current_state
if _chain_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_chain_label.text = "Chain: %d (+%d%%)" % [sm.chain_count, int(sm.current_chain_bonus * 100)]
# Update utility indicators
if sm.current_state == "grapple" or sm.is_grapple_shooting:
_grapple_icon.modulate = Color(0.2, 1.0, 0.4)
_grapple_label.text = "Grappling"
else:
_grapple_icon.modulate = Color(1.0, 1.0, 1.0)
_grapple_label.text = "Ready"
var dash_rem = sm.get_dash_cooldown_remaining()
if dash_rem > 0.0:
_dash_icon.modulate = Color(1.0, 0.3, 0.3)
_dash_label.text = "%.1f" % dash_rem
else:
_dash_icon.modulate = Color(1.0, 1.0, 1.0)
_dash_label.text = "Ready"
if _weapon_label:
var wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if wman and wman.weapons.has(wman.active_slot):
var active_weapon = wman.weapons[wman.active_slot]
var w_name = "Weapon"
var cur_ammo = 0
var max_ammo = 0
if "weapon_name" in active_weapon:
w_name = active_weapon.weapon_name
elif active_weapon is DoubleBarrelShotgun:
w_name = "Double Barrel Shotgun"
if "current_ammo" in active_weapon:
cur_ammo = active_weapon.current_ammo
max_ammo = active_weapon.max_ammo
elif "shells" in active_weapon:
cur_ammo = active_weapon.shells
max_ammo = 2
if "reloading" in active_weapon and active_weapon.reloading:
_weapon_label.text = "%s\nReloading..." % w_name
else:
_weapon_label.text = "%s\n%d / %d" % [w_name, cur_ammo, max_ammo]
else:
_weapon_label.text = "Unarmed\n0 / 0"
# ── HUD ───────────────────────────────────────────────────────────────
#
# There is no HUD here any more, and no _process to drive one. See the note in
# scenes/maps/level_runtime.gd: everything that describes A PLAYER belongs to
# ui/player_hud.gd, which that player spawns for itself.
+1
View File
@@ -0,0 +1 @@
uid://78mssihccx1s
+38 -164
View File
@@ -27,10 +27,10 @@ var death_count: int = 0
var is_dead: bool = false
# UI
var health_bar: ProgressBar
var shield_bar: ProgressBar
var health_label: Label
var shield_label: Label
## The first-person HUD — reticle, vitals, ammo, death screen. See ui/player_hud.gd.
var _hud: PlayerHUD = null
## Kept because the controller drives them directly: the death screen is toggled
## by die()/respawn, and the ring is read by the reload logic.
var death_screen: Control
var ragdoll_instance: Node3D
@@ -64,8 +64,7 @@ var grapple_latch_player: AudioStreamPlayer
var grapple_swing_player: AudioStreamPlayer
# UI
var hit_marker: Control
var _hit_marker_tween: Tween
## The reload ring, owned by the HUD. Kept here because the reload logic reads it.
var reload_ring: Control
var grenades: int = 2
@@ -146,7 +145,6 @@ func _ready() -> void:
if is_multiplayer_authority():
_damage_layer = CanvasLayer.new()
add_child(_damage_layer)
_setup_hit_marker()
_setup_hud()
_setup_speedlines()
else:
@@ -395,32 +393,6 @@ func _setup_speedlines() -> void:
_damage_layer.add_child(_speedlines)
func _setup_hit_marker() -> void:
hit_marker = Control.new()
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
hit_marker.modulate.a = 0.0 # Hidden by default
_damage_layer.add_child(hit_marker)
# Draw an X perfectly centered
var length = 12
var thickness = 2
for angle in [PI/4, 3*PI/4, 5*PI/4, 7*PI/4]:
var rect = ColorRect.new()
rect.color = Color.WHITE
rect.size = Vector2(length, thickness)
rect.pivot_offset = rect.size / 2.0
# Center the rect itself at (0,0) before offset
var center_pos = -rect.size / 2.0
# Move it outward along the angle so it doesn't cover the exact center dot
rect.position = center_pos + Vector2(cos(angle), sin(angle)) * (length / 2.0)
rect.rotation = angle
hit_marker.add_child(rect)
# Reload Ring
reload_ring = load("res://ui/reload_ring.gd").new()
reload_ring.set_anchors_preset(Control.PRESET_CENTER)
_damage_layer.add_child(reload_ring)
func _setup_grapple() -> void:
grapple_rope = MeshInstance3D.new()
var rope_mesh = CylinderMesh.new()
@@ -462,10 +434,12 @@ func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
var label = load("res://ui/floating_damage_text.gd").new()
label.text = str(round(amount))
label.add_theme_font_size_override("font_size", 24)
label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.1))
label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
label.add_theme_constant_override("outline_size", 4)
label.add_theme_font_size_override("font_size", 28)
# The theme's papaya and its violet ink, not an approximate orange on pure
# black — these numbers fly over the same 3D scene the rest of the HUD does.
label.add_theme_color_override("font_color", UITheme.PAPAYA)
label.add_theme_color_override("font_outline_color", UITheme.INK)
label.add_theme_constant_override("outline_size", 7)
# Small random offset in 3D space so multiple pellets don't perfectly overlap
var offset = Vector3(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
@@ -480,13 +454,10 @@ func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
if now - _last_hit_sound_time > 10:
_last_hit_sound_time = now
hit_player.play()
if _hit_marker_tween and _hit_marker_tween.is_valid():
_hit_marker_tween.kill()
hit_marker.modulate.a = 1.0
_hit_marker_tween = create_tween()
_hit_marker_tween.tween_property(hit_marker, "modulate:a", 0.0, 0.4)
# The confirmation is part of the reticle now, so it lands where the eye
# already is and shares its ink outline.
if is_instance_valid(_hud):
_hud.confirm_hit()
func apply_impulse(force: Vector3) -> void:
velocity += force
@@ -1088,27 +1059,11 @@ func _process(delta: float) -> void:
lvisual.add_gun_recoil()
_last_local_ammo = ammo_now
# Update UI
if is_instance_valid(health_bar):
health_bar.value = health
health_label.text = "%d / %d" % [ceil(health), max_health]
if is_instance_valid(shield_bar):
shield_bar.value = shield
shield_label.text = "%d / %d" % [ceil(shield), max_shield]
# Update Reload Ring
if is_instance_valid(reload_ring) and is_instance_valid(camera):
var wman = camera.get_node_or_null("WeaponManager")
if wman:
var slot = wman.get("active_slot")
if slot != null and wman.weapons.has(slot):
var w = wman.weapons[slot]
if "reloading" in w and w.reloading and "reload_timer" in w and "reload_time" in w:
reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time)
else:
reload_ring.progress = 0.0
else:
reload_ring.progress = 0.0
# Vitals are pushed (the controller owns health and shield); ammo, the
# reticle bloom and the reload ring are pulled by the HUD from the weapon,
# which is the authority on all three.
if is_instance_valid(_hud):
_hud.set_vitals(health, max_health, shield, max_shield)
if is_multiplayer_authority() and not is_dead:
if is_holding_grenade and grenades > 0:
@@ -1139,112 +1094,31 @@ func _process(delta: float) -> void:
if shield > max_shield:
shield = max_shield
## Build the first-person HUD.
##
## Everything it draws lives in ui/player_hud.gd now. This used to be 105 lines
## of stock ProgressBars and a plain-black death screen inlined here, which is
## why none of it shared the game's look: a HUD assembled inside a 1500-line
## movement controller is a HUD nobody styles.
func _setup_hud() -> void:
var margin = MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
margin.offset_left = 20
margin.offset_bottom = -20
margin.grow_vertical = Control.GROW_DIRECTION_BEGIN
var vbox = VBoxContainer.new()
margin.add_child(vbox)
# Add Match HUD overlay
# Match state (timer, score, killfeed, scoreboard) is a separate overlay and
# is deliberately not part of the player's own HUD.
if is_multiplayer_authority():
var match_hud_scene = load("res://ui/match_hud.tscn")
if match_hud_scene:
var match_hud = match_hud_scene.instantiate()
add_child(match_hud)
add_child(match_hud_scene.instantiate())
# Shield Bar (Top)
var shield_box = VBoxContainer.new()
var s_title = Label.new()
s_title.text = "SHIELD"
s_title.add_theme_font_size_override("font_size", 12)
s_title.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0))
shield_box.add_child(s_title)
_hud = PlayerHUD.new()
_hud.name = "PlayerHUD"
_hud.player = self
add_child(_hud)
shield_bar = ProgressBar.new()
shield_bar.custom_minimum_size = Vector2(200, 20)
shield_bar.max_value = max_shield
shield_bar.value = shield
shield_bar.show_percentage = false
# The controller still owns these two — it toggles the death screen on death
# and the ring is read by the reload logic — so keep the references it had.
death_screen = _hud.death_screen
reload_ring = _hud.reload_ring
_hud.set_vitals(health, max_health, shield, max_shield)
var s_sb = StyleBoxFlat.new()
s_sb.bg_color = Color(0.1, 0.4, 0.8)
shield_bar.add_theme_stylebox_override("fill", s_sb)
shield_label = Label.new()
shield_label.text = "100 / 100"
shield_label.set_anchors_preset(Control.PRESET_CENTER)
shield_bar.add_child(shield_label)
shield_box.add_child(shield_bar)
vbox.add_child(shield_box)
# Spacer
vbox.add_child(Control.new())
# Health Bar (Bottom)
var health_box = VBoxContainer.new()
var h_title = Label.new()
h_title.text = "HEALTH"
h_title.add_theme_font_size_override("font_size", 12)
h_title.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
health_box.add_child(h_title)
health_bar = ProgressBar.new()
health_bar.custom_minimum_size = Vector2(200, 20)
health_bar.max_value = max_health
health_bar.value = health
health_bar.show_percentage = false
var h_sb = StyleBoxFlat.new()
h_sb.bg_color = Color(0.8, 0.1, 0.1)
health_bar.add_theme_stylebox_override("fill", h_sb)
health_label = Label.new()
health_label.text = "100 / 100"
health_label.set_anchors_preset(Control.PRESET_CENTER)
health_bar.add_child(health_label)
health_box.add_child(health_bar)
vbox.add_child(health_box)
_damage_layer.add_child(margin)
# Setup Death Screen
death_screen = ColorRect.new()
death_screen.color = Color(0, 0, 0, 0.7)
death_screen.set_anchors_preset(Control.PRESET_FULL_RECT)
death_screen.visible = false
var center = CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
death_screen.add_child(center)
var d_vbox = VBoxContainer.new()
d_vbox.add_theme_constant_override("separation", 20)
center.add_child(d_vbox)
var d_title = Label.new()
d_title.text = "SYSTEM FAILURE"
d_title.add_theme_font_size_override("font_size", 48)
d_title.add_theme_color_override("font_color", Color(1.0, 0.2, 0.2))
d_vbox.add_child(d_title)
var respawn_label = Label.new()
respawn_label.text = "PRESS ANY KEY TO REBOOT"
respawn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
respawn_label.add_theme_font_size_override("font_size", 24)
d_vbox.add_child(respawn_label)
var tw = create_tween().set_loops()
tw.tween_property(respawn_label, "modulate:a", 0.2, 0.8)
tw.tween_property(respawn_label, "modulate:a", 1.0, 0.8)
if is_multiplayer_authority():
_damage_layer.add_child(death_screen)
func die(impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead: return
+13 -293
View File
@@ -1,18 +1,7 @@
extends Node3D
class_name LevelRuntime
var _speed_label: Label
var _state_label: Label
var _chain_label: Label
var _weapon_label: Label
var _grapple_icon: TextureRect
var _dash_icon: TextureRect
var _grapple_label: Label
var _dash_label: Label
var _fps_label: Label
var _standalone_speed_label: Label
var _player: CharacterBody3D
var _debug_ui_panel: PanelContainer
func _ready() -> void:
@@ -24,7 +13,6 @@ func _ready() -> void:
if not has_node("WorldEnvironment"):
LevelEnvironment.add_to(self)
_build_hud()
# Multiplayer Spawning
var spawner = MultiplayerSpawner.new()
@@ -252,284 +240,16 @@ func _spawn_player(pid: int) -> CharacterBody3D:
return player
# ── HUD ───────────────────────────────────────────────────────────────────────
func _build_hud() -> void:
var canvas := CanvasLayer.new()
canvas.name = "UI"
add_child(canvas)
# ── Crosshair ─────────────────────────────────────────────────────────
var crosshair := Control.new()
crosshair.name = "Crosshair"
crosshair.set_anchors_preset(Control.PRESET_CENTER)
crosshair.custom_minimum_size = Vector2(20, 20)
canvas.add_child(crosshair)
var ch_dot := ColorRect.new()
ch_dot.name = "Dot"
ch_dot.color = Color(1, 1, 1, 0.8)
ch_dot.size = Vector2(4, 4)
ch_dot.position = Vector2(-2, -2)
crosshair.add_child(ch_dot)
# Crosshair lines
for data in [
{"pos": Vector2(-10, -1), "size": Vector2(6, 2)}, # Left
{"pos": Vector2(4, -1), "size": Vector2(6, 2)}, # Right
{"pos": Vector2(-1, -10), "size": Vector2(2, 6)}, # Top
{"pos": Vector2(-1, 4), "size": Vector2(2, 6)}, # Bottom
]:
var line := ColorRect.new()
line.color = Color(1, 1, 1, 0.6)
line.position = data["pos"]
line.size = data["size"]
crosshair.add_child(line)
# ── Info panel background ─────────────────────────────────────────────
_fps_label = Label.new()
_fps_label.name = "FPSLabel"
_fps_label.text = "FPS: 0"
_fps_label.add_theme_font_size_override("font_size", 24)
_fps_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0.2))
_fps_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_fps_label.add_theme_constant_override("outline_size", 4)
_fps_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_fps_label.position = Vector2(12, 12)
canvas.add_child(_fps_label)
_standalone_speed_label = Label.new()
_standalone_speed_label.name = "StandaloneSpeedLabel"
_standalone_speed_label.text = "Speed: 0.0 m/s"
_standalone_speed_label.add_theme_font_size_override("font_size", 24)
_standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
_standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_standalone_speed_label.add_theme_constant_override("outline_size", 4)
_standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_standalone_speed_label.position = Vector2(12, 45)
canvas.add_child(_standalone_speed_label)
_debug_ui_panel = PanelContainer.new()
_debug_ui_panel.name = "InfoPanel"
_debug_ui_panel.offset_left = 12
_debug_ui_panel.offset_top = 50
_debug_ui_panel.offset_right = 400
_debug_ui_panel.offset_bottom = 160
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0, 0, 0, 0.55)
panel_style.corner_radius_top_left = 8
panel_style.corner_radius_top_right = 8
panel_style.corner_radius_bottom_left = 8
panel_style.corner_radius_bottom_right = 8
panel_style.content_margin_left = 12
panel_style.content_margin_top = 8
panel_style.content_margin_right = 12
panel_style.content_margin_bottom = 8
_debug_ui_panel.add_theme_stylebox_override("panel", panel_style)
canvas.add_child(_debug_ui_panel)
var vbox := VBoxContainer.new()
vbox.name = "InfoVBox"
_debug_ui_panel.add_child(vbox)
_speed_label = Label.new()
_speed_label.name = "SpeedLabel"
_speed_label.text = "Speed: 0.0 m/s"
_speed_label.add_theme_font_size_override("font_size", 18)
_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
vbox.add_child(_speed_label)
_state_label = Label.new()
_state_label.name = "StateLabel"
_state_label.text = "State: ground"
_state_label.add_theme_font_size_override("font_size", 16)
_state_label.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0))
vbox.add_child(_state_label)
_chain_label = Label.new()
_chain_label.name = "ChainLabel"
_chain_label.text = "Chain: 0 (+0%)"
_chain_label.add_theme_font_size_override("font_size", 16)
_chain_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.3))
vbox.add_child(_chain_label)
# ── Weapon & Ammo Panel ───────────────────────────────────────────────
var wp_panel := PanelContainer.new()
wp_panel.name = "WeaponPanel"
wp_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
wp_panel.offset_left = -250
wp_panel.offset_top = -100
wp_panel.offset_right = -20
wp_panel.offset_bottom = -20
var wp_style := StyleBoxFlat.new()
wp_style.bg_color = Color(0, 0, 0, 0.6)
wp_style.corner_radius_top_left = 8
wp_style.corner_radius_top_right = 8
wp_style.corner_radius_bottom_left = 8
wp_style.corner_radius_bottom_right = 8
wp_style.content_margin_left = 16
wp_style.content_margin_top = 12
wp_style.content_margin_right = 16
wp_style.content_margin_bottom = 12
wp_panel.add_theme_stylebox_override("panel", wp_style)
wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(wp_panel)
_weapon_label = Label.new()
_weapon_label.name = "WeaponLabel"
_weapon_label.text = "Unarmed\n0 / 0"
_weapon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_weapon_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_weapon_label.add_theme_font_size_override("font_size", 24)
wp_panel.add_child(_weapon_label)
# ── Utilities Panel ───────────────────────────────────────────────────────
var util_panel := PanelContainer.new()
util_panel.name = "UtilPanel"
util_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
util_panel.offset_left = -200
util_panel.offset_top = -180
util_panel.offset_right = -20
util_panel.offset_bottom = -110
var util_style := StyleBoxFlat.new()
util_style.bg_color = Color(0, 0, 0, 0.6)
util_style.corner_radius_top_left = 8
util_style.corner_radius_top_right = 8
util_style.corner_radius_bottom_left = 8
util_style.corner_radius_bottom_right = 8
util_style.content_margin_left = 12
util_style.content_margin_top = 8
util_style.content_margin_right = 12
util_style.content_margin_bottom = 8
util_panel.add_theme_stylebox_override("panel", util_style)
util_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(util_panel)
var util_hbox := HBoxContainer.new()
util_hbox.name = "UtilHBox"
util_hbox.add_theme_constant_override("separation", 20)
util_hbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_panel.add_child(util_hbox)
var grapple_vbox := VBoxContainer.new()
grapple_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(grapple_vbox)
_grapple_icon = TextureRect.new()
if ResourceLoader.exists("res://assets/ui/grapple_icon.jpg"):
_grapple_icon.texture = load("res://assets/ui/grapple_icon.jpg")
_grapple_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_grapple_icon.custom_minimum_size = Vector2(32, 32)
_grapple_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
grapple_vbox.add_child(_grapple_icon)
_grapple_label = Label.new()
_grapple_label.text = "Grapple"
_grapple_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_grapple_label.add_theme_font_size_override("font_size", 12)
grapple_vbox.add_child(_grapple_label)
var dash_vbox := VBoxContainer.new()
dash_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(dash_vbox)
_dash_icon = TextureRect.new()
if ResourceLoader.exists("res://assets/ui/dash_icon.jpg"):
_dash_icon.texture = load("res://assets/ui/dash_icon.jpg")
_dash_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_dash_icon.custom_minimum_size = Vector2(32, 32)
_dash_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
dash_vbox.add_child(_dash_icon)
_dash_label = Label.new()
_dash_label.text = "Ready"
_dash_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_dash_label.add_theme_font_size_override("font_size", 12)
dash_vbox.add_child(_dash_label)
# ── HUD Update ────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
if not _player or not is_instance_valid(_player):
return
if _debug_ui_panel:
_debug_ui_panel.visible = SettingsManager.show_debug_ui
if _fps_label:
_fps_label.visible = SettingsManager.show_fps
if _fps_label.visible:
_fps_label.text = "FPS: %d" % Engine.get_frames_per_second()
var vel: Vector3 = _player.velocity
var hspeed := Vector2(vel.x, vel.z).length()
var total_speed := vel.length()
if _speed_label:
_speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed]
if _standalone_speed_label:
if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui:
_standalone_speed_label.visible = true
_standalone_speed_label.text = "Speed: %.1f m/s" % hspeed
if _fps_label and _fps_label.visible:
_standalone_speed_label.position = Vector2(12, 45)
else:
_standalone_speed_label.position = Vector2(12, 12)
else:
_standalone_speed_label.visible = false
if _state_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_state_label.text = "State: %s" % sm.current_state
if _chain_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_chain_label.text = "Chain: %d (+%d%%)" % [sm.chain_count, int(sm.current_chain_bonus * 100)]
# Update utility indicators
if sm.current_state == "grapple" or sm.is_grapple_shooting:
_grapple_icon.modulate = Color(0.2, 1.0, 0.4)
_grapple_label.text = "Grappling"
else:
_grapple_icon.modulate = Color(1.0, 1.0, 1.0)
_grapple_label.text = "Ready"
if sm.has_method("get_dash_cooldown_remaining"):
var dash_rem = sm.get_dash_cooldown_remaining()
if dash_rem > 0.0:
_dash_icon.modulate = Color(1.0, 0.3, 0.3)
_dash_label.text = "%.1f" % dash_rem
else:
_dash_icon.modulate = Color(1.0, 1.0, 1.0)
_dash_label.text = "Ready"
if _weapon_label:
var wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if wman and wman.weapons.has(wman.active_slot):
var active_weapon = wman.weapons[wman.active_slot]
var w_name = "Weapon"
var cur_ammo = 0
var max_ammo = 0
if "weapon_name" in active_weapon:
w_name = active_weapon.weapon_name
elif active_weapon is DoubleBarrelShotgun:
w_name = "Double Barrel Shotgun"
if "current_ammo" in active_weapon:
cur_ammo = active_weapon.current_ammo
max_ammo = active_weapon.max_ammo
elif "shells" in active_weapon:
cur_ammo = active_weapon.shells
max_ammo = 2
if "reloading" in active_weapon and active_weapon.reloading:
_weapon_label.text = "%s\nReloading..." % w_name
else:
_weapon_label.text = "%s\n%d / %d" % [w_name, cur_ammo, max_ammo]
else:
_weapon_label.text = "Unarmed\n0 / 0"
# ── HUD ───────────────────────────────────────────────────────────────
#
# There is no HUD here any more, and no _process to drive one.
#
# Reticle, vitals, ammo, ability cooldowns, the chain meter and the debug
# readout all belong to ui/player_hud.gd, spawned by the player itself. Every
# one of those describes A PLAYER, so a level that owns them has to reach down
# into that player's state machine and weapon manager every frame to fill them
# in — which is exactly what this did, from three byte-identical copies across
# the three level runtimes. The visible symptom was two ammo panels on screen at
# once, in two different styles, overlapping in the bottom-right corner.
#
# A level owns the level.
@@ -1,18 +1,7 @@
extends Node3D
var _speed_label: Label
var _state_label: Label
var _chain_label: Label
var _weapon_label: Label
var _grapple_icon: TextureRect
var _dash_icon: TextureRect
var _grapple_label: Label
var _dash_label: Label
var _fps_label: Label
var _standalone_speed_label: Label
var _player: CharacterBody3D
var _debug_ui_panel: PanelContainer
func _ready() -> void:
@@ -29,7 +18,6 @@ func _ready() -> void:
LevelEnvironment.add_to(self)
LevelMaterials.apply_toon_recursive(self, 0.0)
_build_hud()
# Multiplayer Spawning
var spawner = MultiplayerSpawner.new()
@@ -221,297 +209,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
return player
# ── HUD ───────────────────────────────────────────────────────────────────────
func _build_hud() -> void:
var canvas := CanvasLayer.new()
canvas.name = "UI"
add_child(canvas)
# ── Crosshair ─────────────────────────────────────────────────────────
var crosshair := Control.new()
crosshair.name = "Crosshair"
crosshair.set_anchors_preset(Control.PRESET_CENTER)
crosshair.custom_minimum_size = Vector2(20, 20)
canvas.add_child(crosshair)
var ch_dot := ColorRect.new()
ch_dot.name = "Dot"
ch_dot.color = Color(1, 1, 1, 0.8)
ch_dot.size = Vector2(4, 4)
ch_dot.position = Vector2(-2, -2)
crosshair.add_child(ch_dot)
# Crosshair lines
for data in [
{"pos": Vector2(-10, -1), "size": Vector2(6, 2)}, # Left
{"pos": Vector2(4, -1), "size": Vector2(6, 2)}, # Right
{"pos": Vector2(-1, -10), "size": Vector2(2, 6)}, # Top
{"pos": Vector2(-1, 4), "size": Vector2(2, 6)}, # Bottom
]:
var line := ColorRect.new()
line.color = Color(1, 1, 1, 0.6)
line.position = data["pos"]
line.size = data["size"]
crosshair.add_child(line)
# ── Info panel background ─────────────────────────────────────────────
_fps_label = Label.new()
_fps_label.name = "FPSLabel"
_fps_label.text = "FPS: 0"
_fps_label.add_theme_font_size_override("font_size", 24)
_fps_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0.2))
_fps_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_fps_label.add_theme_constant_override("outline_size", 4)
_fps_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_fps_label.position = Vector2(12, 12)
canvas.add_child(_fps_label)
_standalone_speed_label = Label.new()
_standalone_speed_label.name = "StandaloneSpeedLabel"
_standalone_speed_label.text = "Speed: 0.0 m/s"
_standalone_speed_label.add_theme_font_size_override("font_size", 24)
_standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
_standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_standalone_speed_label.add_theme_constant_override("outline_size", 4)
_standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_standalone_speed_label.position = Vector2(12, 45)
canvas.add_child(_standalone_speed_label)
_debug_ui_panel = PanelContainer.new()
_debug_ui_panel.name = "InfoPanel"
_debug_ui_panel.offset_left = 12
_debug_ui_panel.offset_top = 50
_debug_ui_panel.offset_right = 400
_debug_ui_panel.offset_bottom = 160
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0, 0, 0, 0.55)
panel_style.corner_radius_top_left = 8
panel_style.corner_radius_top_right = 8
panel_style.corner_radius_bottom_left = 8
panel_style.corner_radius_bottom_right = 8
panel_style.content_margin_left = 12
panel_style.content_margin_top = 8
panel_style.content_margin_right = 12
panel_style.content_margin_bottom = 8
_debug_ui_panel.add_theme_stylebox_override("panel", panel_style)
canvas.add_child(_debug_ui_panel)
var vbox := VBoxContainer.new()
vbox.name = "InfoVBox"
_debug_ui_panel.add_child(vbox)
_speed_label = Label.new()
_speed_label.name = "SpeedLabel"
_speed_label.text = "Speed: 0.0 m/s"
_speed_label.add_theme_font_size_override("font_size", 18)
_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
vbox.add_child(_speed_label)
_state_label = Label.new()
_state_label.name = "StateLabel"
_state_label.text = "State: ground"
_state_label.add_theme_font_size_override("font_size", 16)
_state_label.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0))
vbox.add_child(_state_label)
_chain_label = Label.new()
_chain_label.name = "ChainLabel"
_chain_label.text = "Chain: 0 (+0%)"
_chain_label.add_theme_font_size_override("font_size", 16)
_chain_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.3))
vbox.add_child(_chain_label)
# ── Weapon & Ammo Panel ───────────────────────────────────────────────
var wp_panel := PanelContainer.new()
wp_panel.name = "WeaponPanel"
wp_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
wp_panel.offset_left = -250
wp_panel.offset_top = -100
wp_panel.offset_right = -20
wp_panel.offset_bottom = -20
var wp_style := StyleBoxFlat.new()
wp_style.bg_color = Color(0, 0, 0, 0.6)
wp_style.corner_radius_top_left = 8
wp_style.corner_radius_top_right = 8
wp_style.corner_radius_bottom_left = 8
wp_style.corner_radius_bottom_right = 8
wp_style.content_margin_left = 16
wp_style.content_margin_top = 12
wp_style.content_margin_right = 16
wp_style.content_margin_bottom = 12
wp_panel.add_theme_stylebox_override("panel", wp_style)
wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(wp_panel)
_weapon_label = Label.new()
_weapon_label.name = "WeaponLabel"
_weapon_label.text = "Unarmed\n0 / 0"
_weapon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_weapon_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_weapon_label.add_theme_font_size_override("font_size", 24)
wp_panel.add_child(_weapon_label)
# ── Utilities Panel ───────────────────────────────────────────────────────
var util_panel := PanelContainer.new()
util_panel.name = "UtilPanel"
util_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
util_panel.offset_left = -200
util_panel.offset_top = -180
util_panel.offset_right = -20
util_panel.offset_bottom = -110
var util_style := StyleBoxFlat.new()
util_style.bg_color = Color(0, 0, 0, 0.6)
util_style.corner_radius_top_left = 8
util_style.corner_radius_top_right = 8
util_style.corner_radius_bottom_left = 8
util_style.corner_radius_bottom_right = 8
util_style.content_margin_left = 12
util_style.content_margin_top = 8
util_style.content_margin_right = 12
util_style.content_margin_bottom = 8
util_panel.add_theme_stylebox_override("panel", util_style)
util_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(util_panel)
var util_hbox := HBoxContainer.new()
util_hbox.name = "UtilHBox"
util_hbox.add_theme_constant_override("separation", 20)
util_hbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_panel.add_child(util_hbox)
var grapple_vbox := VBoxContainer.new()
grapple_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(grapple_vbox)
_grapple_icon = TextureRect.new()
_grapple_icon.texture = load("res://assets/ui/grapple_icon.jpg")
_grapple_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_grapple_icon.custom_minimum_size = Vector2(32, 32)
_grapple_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
grapple_vbox.add_child(_grapple_icon)
_grapple_label = Label.new()
_grapple_label.text = "Grapple"
_grapple_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_grapple_label.add_theme_font_size_override("font_size", 12)
grapple_vbox.add_child(_grapple_label)
var dash_vbox := VBoxContainer.new()
dash_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(dash_vbox)
_dash_icon = TextureRect.new()
_dash_icon.texture = load("res://assets/ui/dash_icon.jpg")
_dash_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_dash_icon.custom_minimum_size = Vector2(32, 32)
_dash_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
dash_vbox.add_child(_dash_icon)
_dash_label = Label.new()
_dash_label.text = "Ready"
_dash_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_dash_label.add_theme_font_size_override("font_size", 12)
dash_vbox.add_child(_dash_label)
# ── Utility: Key Name ─────────────────────────────────────────────────────────
func _get_key_name(action: String) -> String:
if not InputMap.has_action(action):
return "?"
var events = InputMap.action_get_events(action)
for e in events:
if e is InputEventKey:
var code = e.physical_keycode if e.physical_keycode != 0 else e.keycode
return OS.get_keycode_string(code)
elif e is InputEventMouseButton:
if e.button_index == MOUSE_BUTTON_LEFT: return "LClick"
elif e.button_index == MOUSE_BUTTON_RIGHT: return "RClick"
elif e.button_index == MOUSE_BUTTON_MIDDLE: return "MClick"
return "?"
# ── HUD Update ────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
if not _player or not is_instance_valid(_player):
return
if _debug_ui_panel:
_debug_ui_panel.visible = SettingsManager.show_debug_ui
if _fps_label:
_fps_label.visible = SettingsManager.show_fps
if _fps_label.visible:
_fps_label.text = "FPS: %d" % Engine.get_frames_per_second()
var vel: Vector3 = _player.velocity
var hspeed := Vector2(vel.x, vel.z).length()
var total_speed := vel.length()
if _speed_label:
_speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed]
if _standalone_speed_label:
if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui:
_standalone_speed_label.visible = true
_standalone_speed_label.text = "Speed: %.1f m/s" % hspeed
if _fps_label and _fps_label.visible:
_standalone_speed_label.position = Vector2(12, 45)
else:
_standalone_speed_label.position = Vector2(12, 12)
else:
_standalone_speed_label.visible = false
if _state_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_state_label.text = "State: %s" % sm.current_state
if _chain_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_chain_label.text = "Chain: %d (+%d%%)" % [sm.chain_count, int(sm.current_chain_bonus * 100)]
# Update utility indicators
if sm.current_state == "grapple" or sm.is_grapple_shooting:
_grapple_icon.modulate = Color(0.2, 1.0, 0.4)
_grapple_label.text = "Grappling"
else:
_grapple_icon.modulate = Color(1.0, 1.0, 1.0)
_grapple_label.text = "Ready"
var dash_rem = sm.get_dash_cooldown_remaining()
if dash_rem > 0.0:
_dash_icon.modulate = Color(1.0, 0.3, 0.3)
_dash_label.text = "%.1f" % dash_rem
else:
_dash_icon.modulate = Color(1.0, 1.0, 1.0)
_dash_label.text = "Ready"
if _weapon_label:
var wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if wman and wman.weapons.has(wman.active_slot):
var active_weapon = wman.weapons[wman.active_slot]
var w_name = "Weapon"
var cur_ammo = 0
var max_ammo = 0
if "weapon_name" in active_weapon:
w_name = active_weapon.weapon_name
elif active_weapon is DoubleBarrelShotgun:
w_name = "Double Barrel Shotgun"
if "current_ammo" in active_weapon:
cur_ammo = active_weapon.current_ammo
max_ammo = active_weapon.max_ammo
elif "shells" in active_weapon:
cur_ammo = active_weapon.shells
max_ammo = 2
if "reloading" in active_weapon and active_weapon.reloading:
_weapon_label.text = "%s\nReloading..." % w_name
else:
_weapon_label.text = "%s\n%d / %d" % [w_name, cur_ammo, max_ammo]
else:
_weapon_label.text = "Unarmed\n0 / 0"
# ── HUD ───────────────────────────────────────────────────────────────
#
# There is no HUD here any more, and no _process to drive one. See the note in
# scenes/maps/level_runtime.gd: everything that describes A PLAYER belongs to
# ui/player_hud.gd, which that player spawns for itself.
+128
View File
@@ -0,0 +1,128 @@
extends Control
class_name AbilityChip
## A dash or grapple readout: a leaning chip that drains while the ability is on
## cooldown and snaps back to charged when it is ready again.
##
## It replaces a 32 px JPEG icon with a text label under it, tinted red or green
## by a colour multiply — an approach with three problems. The icon was a photo
## in a game drawn entirely in flat ink; the tint said "not ready" but not HOW not
## ready; and the number that did say it was rendered at 12 px, which is below
## what anyone reads mid-fight.
##
## What a player actually needs from a cooldown is one bit at a glance (can I go?)
## and one magnitude in peripheral vision (how soon?). So the chip answers the bit
## with COLOUR — volt when charged, ink when not, the same volt-means-now rule the
## rest of the UI runs on — and the magnitude with a WIPE across the chip, which
## can be read without focusing on it because it is a shape changing size rather
## than a number changing value.
##
## The ready transition overshoots slightly before settling. A cooldown that ends
## by silently going bright is easy to miss while looking somewhere else; one that
## pops is not, and it costs a tween.
## Ability name, drawn on the chip.
var label: String = "DASH":
set(v):
label = v
queue_redraw()
## 0 = fully charged, 1 = just used. Written each frame.
var cooldown: float = 0.0:
set(v):
var c := clampf(v, 0.0, 1.0)
var was_ready := cooldown <= 0.001
cooldown = c
if was_ready and c > 0.001:
_pop = 0.0
elif not was_ready and c <= 0.001:
# Just came back. Fire the overshoot.
_pop = 1.0
queue_redraw()
## Set while the ability is actively in use (mid-grapple), which is a third state
## and reads as neither charged nor recharging.
var active: bool = false:
set(v):
if v != active:
active = v
queue_redraw()
const SHEAR := 7.0
const INK_W := 3.0
const POP_DECAY := 4.0
var _pop: float = 0.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
custom_minimum_size = Vector2(96, 34)
func _process(delta: float) -> void:
if _pop <= 0.0:
return
_pop = maxf(_pop - delta * POP_DECAY, 0.0)
queue_redraw()
func _draw() -> void:
var w := size.x
var h := size.y
if w <= 1.0 or h <= 1.0:
return
var ready := cooldown <= 0.001
# The overshoot: a brief lift in the fill, decaying to the resting colour.
var lift: float = _pop * _pop
var fill: Color = UITheme.INK
var text: Color = UITheme.PAPER_DIM
if active:
# In use. Cyan, so it cannot be confused with either of the other two.
fill = UITheme.CYAN
text = UITheme.ink_for(UITheme.CYAN)
elif ready:
fill = UITheme.VOLT.lerp(UITheme.PAPER, lift * 0.5)
text = UITheme.ink_for(UITheme.VOLT)
_shear(0.0, w, fill, h)
# The recharge wipe, left to right over the dark chip.
if not ready and not active:
_shear(0.0, w * (1.0 - cooldown), UITheme.PAPAYA, h)
# Anything the wipe has reached is papaya, anything it has not is ink,
# and one text colour has to sit on both. Ink loses against ink; paper
# wins against both, and the outline below covers the papaya case.
text = UITheme.PAPER
_shear_outline(0.0, w, UITheme.INK, h)
var font := get_theme_default_font()
if font == null:
return
var fs := 17
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
var at := Vector2((w - text_size.x) * 0.5, (h + text_size.y * 0.62) * 0.5)
# Ink outline under the glyphs, same as every Label in the theme, so the
# label survives the wipe passing underneath it.
draw_string_outline(font, at, label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, 5,
UITheme.INK)
draw_string(font, at, label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, text)
func _shear(x0: float, x1: float, col: Color, h: float) -> void:
if x1 - x0 < 0.5:
return
draw_colored_polygon(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0), Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h), Vector2(x0, h),
]), col)
func _shear_outline(x0: float, x1: float, col: Color, h: float) -> void:
draw_polyline(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0), Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h), Vector2(x0, h), Vector2(x0 + SHEAR, 0.0),
]), col, INK_W)
+1
View File
@@ -0,0 +1 @@
uid://48ja2bu8k6xm
+175
View File
@@ -0,0 +1,175 @@
extends Control
class_name Crosshair
## The reticle, drawn rather than assembled out of ColorRects.
##
## It replaces five white rectangles that were pasted into three different level
## runtimes, and it exists as one drawn thing for two reasons.
##
## The first is that it has to say something. A static cross tells the player
## nothing they do not already know; a reticle that OPENS as they sprint, jump and
## fire, and snaps shut when they stop or shoulder the weapon, is the accuracy
## readout of the whole game and it costs one number per frame. Four separate
## ColorRects cannot express that without four separate position updates, which is
## why they never did.
##
## The second is the ink. Every other element in this UI carries a heavy dark edge
## — it is the theme's first rule, and the reason white type stays legible over a
## sunlit 3D scene. A 2 px white line does not: over pale concrete it disappears
## exactly when aim matters. Here every stroke is drawn twice, ink underneath and
## wider, so the reticle reads against the map instead of against luck.
##
## The hit confirmation lives here too, rather than as a separate centred Control
## fading on top. It is the same four strokes rotated 45°, which means the
## feedback arrives where the player's eye already is and shares the reticle's
## outline instead of needing its own.
## 0 = tight, 1 = fully bloomed. Written each frame by PlayerHUD from speed,
## airtime and fire cooldown.
var spread: float = 0.0:
set(v):
var c := clampf(v, 0.0, 1.0)
if absf(c - spread) > 0.002:
spread = c
queue_redraw()
else:
spread = c
## 0 = hip, 1 = down the sights. At full ADS the ticks retract entirely and only
## the centre dot remains, which is the convention every shooter uses and the
## clearest possible statement that the shot is going where the dot is.
var ads: float = 0.0:
set(v):
var c := clampf(v, 0.0, 1.0)
if absf(c - ads) > 0.002:
ads = c
queue_redraw()
else:
ads = c
## Hit and kill confirmations, 1 -> 0. Kill is the louder one and it wins.
var hit: float = 0.0
var kill: float = 0.0
## Geometry, in pixels at 1080p.
const GAP_TIGHT := 5.0
const GAP_BLOOM := 30.0
const TICK := 9.0
const STROKE := 2.0
const INK_GROW := 2.0
const DOT := 2.2
## How far the confirmation strokes sit out, and how long they are.
const HIT_GAP := 13.0
const HIT_TICK := 8.0
## Decay rates, per second. The hit pop is quick — it has to land inside the
## rhythm of firing, not linger into the next shot.
const HIT_DECAY := 3.2
const KILL_DECAY := 1.7
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
# FULL RECT, and the drawing centres itself in `size`.
#
# A zero-sized Control on PRESET_CENTER is the obvious way to do this and it
# is wrong: the control draws once when it enters the tree, before the
# viewport has told it how big the screen is, so the whole reticle lands in
# the top-left corner and stays there until something else happens to make it
# redraw. Nothing does, because the reticle only redraws when the bloom
# changes. Owning the full rect and measuring the centre every draw cannot
# get that wrong.
# `set_anchors_AND_OFFSETS_preset`, not `set_anchors_preset`. The latter
# moves the anchors and leaves the offsets where they were, which for a
# control that has never been laid out means an empty rect: the measured
# result was anchors spanning the viewport and a size of exactly (0, 0).
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
resized.connect(queue_redraw)
func _process(delta: float) -> void:
if hit <= 0.0 and kill <= 0.0:
return
hit = maxf(hit - delta * HIT_DECAY, 0.0)
kill = maxf(kill - delta * KILL_DECAY, 0.0)
queue_redraw()
## A landed shot. Kills pass `fatal` and get the louder, slower magenta pop.
func confirm(fatal: bool = false) -> void:
if fatal:
kill = 1.0
else:
hit = 1.0
queue_redraw()
func _draw() -> void:
_mid = size * 0.5
# ADS retracts the ticks and tightens what is left, so the bloom cannot
# fight the sight picture.
var open := 1.0 - ads
var gap: float = lerpf(GAP_TIGHT, GAP_BLOOM, spread) * lerpf(1.0, 0.45, ads)
var tick: float = TICK * open
if tick > 0.5:
# Vertical ticks are drawn slightly shorter than horizontal ones. A
# perfectly square cross reads taller than it is because the eye
# over-weights vertical extent; trimming the verticals is the standard
# correction and it is the difference between a reticle that looks
# centred and one that looks slightly high.
_stroke(Vector2(-gap - tick, 0), Vector2(-gap, 0), UITheme.PAPER)
_stroke(Vector2(gap, 0), Vector2(gap + tick, 0), UITheme.PAPER)
var v: float = tick * 0.86
_stroke(Vector2(0, -gap - v), Vector2(0, -gap), UITheme.PAPER)
_stroke(Vector2(0, gap), Vector2(0, gap + v), UITheme.PAPER)
# Centre dot: the one element that never moves and never fades. It goes
# papaya at the hip and volt down the sights, so shouldering the weapon
# changes the reticle's colour as well as its shape.
var dot_col: Color = UITheme.PAPAYA.lerp(UITheme.VOLT, ads)
var r: float = DOT * lerpf(1.0, 1.25, ads)
draw_circle(_mid, r + INK_GROW, UITheme.INK)
draw_circle(_mid, r, dot_col)
# Confirmations, over the top: the same cross rotated 45°.
if kill > 0.001:
_confirm_arms(kill, UITheme.MAGENTA, 1.35)
if hit > 0.001:
_confirm_arms(hit, UITheme.VOLT, 1.0)
## Four diagonal strokes, scaled and faded by `amount`.
##
## They grow slightly as they fade rather than merely fading, which reads as an
## impact rather than as a light being switched off — the same expansion-plus-
## dissolve that carries an anime impact frame.
func _confirm_arms(amount: float, col: Color, scale: float) -> void:
var grow: float = 1.0 + (1.0 - amount) * 0.5
var gap: float = HIT_GAP * scale * grow
var tick: float = HIT_TICK * scale
var c := Color(col.r, col.g, col.b, amount)
var ink := Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, amount)
const DIAGONALS: Array[Vector2] = [Vector2(1, 1), Vector2(1, -1),
Vector2(-1, 1), Vector2(-1, -1)]
for d in DIAGONALS:
var dir := d.normalized()
_stroke(dir * gap, dir * (gap + tick), c, ink)
## Screen centre, recomputed at the top of every `_draw`. Every offset below is
## relative to it.
var _mid: Vector2 = Vector2.ZERO
## One stroke, ink first and wider so the colour sits inside an outline.
##
## Endpoints are given RELATIVE TO CENTRE and offset here, so no caller has to
## remember to add it.
##
## `draw_line` with a round cap would leave the ink poking out past the ends as a
## dark bead; square caps keep the outline flush with the stroke it is edging.
func _stroke(a: Vector2, b: Vector2, col: Color,
ink: Color = UITheme.INK) -> void:
draw_line(_mid + a, _mid + b, ink, STROKE + INK_GROW * 2.0, false)
draw_line(_mid + a, _mid + b, col, STROKE, false)
+1
View File
@@ -0,0 +1 @@
uid://ck57a8waor1d1
+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
+1
View File
@@ -0,0 +1 @@
uid://hjsfcdnutb4t
+28 -6
View File
@@ -1,22 +1,44 @@
extends Control
class_name ReloadRing
## The reload arc, drawn around the reticle.
##
## Like Crosshair it owns the full rect and centres itself in `size` rather than
## sitting at a zero-sized PRESET_CENTER: a control that draws before the
## viewport has sized it lands in the top-left corner, and only a later redraw
## moves it — which, for something that redraws on a value change, can be never.
var progress: float = 0.0:
set(val):
progress = clampf(val, 0.0, 1.0)
var v := clampf(val, 0.0, 1.0)
if is_equal_approx(v, progress):
return
progress = v
queue_redraw()
var radius: float = 16.0
var thickness: float = 4.0
var color: Color = Color(1.0, 1.0, 1.0, 0.8)
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
# Offsets too — see the note in ui/crosshair.gd.
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
resized.connect(queue_redraw)
func _draw() -> void:
if progress <= 0.0 or progress >= 1.0:
return
var mid := size * 0.5
# Draw background arc (darker)
draw_arc(Vector2.ZERO, radius, 0, PI * 2.0, 32, Color(0, 0, 0, 0.4), thickness, true)
# Ink track first, wider than the arc, so the ring reads over a bright
# skybox the same way every other element in this UI does. The old version
# used 40% black, which vanished against pale concrete.
draw_arc(mid, radius, 0, TAU, 48, UITheme.INK, thickness + 4.0, true)
draw_arc(mid, radius, 0, TAU, 48, UITheme.INK_SOFT, thickness, true)
# Draw progress arc
var end_angle = -PI / 2.0 + (PI * 2.0 * progress)
draw_arc(Vector2.ZERO, radius, -PI / 2.0, end_angle, 32, color, thickness, true)
# The charged part, sweeping from twelve o'clock.
draw_arc(mid, radius, -PI / 2.0, -PI / 2.0 + TAU * progress, 48, color,
thickness, true)
+186
View File
@@ -0,0 +1,186 @@
extends Control
class_name VitalBar
## A health or shield bar in the game's own hand: sheared, ink-edged, segmented,
## with a drain ghost behind the fill.
##
## It replaces a stock ProgressBar with a flat colour override, and each of the
## three things it adds answers a question the ProgressBar could not.
##
## SEGMENTS — how much is left, without reading a number. A continuous bar has to
## be measured against its own ends; a bar cut into blocks of 25 can be COUNTED,
## and counting is faster than estimating and survives being glimpsed in
## peripheral vision during a firefight. This is the same reason HoYoverse's
## action UIs chunk their meters rather than drawing one smooth sweep.
##
## THE DRAIN GHOST — how much was just lost. The fill snaps to the new value
## immediately, because the player must never be told they have more health than
## they do; a paler ghost holds the old value for a beat and then catches up. The
## gap between them is the size of the hit, which is information that does not
## exist anywhere on a bar that simply gets shorter.
##
## THE SHEAR — because everything else in this UI leans. A square meter under
## leaning chips reads as a widget from a different game.
##
## The numeral deliberately lives OUTSIDE this control, beside the bar rather
## than centred on it. Text over a two-tone bar cannot be given a colour that
## beats both the fill and the trough, which is what debug/ui_contrast_check.gd
## measured at 2.4:1 on the old HUD. Moving it off the fill fixes that at the
## source instead of relying on an outline to rescue it.
var value: float = 100.0:
set(v):
var c := clampf(v, 0.0, max_value)
if c < value - 0.01:
# Lost some: the ghost stays where it was and the flash fires.
_flash = 1.0
_lag_hold = LAG_HOLD
elif c > value + 0.01:
# Gained some: the ghost has nothing to show, so bring it along.
_lag = c
value = c
queue_redraw()
var max_value: float = 100.0:
set(v):
max_value = maxf(v, 1.0)
_lag = minf(_lag, max_value)
queue_redraw()
## The charged colour of the fill. Health is papaya, shield is cyan.
var fill_color: Color = UITheme.PAPAYA:
set(v):
fill_color = v
queue_redraw()
## One block per this many points. 25 gives a 100-point bar four blocks, which is
## the most the eye can count without moving.
var per_segment: float = 25.0
## Below this fraction the bar breathes, so low health is felt rather than read.
const LOW_AT := 0.3
const LOW_RATE := 5.5
## How long the ghost holds the old value before draining, and how fast it goes.
const LAG_HOLD := 0.35
const LAG_RATE := 55.0
const SHEAR := 6.0
const INK_W := 3.0
const GAP := 3.0
var _lag: float = 100.0
var _lag_hold: float = 0.0
var _flash: float = 0.0
var _pulse: float = 0.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
custom_minimum_size = Vector2(230, 22)
_lag = value
func _process(delta: float) -> void:
var dirty := false
if _lag > value:
if _lag_hold > 0.0:
_lag_hold -= delta
else:
_lag = maxf(_lag - LAG_RATE * delta, value)
dirty = true
elif _lag < value:
_lag = value
dirty = true
if _flash > 0.0:
_flash = maxf(_flash - delta * 4.5, 0.0)
dirty = true
if value / max_value <= LOW_AT and value > 0.0:
_pulse += delta * LOW_RATE
dirty = true
elif _pulse != 0.0:
_pulse = 0.0
dirty = true
if dirty:
queue_redraw()
func _draw() -> void:
var w := size.x
var h := size.y
if w <= 1.0 or h <= 1.0:
return
# Trough: ink fill with a soft inner edge, so an empty bar is still a shape
# on the screen rather than a hole in it.
_shear_rect(0.0, w, UITheme.INK, h)
_shear_outline(0.0, w, UITheme.INK_SOFT, h)
var frac: float = clampf(value / max_value, 0.0, 1.0)
var lag_frac: float = clampf(_lag / max_value, 0.0, 1.0)
# Ghost first, so the live fill draws over its left end and only the
# difference between the two is visible.
if lag_frac > frac:
var ghost := Color(fill_color.r, fill_color.g, fill_color.b, 0.38)
_shear_rect(0.0, w * lag_frac, ghost, h)
if frac > 0.0:
var col := fill_color
# Low health breathes toward volt. It never goes fully volt: that colour
# means "input landed" everywhere else in the UI and spending it on a
# steady state would blunt it.
if frac <= LOW_AT:
var b: float = 0.5 + 0.5 * sin(_pulse)
col = col.lerp(UITheme.VOLT, 0.35 * b)
# The instant of damage whites the bar out briefly — the cheapest way to
# make a hit register before the number has been read.
if _flash > 0.0:
col = col.lerp(UITheme.PAPER, _flash * 0.7)
_segments(w, h, frac, col)
# Ink edge last so it sits on top of both fills and reads as a drawn border.
_shear_outline(0.0, w, UITheme.INK, h)
## The fill, cut into countable blocks.
##
## The last block is clipped rather than dropped, so the bar still moves
## continuously as damage lands inside a block — the segments are for reading
## the amount at a glance, not for quantising it.
func _segments(w: float, h: float, frac: float, col: Color) -> void:
var count := maxi(int(round(max_value / per_segment)), 1)
var seg_w := (w - GAP * (count - 1)) / count
var filled := w * frac
for i in count:
var x0 := i * (seg_w + GAP)
if x0 >= filled:
break
var x1: float = minf(x0 + seg_w, filled)
if x1 - x0 < 0.5:
continue
_shear_rect(x0, x1, col, h)
## A parallelogram from x0 to x1 — the bar's lean, matching the theme's chips.
func _shear_rect(x0: float, x1: float, col: Color, h: float) -> void:
draw_colored_polygon(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0),
Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h),
Vector2(x0, h),
]), col)
func _shear_outline(x0: float, x1: float, col: Color, h: float) -> void:
draw_polyline(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0),
Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h),
Vector2(x0, h),
Vector2(x0 + SHEAR, 0.0),
]), col, INK_W)
+1
View File
@@ -0,0 +1 @@
uid://1syuhwlflh2p
+13 -3
View File
@@ -3,7 +3,17 @@ class_name DoubleBarrelShotgun
@export var reload_time: float = 1.0
var shells: int = 2
## The name and the capacity every other weapon in the set declares.
##
## This one did not, and it had "2" written inline in four places. The HUD had no
## way to ask how big a full load was, so the level's ammo panel carried a
## special case — `elif active_weapon is DoubleBarrelShotgun` — to fill in the
## name, and the capacity was simply hardcoded there as well. Anything that ever
## wanted to display this weapon had to know about it specifically.
@export var weapon_name: String = "Double Barrel"
@export var max_shells: int = 2
var shells: int = max_shells
var reloading: bool = false
var reload_timer: float = 0.0
var _vm_kick: float = 0.0
@@ -59,7 +69,7 @@ func _process(delta: float) -> void:
if reloading:
reload_timer -= delta
if reload_timer <= 0.0:
shells = 2
shells = max_shells
reloading = false
# Big single-shot shove that springs back.
@@ -77,7 +87,7 @@ func _input(event: InputEvent) -> void:
_try_fire()
if event.is_action_pressed("reload"):
if shells < 2 and not reloading:
if shells < max_shells and not reloading:
_start_reload()
func _start_reload() -> void: