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:
co-authored by
Claude Opus 5
parent
414026f001
commit
c4bcbc7fd1
@@ -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,11 +434,13 @@ 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))
|
||||
label.target_pos = hit_pos + offset
|
||||
@@ -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,28 +1059,12 @@ 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:
|
||||
_draw_trajectory()
|
||||
@@ -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)
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
add_child(match_hud_scene.instantiate())
|
||||
|
||||
_hud = PlayerHUD.new()
|
||||
_hud.name = "PlayerHUD"
|
||||
_hud.player = self
|
||||
add_child(_hud)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
if is_dead: return
|
||||
|
||||
Reference in New Issue
Block a user