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