Feat/fidelity pass 2 #23
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c340r7urelhhx
|
||||
+5
-307
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://78mssihccx1s
|
||||
@@ -27,10 +27,10 @@ var death_count: int = 0
|
||||
var is_dead: bool = false
|
||||
|
||||
# UI
|
||||
var health_bar: ProgressBar
|
||||
var shield_bar: ProgressBar
|
||||
var health_label: Label
|
||||
var shield_label: Label
|
||||
## The first-person HUD — reticle, vitals, ammo, death screen. See ui/player_hud.gd.
|
||||
var _hud: PlayerHUD = null
|
||||
## Kept because the controller drives them directly: the death screen is toggled
|
||||
## by die()/respawn, and the ring is read by the reload logic.
|
||||
var death_screen: Control
|
||||
|
||||
var ragdoll_instance: Node3D
|
||||
@@ -64,8 +64,7 @@ var grapple_latch_player: AudioStreamPlayer
|
||||
var grapple_swing_player: AudioStreamPlayer
|
||||
|
||||
# UI
|
||||
var hit_marker: Control
|
||||
var _hit_marker_tween: Tween
|
||||
## The reload ring, owned by the HUD. Kept here because the reload logic reads it.
|
||||
var reload_ring: Control
|
||||
|
||||
var grenades: int = 2
|
||||
@@ -146,7 +145,6 @@ func _ready() -> void:
|
||||
if is_multiplayer_authority():
|
||||
_damage_layer = CanvasLayer.new()
|
||||
add_child(_damage_layer)
|
||||
_setup_hit_marker()
|
||||
_setup_hud()
|
||||
_setup_speedlines()
|
||||
else:
|
||||
@@ -395,32 +393,6 @@ func _setup_speedlines() -> void:
|
||||
_damage_layer.add_child(_speedlines)
|
||||
|
||||
|
||||
func _setup_hit_marker() -> void:
|
||||
hit_marker = Control.new()
|
||||
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
|
||||
hit_marker.modulate.a = 0.0 # Hidden by default
|
||||
_damage_layer.add_child(hit_marker)
|
||||
|
||||
# Draw an X perfectly centered
|
||||
var length = 12
|
||||
var thickness = 2
|
||||
for angle in [PI/4, 3*PI/4, 5*PI/4, 7*PI/4]:
|
||||
var rect = ColorRect.new()
|
||||
rect.color = Color.WHITE
|
||||
rect.size = Vector2(length, thickness)
|
||||
rect.pivot_offset = rect.size / 2.0
|
||||
# Center the rect itself at (0,0) before offset
|
||||
var center_pos = -rect.size / 2.0
|
||||
# Move it outward along the angle so it doesn't cover the exact center dot
|
||||
rect.position = center_pos + Vector2(cos(angle), sin(angle)) * (length / 2.0)
|
||||
rect.rotation = angle
|
||||
hit_marker.add_child(rect)
|
||||
|
||||
# Reload Ring
|
||||
reload_ring = load("res://ui/reload_ring.gd").new()
|
||||
reload_ring.set_anchors_preset(Control.PRESET_CENTER)
|
||||
_damage_layer.add_child(reload_ring)
|
||||
|
||||
func _setup_grapple() -> void:
|
||||
grapple_rope = MeshInstance3D.new()
|
||||
var rope_mesh = CylinderMesh.new()
|
||||
@@ -462,10 +434,12 @@ func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
|
||||
|
||||
var label = load("res://ui/floating_damage_text.gd").new()
|
||||
label.text = str(round(amount))
|
||||
label.add_theme_font_size_override("font_size", 24)
|
||||
label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.1))
|
||||
label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
|
||||
label.add_theme_constant_override("outline_size", 4)
|
||||
label.add_theme_font_size_override("font_size", 28)
|
||||
# The theme's papaya and its violet ink, not an approximate orange on pure
|
||||
# black — these numbers fly over the same 3D scene the rest of the HUD does.
|
||||
label.add_theme_color_override("font_color", UITheme.PAPAYA)
|
||||
label.add_theme_color_override("font_outline_color", UITheme.INK)
|
||||
label.add_theme_constant_override("outline_size", 7)
|
||||
|
||||
# Small random offset in 3D space so multiple pellets don't perfectly overlap
|
||||
var offset = Vector3(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
|
||||
@@ -480,13 +454,10 @@ func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
|
||||
if now - _last_hit_sound_time > 10:
|
||||
_last_hit_sound_time = now
|
||||
hit_player.play()
|
||||
|
||||
if _hit_marker_tween and _hit_marker_tween.is_valid():
|
||||
_hit_marker_tween.kill()
|
||||
|
||||
hit_marker.modulate.a = 1.0
|
||||
_hit_marker_tween = create_tween()
|
||||
_hit_marker_tween.tween_property(hit_marker, "modulate:a", 0.0, 0.4)
|
||||
# The confirmation is part of the reticle now, so it lands where the eye
|
||||
# already is and shares its ink outline.
|
||||
if is_instance_valid(_hud):
|
||||
_hud.confirm_hit()
|
||||
|
||||
func apply_impulse(force: Vector3) -> void:
|
||||
velocity += force
|
||||
@@ -1088,27 +1059,11 @@ func _process(delta: float) -> void:
|
||||
lvisual.add_gun_recoil()
|
||||
_last_local_ammo = ammo_now
|
||||
|
||||
# Update UI
|
||||
if is_instance_valid(health_bar):
|
||||
health_bar.value = health
|
||||
health_label.text = "%d / %d" % [ceil(health), max_health]
|
||||
if is_instance_valid(shield_bar):
|
||||
shield_bar.value = shield
|
||||
shield_label.text = "%d / %d" % [ceil(shield), max_shield]
|
||||
|
||||
# Update Reload Ring
|
||||
if is_instance_valid(reload_ring) and is_instance_valid(camera):
|
||||
var wman = camera.get_node_or_null("WeaponManager")
|
||||
if wman:
|
||||
var slot = wman.get("active_slot")
|
||||
if slot != null and wman.weapons.has(slot):
|
||||
var w = wman.weapons[slot]
|
||||
if "reloading" in w and w.reloading and "reload_timer" in w and "reload_time" in w:
|
||||
reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time)
|
||||
else:
|
||||
reload_ring.progress = 0.0
|
||||
else:
|
||||
reload_ring.progress = 0.0
|
||||
# Vitals are pushed (the controller owns health and shield); ammo, the
|
||||
# reticle bloom and the reload ring are pulled by the HUD from the weapon,
|
||||
# which is the authority on all three.
|
||||
if is_instance_valid(_hud):
|
||||
_hud.set_vitals(health, max_health, shield, max_shield)
|
||||
|
||||
if is_multiplayer_authority() and not is_dead:
|
||||
if is_holding_grenade and grenades > 0:
|
||||
@@ -1139,112 +1094,31 @@ func _process(delta: float) -> void:
|
||||
if shield > max_shield:
|
||||
shield = max_shield
|
||||
|
||||
## Build the first-person HUD.
|
||||
##
|
||||
## Everything it draws lives in ui/player_hud.gd now. This used to be 105 lines
|
||||
## of stock ProgressBars and a plain-black death screen inlined here, which is
|
||||
## why none of it shared the game's look: a HUD assembled inside a 1500-line
|
||||
## movement controller is a HUD nobody styles.
|
||||
func _setup_hud() -> void:
|
||||
var margin = MarginContainer.new()
|
||||
margin.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
margin.offset_left = 20
|
||||
margin.offset_bottom = -20
|
||||
margin.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
|
||||
var vbox = VBoxContainer.new()
|
||||
margin.add_child(vbox)
|
||||
|
||||
# Add Match HUD overlay
|
||||
# Match state (timer, score, killfeed, scoreboard) is a separate overlay and
|
||||
# is deliberately not part of the player's own HUD.
|
||||
if is_multiplayer_authority():
|
||||
var match_hud_scene = load("res://ui/match_hud.tscn")
|
||||
if match_hud_scene:
|
||||
var match_hud = match_hud_scene.instantiate()
|
||||
add_child(match_hud)
|
||||
add_child(match_hud_scene.instantiate())
|
||||
|
||||
# Shield Bar (Top)
|
||||
var shield_box = VBoxContainer.new()
|
||||
var s_title = Label.new()
|
||||
s_title.text = "SHIELD"
|
||||
s_title.add_theme_font_size_override("font_size", 12)
|
||||
s_title.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0))
|
||||
shield_box.add_child(s_title)
|
||||
_hud = PlayerHUD.new()
|
||||
_hud.name = "PlayerHUD"
|
||||
_hud.player = self
|
||||
add_child(_hud)
|
||||
|
||||
shield_bar = ProgressBar.new()
|
||||
shield_bar.custom_minimum_size = Vector2(200, 20)
|
||||
shield_bar.max_value = max_shield
|
||||
shield_bar.value = shield
|
||||
shield_bar.show_percentage = false
|
||||
# The controller still owns these two — it toggles the death screen on death
|
||||
# and the ring is read by the reload logic — so keep the references it had.
|
||||
death_screen = _hud.death_screen
|
||||
reload_ring = _hud.reload_ring
|
||||
_hud.set_vitals(health, max_health, shield, max_shield)
|
||||
|
||||
var s_sb = StyleBoxFlat.new()
|
||||
s_sb.bg_color = Color(0.1, 0.4, 0.8)
|
||||
shield_bar.add_theme_stylebox_override("fill", s_sb)
|
||||
|
||||
shield_label = Label.new()
|
||||
shield_label.text = "100 / 100"
|
||||
shield_label.set_anchors_preset(Control.PRESET_CENTER)
|
||||
shield_bar.add_child(shield_label)
|
||||
|
||||
shield_box.add_child(shield_bar)
|
||||
vbox.add_child(shield_box)
|
||||
|
||||
# Spacer
|
||||
vbox.add_child(Control.new())
|
||||
|
||||
# Health Bar (Bottom)
|
||||
var health_box = VBoxContainer.new()
|
||||
var h_title = Label.new()
|
||||
h_title.text = "HEALTH"
|
||||
h_title.add_theme_font_size_override("font_size", 12)
|
||||
h_title.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
|
||||
health_box.add_child(h_title)
|
||||
|
||||
health_bar = ProgressBar.new()
|
||||
health_bar.custom_minimum_size = Vector2(200, 20)
|
||||
health_bar.max_value = max_health
|
||||
health_bar.value = health
|
||||
health_bar.show_percentage = false
|
||||
|
||||
var h_sb = StyleBoxFlat.new()
|
||||
h_sb.bg_color = Color(0.8, 0.1, 0.1)
|
||||
health_bar.add_theme_stylebox_override("fill", h_sb)
|
||||
|
||||
health_label = Label.new()
|
||||
health_label.text = "100 / 100"
|
||||
health_label.set_anchors_preset(Control.PRESET_CENTER)
|
||||
health_bar.add_child(health_label)
|
||||
|
||||
health_box.add_child(health_bar)
|
||||
vbox.add_child(health_box)
|
||||
|
||||
_damage_layer.add_child(margin)
|
||||
|
||||
# Setup Death Screen
|
||||
death_screen = ColorRect.new()
|
||||
death_screen.color = Color(0, 0, 0, 0.7)
|
||||
death_screen.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
death_screen.visible = false
|
||||
|
||||
var center = CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
death_screen.add_child(center)
|
||||
|
||||
var d_vbox = VBoxContainer.new()
|
||||
d_vbox.add_theme_constant_override("separation", 20)
|
||||
center.add_child(d_vbox)
|
||||
|
||||
var d_title = Label.new()
|
||||
d_title.text = "SYSTEM FAILURE"
|
||||
d_title.add_theme_font_size_override("font_size", 48)
|
||||
d_title.add_theme_color_override("font_color", Color(1.0, 0.2, 0.2))
|
||||
d_vbox.add_child(d_title)
|
||||
|
||||
var respawn_label = Label.new()
|
||||
respawn_label.text = "PRESS ANY KEY TO REBOOT"
|
||||
respawn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
respawn_label.add_theme_font_size_override("font_size", 24)
|
||||
d_vbox.add_child(respawn_label)
|
||||
|
||||
var tw = create_tween().set_loops()
|
||||
tw.tween_property(respawn_label, "modulate:a", 0.2, 0.8)
|
||||
tw.tween_property(respawn_label, "modulate:a", 1.0, 0.8)
|
||||
|
||||
if is_multiplayer_authority():
|
||||
_damage_layer.add_child(death_screen)
|
||||
|
||||
func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
if is_dead: return
|
||||
|
||||
+13
-293
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://48ja2bu8k6xm
|
||||
+175
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ck57a8waor1d1
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://hjsfcdnutb4t
|
||||
+28
-6
@@ -1,22 +1,44 @@
|
||||
extends Control
|
||||
class_name ReloadRing
|
||||
|
||||
## The reload arc, drawn around the reticle.
|
||||
##
|
||||
## Like Crosshair it owns the full rect and centres itself in `size` rather than
|
||||
## sitting at a zero-sized PRESET_CENTER: a control that draws before the
|
||||
## viewport has sized it lands in the top-left corner, and only a later redraw
|
||||
## moves it — which, for something that redraws on a value change, can be never.
|
||||
|
||||
var progress: float = 0.0:
|
||||
set(val):
|
||||
progress = clampf(val, 0.0, 1.0)
|
||||
var v := clampf(val, 0.0, 1.0)
|
||||
if is_equal_approx(v, progress):
|
||||
return
|
||||
progress = v
|
||||
queue_redraw()
|
||||
|
||||
var radius: float = 16.0
|
||||
var thickness: float = 4.0
|
||||
var color: Color = Color(1.0, 1.0, 1.0, 0.8)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
# Offsets too — see the note in ui/crosshair.gd.
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
resized.connect(queue_redraw)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if progress <= 0.0 or progress >= 1.0:
|
||||
return
|
||||
var mid := size * 0.5
|
||||
|
||||
# Draw background arc (darker)
|
||||
draw_arc(Vector2.ZERO, radius, 0, PI * 2.0, 32, Color(0, 0, 0, 0.4), thickness, true)
|
||||
# Ink track first, wider than the arc, so the ring reads over a bright
|
||||
# skybox the same way every other element in this UI does. The old version
|
||||
# used 40% black, which vanished against pale concrete.
|
||||
draw_arc(mid, radius, 0, TAU, 48, UITheme.INK, thickness + 4.0, true)
|
||||
draw_arc(mid, radius, 0, TAU, 48, UITheme.INK_SOFT, thickness, true)
|
||||
|
||||
# Draw progress arc
|
||||
var end_angle = -PI / 2.0 + (PI * 2.0 * progress)
|
||||
draw_arc(Vector2.ZERO, radius, -PI / 2.0, end_angle, 32, color, thickness, true)
|
||||
# The charged part, sweeping from twelve o'clock.
|
||||
draw_arc(mid, radius, -PI / 2.0, -PI / 2.0 + TAU * progress, 48, color,
|
||||
thickness, true)
|
||||
|
||||
+186
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://1syuhwlflh2p
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user