Feat/fidelity pass 2 #23

Merged
Dotts merged 7 commits from feat/fidelity-pass-2 into main 2026-07-28 10:31:27 -07:00
17 changed files with 1396 additions and 1086 deletions
Showing only changes of commit c4bcbc7fd1 - Show all commits
+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
+43 -169
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,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
+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
+30 -8
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
# Draw background arc (darker)
draw_arc(Vector2.ZERO, radius, 0, PI * 2.0, 32, Color(0, 0, 0, 0.4), 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)
var mid := size * 0.5
# 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)
# 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: