From cc33c566cb4a55d96ca8389be95f7b540a8decd1 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Fri, 10 Apr 2026 21:12:45 -0700 Subject: [PATCH 01/36] Emit speed_changed signal; add tests To resolve issue #279, we need to decouple the UI speed updates from the _physics_process loop. By introducing a speed_changed signal, the UI will only update when the speed value actually changes, rather than calculating warnings and bar sizes every single frame. Introduce a new speed_changed(float) signal in player.gd and hook it up to update UI and warnings. Connect/disconnect the handler in _ready/_exit_tree to avoid dangling connections, and emit the signal when speed actually changes (in _physics_process and on flameout). Also add guards for _settings validity and ensure flameout resets speed and notifies listeners. Add comprehensive GUT tests (test_player_fuel_logic.gd, test_player_movement_signals.gd and their uid files) that validate fuel behavior, rotor/timer reactions, lateral movement constraints, signal emission semantics, UI reactivity, and speed clamping. --- scripts/player.gd | 25 ++ test/gut/test_player_fuel_logic.gd | 207 ++++++++++++++++ test/gut/test_player_fuel_logic.gd.uid | 1 + test/gut/test_player_movement_signals.gd | 233 +++++++++++++++++++ test/gut/test_player_movement_signals.gd.uid | 1 + 5 files changed, 467 insertions(+) create mode 100644 test/gut/test_player_fuel_logic.gd create mode 100644 test/gut/test_player_fuel_logic.gd.uid create mode 100644 test/gut/test_player_movement_signals.gd create mode 100644 test/gut/test_player_movement_signals.gd.uid diff --git a/scripts/player.gd b/scripts/player.gd index ebd11ebd2..93e4b3558 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -6,6 +6,9 @@ extends Node2D ## Player controller for P-38 Lightning in SkyLockAssault. ## Manages movement, fuel, bounds, rotors (anim/sound), weapons. +## Emitted when the player's forward speed changes. +signal speed_changed(new_speed: float) + # Bounds hitbox scale (quarter texture = tight margin for top-down plane) const HITBOX_SCALE: float = 0.25 @@ -213,6 +216,9 @@ func _ready() -> void: speed["timer"].wait_time = BLINK_INTERVAL speed["timer"].one_shot = false # Repeat indefinitely speed["timer"].timeout.connect(_on_speed_blink_timer_timeout) + + # Connect speed signal + speed_changed.connect(_on_speed_changed) # Init speed bar speed["bar"].max_value = speed["max"] # Set max speed value @@ -229,6 +235,11 @@ func _ready() -> void: push_error("Weapon node not found! Check player.tscn scene tree for $Weapon child.") +func _on_speed_changed(_new_speed: float) -> void: + update_speed_bar() + check_speed_warning() + + # NEW: Defensive cleanup to prevent dangling signal connections # when the player is removed from the scene tree or reloaded. func _exit_tree() -> void: @@ -238,6 +249,9 @@ func _exit_tree() -> void: if _settings.fuel_depleted.is_connected(_on_player_out_of_fuel): _settings.fuel_depleted.disconnect(_on_player_out_of_fuel) + + if speed_changed.is_connected(_on_speed_changed): + speed_changed.disconnect(_on_speed_changed) # NEW: Observer pattern handler to react when GameSettingsResource @@ -280,7 +294,11 @@ func _on_player_out_of_fuel() -> void: Globals.log_message("Player is out of fuel! Engine flameout.", Globals.LogLevel.WARNING) # NEW: Migrated the speed reset to ensure the plane actually stops flying when fuel hits 0 + var old_speed: float = speed["speed"] speed["speed"] = 0.0 + + if old_speed != speed["speed"]: + speed_changed.emit(speed["speed"]) rotor_stop(rotor_right, rotor_right_sfx) rotor_stop(rotor_left, rotor_left_sfx) @@ -529,6 +547,9 @@ func _physics_process(_delta: float) -> void: # NEW: Guard against null references during teardown or tests if not is_instance_valid(_settings): return + + # Track speed to emit signal on change + var old_speed: float = speed["speed"] # Speed changes allowed only if fuel > 0 if Input.is_action_pressed("speed_up") and _settings.current_fuel > 0: @@ -543,6 +564,10 @@ func _physics_process(_delta: float) -> void: speed["speed"] = clamp(speed["speed"], 0, speed["max"]) else: speed["speed"] = clamp(speed["speed"], speed["min"], speed["max"]) + + # Emit signal if speed actually changed + if old_speed != speed["speed"]: + speed_changed.emit(speed["speed"]) # Left/Right movement var lateral_input: float = Input.get_axis("move_left", "move_right") diff --git a/test/gut/test_player_fuel_logic.gd b/test/gut/test_player_fuel_logic.gd new file mode 100644 index 000000000..d45ccd211 --- /dev/null +++ b/test/gut/test_player_fuel_logic.gd @@ -0,0 +1,207 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_player_fuel_logic.gd +## GUT unit tests for Player fuel consumption, engine states, and UI Reactivity. +extends "res://addons/gut/test.gd" + +const PLAYER_SCRIPT_PATH: String = "res://scripts/player.gd" + +var _mock_root: Node +var _player: Variant # CHANGED: Use Variant to allow dynamic property access to player.gd variables +var _original_settings: GameSettingsResource +var _added_actions: Array[String] = [] + +## Per-test setup. +## :rtype: void +func before_each() -> void: + _original_settings = Globals.settings + Globals.settings = GameSettingsResource.new() + Globals.settings.current_log_level = Globals.LogLevel.NONE + + for action: String in ["speed_up", "speed_down", "move_left", "move_right"]: + if not InputMap.has_action(action): + InputMap.add_action(action) + _added_actions.append(action) + + _mock_root = _build_mock_player_scene() + add_child_autoqfree(_mock_root) + _player = _mock_root.get_node("Player") + +## Per-test cleanup. +## :rtype: void +func after_each() -> void: + Globals.settings = _original_settings + for action: String in _added_actions: + InputMap.erase_action(action) + _added_actions.clear() + Input.action_release("move_left") + +## test_ui_updates_automatically_on_resource_change | Observer Pattern +## :rtype: void +func test_ui_updates_automatically_on_resource_change() -> void: + gut.p("Testing: Player UI responds seamlessly to external fuel updates.") + + var fuel_bar: ProgressBar = _player.fuel_bar + + Globals.settings.max_fuel = 200.0 + # Because of the resource setter, current_fuel modification fires 'setting_changed' automatically + Globals.settings.current_fuel = 150.0 + + assert_eq(fuel_bar.max_value, 200.0, "Fuel Bar max_value must sync with Resource max.") + assert_eq(fuel_bar.value, 150.0, "Fuel Bar value must sync automatically.") + +## test_engine_stops_on_zero_fuel | Component State +## :rtype: void +func test_engine_stops_on_zero_fuel() -> void: + gut.p("Testing: Zero fuel stops timers and rotor animations immediately.") + + _player.fuel_timer.start() + var anim_r: AnimatedSprite2D = _player.rotor_right.get_node("AnimatedSprite2D") + anim_r.play("default") + + _player._on_player_out_of_fuel() + + assert_true(_player.fuel_timer.is_stopped(), "Fuel timer must stop running on flameout.") + assert_false(anim_r.is_playing(), "Rotors must stop animating when fuel is empty.") + +## test_engine_reignites_on_refuel | Component State +## :rtype: void +func test_engine_reignites_on_refuel() -> void: + gut.p("Testing: Refueling from an empty tank restarts rotors and timers.") + + # Simulate dead engine + _player.fuel_timer.stop() + var anim_l: AnimatedSprite2D = _player.rotor_left.get_node("AnimatedSprite2D") + anim_l.stop() + + # Trigger the global setting change to simulate refuel logic + Globals.settings.current_fuel = 50.0 + + assert_false(_player.fuel_timer.is_stopped(), "Fuel timer must reignite on refuel.") + assert_true(anim_l.is_playing(), "Rotors must automatically resume spinning.") + +## test_lateral_movement_blocked_without_fuel | Movement Constraints +## :rtype: void +func test_lateral_movement_blocked_without_fuel() -> void: + gut.p("Testing: Lateral turning is disabled if fuel is completely empty.") + + Globals.settings.current_fuel = 0.0 + _player.speed["speed"] = 150.0 + _player.player.velocity.x = 0.0 + + Input.action_press("move_left") + _player._physics_process(0.1) + + assert_eq(float(_player.player.velocity.x), 0.0, "Plane must not turn without fuel, ignoring inputs.") + +# ========================================== +# MOCK BUILDER HELPER +# ========================================== +# Note: You can optionally extract this into a shared res://tests/test_helpers.gd base class later! +## Dynamically constructs the node hierarchy required by player.gd. +## :rtype: Node +func _build_mock_player_scene() -> Node: + var root: Node = Node.new() + root.name = "MockLevel" + + var panel: Panel = Panel.new() + panel.name = "PlayerStatsPanel" + var stats: Control = Control.new() + stats.name = "Stats" + + var fuel: Control = Control.new() + fuel.name = "Fuel" + var fuel_bar: ProgressBar = ProgressBar.new() + fuel_bar.name = "FuelBar" + var fuel_label: Label = Label.new() + fuel_label.name = "FuelLabel" + var f_timer: Timer = Timer.new() + f_timer.name = "BlinkTimer" + fuel_label.add_child(f_timer) + fuel.add_child(fuel_bar) + fuel.add_child(fuel_label) + + var speed: Control = Control.new() + speed.name = "Speed" + var speed_bar: ProgressBar = ProgressBar.new() + speed_bar.name = "SpeedBar" + var speed_label: Label = Label.new() + speed_label.name = "SpeedLabel" + var s_timer: Timer = Timer.new() + s_timer.name = "BlinkTimer" + speed_label.add_child(s_timer) + speed.add_child(speed_bar) + speed.add_child(speed_label) + + stats.add_child(fuel) + stats.add_child(speed) + panel.add_child(stats) + root.add_child(panel) + + var PlayerScript := load(PLAYER_SCRIPT_PATH) + var p_node: Variant = PlayerScript.new() + p_node.name = "Player" + + var cb2d: CharacterBody2D = CharacterBody2D.new() + cb2d.name = "CharacterBody2D" + + for rotor_name: String in ["RotorRight", "RotorLeft"]: + var rotor: Node2D = Node2D.new() + rotor.name = rotor_name + var sfx: AudioStreamPlayer2D = AudioStreamPlayer2D.new() + sfx.name = "AudioStreamPlayer2D" + var anim: AnimatedSprite2D = AnimatedSprite2D.new() + anim.name = "AnimatedSprite2D" + # var frames: SpriteFrames = SpriteFrames.new() + # frames.add_animation("default") + # anim.sprite_frames = frames + + var frames: SpriteFrames = SpriteFrames.new() + frames.add_animation("default") + # Add a dummy frame so play() actually engages and is_playing() returns true + var dummy_tex: PlaceholderTexture2D = PlaceholderTexture2D.new() + frames.add_frame("default", dummy_tex) + anim.sprite_frames = frames + + rotor.add_child(anim) + rotor.add_child(sfx) + cb2d.add_child(rotor) + + var sprite: Sprite2D = Sprite2D.new() + sprite.name = "Sprite2D" + var coll: CollisionPolygon2D = CollisionPolygon2D.new() + coll.name = "CollisionPolygon2D" + #var weapon: Node2D = Node2D.new() + #weapon.name = "Weapon" + + var weapon: Node2D = Node2D.new() + weapon.name = "Weapon" + + # Create a dummy script so player.gd's _ready() and _input() don't crash + var mock_weapon_script: GDScript = GDScript.new() + mock_weapon_script.source_code = """ +extends Node2D +var weapon_types: Array = [] +var current_index: int = 0 +func fire() -> void: + pass +func get_num_weapons() -> int: + return 1 +func switch_to(idx: int) -> void: + pass +""" + mock_weapon_script.reload() + weapon.set_script(mock_weapon_script) + + cb2d.add_child(sprite) + cb2d.add_child(coll) + cb2d.add_child(weapon) + + var fuel_timer: Timer = Timer.new() + fuel_timer.name = "FuelTimer" + + p_node.add_child(cb2d) + p_node.add_child(fuel_timer) + root.add_child(p_node) + + return root diff --git a/test/gut/test_player_fuel_logic.gd.uid b/test/gut/test_player_fuel_logic.gd.uid new file mode 100644 index 000000000..3863b8e6e --- /dev/null +++ b/test/gut/test_player_fuel_logic.gd.uid @@ -0,0 +1 @@ +uid://b0q7q3g1c058o diff --git a/test/gut/test_player_movement_signals.gd b/test/gut/test_player_movement_signals.gd new file mode 100644 index 000000000..aa6aa9be7 --- /dev/null +++ b/test/gut/test_player_movement_signals.gd @@ -0,0 +1,233 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_player_movement_signals.gd +## GUT unit tests for Player movement and the decoupled speed_changed signal. + +extends "res://addons/gut/test.gd" + +# UPDATE THIS PATH if player.gd is located in a different folder +const PLAYER_SCRIPT_PATH: String = "res://scripts/player.gd" + +var _mock_root: Node +var _player: Node2D +var _original_settings: GameSettingsResource +var _added_actions: Array[String] = [] + +## Per-test setup: Isolate memory and establish mock hierarchy. +## :rtype: void +func before_each() -> void: + _original_settings = Globals.settings + Globals.settings = GameSettingsResource.new() + Globals.settings.current_log_level = Globals.LogLevel.NONE + + # Guarantee required actions exist so simulated Input.action_press doesn't error + for action: String in ["speed_up", "speed_down", "move_left", "move_right"]: + if not InputMap.has_action(action): + InputMap.add_action(action) + _added_actions.append(action) + + _mock_root = _build_mock_player_scene() + add_child_autoqfree(_mock_root) + _player = _mock_root.get_node("Player") + +## Per-test cleanup. +## :rtype: void +func after_each() -> void: + Globals.settings = _original_settings + for action: String in _added_actions: + InputMap.erase_action(action) + _added_actions.clear() + + # Force-release simulated inputs to prevent test leakage + Input.action_release("speed_up") + Input.action_release("speed_down") + +## test_physics_emits_speed_changed_on_acceleration | Signal Behavior +## :rtype: void +func test_physics_emits_speed_changed_on_acceleration() -> void: + gut.p("Testing: _physics_process emits speed_changed exactly once per frame when accelerating.") + watch_signals(_player) + + Globals.settings.current_fuel = 100.0 + _player.speed["speed"] = 100.0 + + # Simulate acceleration input + Input.action_press("speed_up") + _player._physics_process(1.0) # 1 second delta to cause noticeable change + + assert_signal_emitted(_player, "speed_changed", "Signal must fire when speed up increases value.") + assert_gt(float(_player.speed["speed"]), 100.0, "Speed logic should have increased current speed.") + +## test_physics_does_not_spam_speed_changed | Signal Efficiency +## :rtype: void +func test_physics_does_not_spam_speed_changed() -> void: + gut.p("Testing: _physics_process suppresses speed_changed emissions when cruising.") + watch_signals(_player) + + Globals.settings.current_fuel = 100.0 + _player.speed["speed"] = 250.0 + + # Process multiple frames without active input + _player._physics_process(0.1) + _player._physics_process(0.1) + _player._physics_process(0.1) + + assert_signal_emit_count(_player, "speed_changed", 0, "Signal must not emit when speed is unchanged.") + +## test_flameout_resets_speed_and_emits_signal | Edge Cases +## :rtype: void +func test_flameout_resets_speed_and_emits_signal() -> void: + gut.p("Testing: Engine flameout halts the plane instantly and notifies UI.") + watch_signals(_player) + + _player.speed["speed"] = 300.0 + + # Manually trigger the flameout handler + _player._on_player_out_of_fuel() + + assert_eq(float(_player.speed["speed"]), 0.0, "Speed must forcibly reset to 0.0 on zero fuel.") + assert_signal_emitted(_player, "speed_changed", "Flameout must broadcast the speed halt to UI.") + +## test_ui_updates_on_speed_signal | UI Reactivity +## :rtype: void +func test_ui_updates_on_speed_signal() -> void: + gut.p("Testing: Target UI updates instantly when speed_changed fires.") + + _player.speed_bar.value = 0.0 + _player.speed["speed"] = 500.0 # Force local sync + + # Fire the signal explicitly as the engine would + _player.speed_changed.emit(500.0) + + assert_eq(_player.speed_bar.value, 500.0, "Progress bar must sync tightly with speed_changed.") + +## test_speed_clamps_to_max_and_min | Constraints +## :rtype: void +func test_speed_clamps_to_max_and_min() -> void: + gut.p("Testing: Speed values obey MIN and MAX constraints.") + + Globals.settings.current_fuel = 100.0 + var max_cap: float = _player.speed["max"] + + _player.speed["speed"] = max_cap - 5.0 + + Input.action_press("speed_up") + # Force an extreme acceleration delta + _player._physics_process(10.0) + + assert_eq(float(_player.speed["speed"]), max_cap, "Speed must not exceed configured MAX_SPEED.") + +# ========================================== +# MOCK BUILDER HELPER +# ========================================== + +## Dynamically constructs the node hierarchy required by player.gd. +## :rtype: Node +func _build_mock_player_scene() -> Node: + var root: Node = Node.new() + root.name = "MockLevel" + + # --- UI Siblings --- + var panel: Panel = Panel.new() + panel.name = "PlayerStatsPanel" + var stats: Control = Control.new() + stats.name = "Stats" + + var fuel: Control = Control.new() + fuel.name = "Fuel" + var fuel_bar: ProgressBar = ProgressBar.new() + fuel_bar.name = "FuelBar" + var fuel_label: Label = Label.new() + fuel_label.name = "FuelLabel" + var f_timer: Timer = Timer.new() + f_timer.name = "BlinkTimer" + fuel_label.add_child(f_timer) + fuel.add_child(fuel_bar) + fuel.add_child(fuel_label) + + var speed: Control = Control.new() + speed.name = "Speed" + var speed_bar: ProgressBar = ProgressBar.new() + speed_bar.name = "SpeedBar" + var speed_label: Label = Label.new() + speed_label.name = "SpeedLabel" + var s_timer: Timer = Timer.new() + s_timer.name = "BlinkTimer" + speed_label.add_child(s_timer) + speed.add_child(speed_bar) + speed.add_child(speed_label) + + stats.add_child(fuel) + stats.add_child(speed) + panel.add_child(stats) + root.add_child(panel) + + # --- Core Player --- + var PlayerScript := load(PLAYER_SCRIPT_PATH) + var p_node: Variant = PlayerScript.new() + p_node.name = "Player" + + var cb2d: CharacterBody2D = CharacterBody2D.new() + cb2d.name = "CharacterBody2D" + + for rotor_name: String in ["RotorRight", "RotorLeft"]: + var rotor: Node2D = Node2D.new() + rotor.name = rotor_name + var sfx: AudioStreamPlayer2D = AudioStreamPlayer2D.new() + sfx.name = "AudioStreamPlayer2D" + var anim: AnimatedSprite2D = AnimatedSprite2D.new() + anim.name = "AnimatedSprite2D" + # var frames: SpriteFrames = SpriteFrames.new() + # frames.add_animation("default") + # anim.sprite_frames = frames + + var frames: SpriteFrames = SpriteFrames.new() + frames.add_animation("default") + # Add a dummy frame so play() actually engages and is_playing() returns true + var dummy_tex: PlaceholderTexture2D = PlaceholderTexture2D.new() + frames.add_frame("default", dummy_tex) + anim.sprite_frames = frames + + rotor.add_child(anim) + rotor.add_child(sfx) + cb2d.add_child(rotor) + + var sprite: Sprite2D = Sprite2D.new() + sprite.name = "Sprite2D" + var coll: CollisionPolygon2D = CollisionPolygon2D.new() + coll.name = "CollisionPolygon2D" + # var weapon: Node2D = Node2D.new() + # weapon.name = "Weapon" + + var weapon: Node2D = Node2D.new() + weapon.name = "Weapon" + + # Create a dummy script so player.gd's _ready() and _input() don't crash + var mock_weapon_script: GDScript = GDScript.new() + mock_weapon_script.source_code = """ +extends Node2D +var weapon_types: Array = [] +var current_index: int = 0 +func fire() -> void: + pass +func get_num_weapons() -> int: + return 1 +func switch_to(idx: int) -> void: + pass +""" + + mock_weapon_script.reload() + weapon.set_script(mock_weapon_script) + + cb2d.add_child(sprite) + cb2d.add_child(coll) + cb2d.add_child(weapon) + + var fuel_timer: Timer = Timer.new() + fuel_timer.name = "FuelTimer" + + p_node.add_child(cb2d) + p_node.add_child(fuel_timer) + root.add_child(p_node) + + return root diff --git a/test/gut/test_player_movement_signals.gd.uid b/test/gut/test_player_movement_signals.gd.uid new file mode 100644 index 000000000..7a0d3c6fc --- /dev/null +++ b/test/gut/test_player_movement_signals.gd.uid @@ -0,0 +1 @@ +uid://cpmr3k4ocy567 From bd9f03cf3940a9c76c0b841d6612d3572adb9027 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Fri, 10 Apr 2026 21:16:00 -0700 Subject: [PATCH 02/36] Update player.gd --- scripts/player.gd | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/player.gd b/scripts/player.gd index 93e4b3558..4c557f3c6 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -216,7 +216,7 @@ func _ready() -> void: speed["timer"].wait_time = BLINK_INTERVAL speed["timer"].one_shot = false # Repeat indefinitely speed["timer"].timeout.connect(_on_speed_blink_timer_timeout) - + # Connect speed signal speed_changed.connect(_on_speed_changed) @@ -249,7 +249,7 @@ func _exit_tree() -> void: if _settings.fuel_depleted.is_connected(_on_player_out_of_fuel): _settings.fuel_depleted.disconnect(_on_player_out_of_fuel) - + if speed_changed.is_connected(_on_speed_changed): speed_changed.disconnect(_on_speed_changed) @@ -296,7 +296,7 @@ func _on_player_out_of_fuel() -> void: # NEW: Migrated the speed reset to ensure the plane actually stops flying when fuel hits 0 var old_speed: float = speed["speed"] speed["speed"] = 0.0 - + if old_speed != speed["speed"]: speed_changed.emit(speed["speed"]) @@ -547,7 +547,7 @@ func _physics_process(_delta: float) -> void: # NEW: Guard against null references during teardown or tests if not is_instance_valid(_settings): return - + # Track speed to emit signal on change var old_speed: float = speed["speed"] @@ -564,7 +564,7 @@ func _physics_process(_delta: float) -> void: speed["speed"] = clamp(speed["speed"], 0, speed["max"]) else: speed["speed"] = clamp(speed["speed"], speed["min"], speed["max"]) - + # Emit signal if speed actually changed if old_speed != speed["speed"]: speed_changed.emit(speed["speed"]) From 1590492e7ee4ae8f7b89f7398545fc4a04b7d529 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sat, 11 Apr 2026 20:42:12 -0700 Subject: [PATCH 03/36] Test name and assertions are inconsistent (max_and_min but only max is checked). Test name and assertions are inconsistent (max_and_min but only max is checked). Line 104 says max and min constraints, but this test only validates max clamp. Either rename it or add a min-clamp assertion. --- test/gut/test_player_movement_signals.gd | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/gut/test_player_movement_signals.gd b/test/gut/test_player_movement_signals.gd index aa6aa9be7..c877c96ed 100644 --- a/test/gut/test_player_movement_signals.gd +++ b/test/gut/test_player_movement_signals.gd @@ -108,7 +108,9 @@ func test_speed_clamps_to_max_and_min() -> void: Globals.settings.current_fuel = 100.0 var max_cap: float = _player.speed["max"] + var min_cap: float = _player.speed["min"] + # --- 1. Test MAX Clamp --- _player.speed["speed"] = max_cap - 5.0 Input.action_press("speed_up") @@ -116,6 +118,17 @@ func test_speed_clamps_to_max_and_min() -> void: _player._physics_process(10.0) assert_eq(float(_player.speed["speed"]), max_cap, "Speed must not exceed configured MAX_SPEED.") + Input.action_release("speed_up") # Release the key for the next test phase + + # --- 2. Test MIN Clamp --- + _player.speed["speed"] = min_cap + 5.0 + + Input.action_press("speed_down") + # Force an extreme deceleration delta + _player._physics_process(10.0) + + assert_eq(float(_player.speed["speed"]), min_cap, "Speed must not fall below configured MIN_SPEED.") + Input.action_release("speed_down") # Clean up # ========================================== # MOCK BUILDER HELPER From d857fe3f5a90b547010edcc5874bfb8dc76acc38 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sat, 11 Apr 2026 21:04:29 -0700 Subject: [PATCH 04/36] Update player.gd The PR adds only a single signal speed_changed(new_speed: float) to Player.gd. It does not define speed_low(threshold: float) or speed_maxed(), and the speed_changed signature does not include max_speed as specified in the issue. --- scripts/player.gd | 92 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 14 deletions(-) diff --git a/scripts/player.gd b/scripts/player.gd index 4c557f3c6..c703ca4be 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -7,7 +7,11 @@ extends Node2D ## Manages movement, fuel, bounds, rotors (anim/sound), weapons. ## Emitted when the player's forward speed changes. -signal speed_changed(new_speed: float) +signal speed_changed(new_speed: float, max_speed: float) +## Emitted when speed falls below the safe threshold. +signal speed_low(threshold: float) +## Emitted when the plane hits maximum velocity. +signal speed_maxed() # Bounds hitbox scale (quarter texture = tight margin for top-down plane) const HITBOX_SCALE: float = 0.25 @@ -75,6 +79,10 @@ var speed_label_blink_timer: Timer = $"../PlayerStatsPanel/Stats/Speed/SpeedLabe @onready var weapon: Node2D = $CharacterBody2D/Weapon # Path to your WeaponManager node +## Called when the node enters the scene tree for the first time. +## Initializes the player state, calculates screen boundaries, binds UI references, +## and connects core signals for the fuel and speed systems. +## @return: void func _ready() -> void: # Safely cache the settings resource _settings = Globals.settings if is_instance_valid(Globals) else null @@ -235,13 +243,19 @@ func _ready() -> void: push_error("Weapon node not found! Check player.tscn scene tree for $Weapon child.") -func _on_speed_changed(_new_speed: float) -> void: +## Callback triggered when the player's speed changes. +## Updates the speed UI bar and checks for proximity to minimum/maximum limits. +## @param _new_speed: The current forward speed of the player. +## @param _max_speed: The absolute maximum speed limit. +## @return: void +func _on_speed_changed(_new_speed: float, _max_speed: float) -> void: update_speed_bar() check_speed_warning() -# NEW: Defensive cleanup to prevent dangling signal connections -# when the player is removed from the scene tree or reloaded. +## Lifecycle callback triggered right before the node is removed from the tree. +## Safely disconnects global resource signals to prevent dangling references and memory leaks. +## @return: void func _exit_tree() -> void: if is_instance_valid(_settings): if _settings.setting_changed.is_connected(_on_setting_changed): @@ -254,8 +268,11 @@ func _exit_tree() -> void: speed_changed.disconnect(_on_speed_changed) -# NEW: Observer pattern handler to react when GameSettingsResource -# properties (like fuel) are updated externally. +## Observer pattern callback to react to updates from the global settings resource. +## Re-ignites engines if refueled, or updates UI limits when settings change. +## @param setting_name: The name of the property that was modified. +## @param new_value: The updated value of the property. +## @return: void func _on_setting_changed(setting_name: String, new_value: Variant) -> void: if not is_instance_valid(_settings): return @@ -289,7 +306,9 @@ func _on_setting_changed(setting_name: String, new_value: Variant) -> void: check_fuel_warning() -# NEW: Handler for engine failure triggered by the global fuel_depleted signal from the resource. +## Signal handler for engine failure triggered by the global fuel_depleted signal. +## Stops the plane, halts rotors, and broadcasts the flameout state to the UI. +## @return: void func _on_player_out_of_fuel() -> void: Globals.log_message("Player is out of fuel! Engine flameout.", Globals.LogLevel.WARNING) @@ -298,7 +317,8 @@ func _on_player_out_of_fuel() -> void: speed["speed"] = 0.0 if old_speed != speed["speed"]: - speed_changed.emit(speed["speed"]) + speed_changed.emit(speed["speed"], speed["max"]) + speed_low.emit(LOW_YELLOW_THRESHOLD) rotor_stop(rotor_right, rotor_right_sfx) rotor_stop(rotor_left, rotor_left_sfx) @@ -314,6 +334,10 @@ func get_label_text_color(label: Label) -> Color: return label.get_theme_color("font_color", "Label") +## Applies a dynamic font color override to a specified label. +## @param label: The Label node to modify. +## @param new_color: The target Color to apply. +## @return: void func set_label_text_color(label: Label, new_color: Color) -> void: if label: # Apply the color as a theme override @@ -321,6 +345,10 @@ func set_label_text_color(label: Label, new_color: Color) -> void: Globals.log_message("Label text color set to: " + str(new_color), Globals.LogLevel.DEBUG) +## Applies standard corner radiuses and assigns a custom stylebox to a ProgressBar. +## @param bar: The ProgressBar node to style. +## @param bar_fill_style: The StyleBoxFlat to configure and apply. +## @return: void func set_bar_fill_style(bar: ProgressBar, bar_fill_style: StyleBoxFlat) -> void: bar_fill_style.corner_radius_bottom_left = corner_radius bar_fill_style.corner_radius_top_left = corner_radius @@ -329,6 +357,9 @@ func set_bar_fill_style(bar: ProgressBar, bar_fill_style: StyleBoxFlat) -> void: bar.add_theme_stylebox_override("fill", bar_fill_style) +## Captures core input events for the player, specifically weapon firing and swapping. +## @param event: The input event detected by the engine. +## @return: void func _input(event: InputEvent) -> void: # Fire weapon if event.is_action_pressed("fire"): @@ -376,6 +407,9 @@ func rotor_stop(rotor: Node2D, rotor_sfx: AudioStreamPlayer2D) -> void: rotor_sfx.stop() +## Updates the fuel bar's visual fill and color based on the current fuel level. +## Transitions through Green, Yellow, and Red depending on configured resource thresholds. +## @return: void func update_fuel_bar() -> void: if not is_instance_valid(_settings): return @@ -461,7 +495,9 @@ func update_speed_bar() -> void: speed["factor"] = factor # Always store the updated value -# Connect Timer's timeout signal +## Timer callback triggered every tick of the fuel timer. +## Calculates dynamic fuel consumption based on current speed and game difficulty. +## @return: void func _on_fuel_timer_timeout() -> void: if not is_instance_valid(_settings): return @@ -478,6 +514,9 @@ func _on_fuel_timer_timeout() -> void: # Globals.log_message("Fuel left: " + str(_settings.current_fuel), Globals.LogLevel.DEBUG) +## Checks if the current fuel has dropped below the low-fuel threshold. +## Activates or deactivates the UI warning blinker accordingly. +## @return: void func check_fuel_warning() -> void: if not is_instance_valid(_settings): return @@ -512,6 +551,9 @@ func check_speed_warning() -> void: stop_blinking(speed) +## Initiates the blinking effect for a specific UI parameter dictionary (e.g., fuel or speed). +## @param param: A dictionary containing the target 'label', 'timer', and state flags. +## @return: void func start_blinking(param: Dictionary) -> void: if param["label"] and param["timer"]: param["blinking"] = true @@ -519,6 +561,9 @@ func start_blinking(param: Dictionary) -> void: _toggle_label(param) # Immediate first toggle +## Halts the blinking effect for a specific UI parameter dictionary and restores its base color. +## @param param: A dictionary containing the target 'label', 'timer', and state flags. +## @return: void func stop_blinking(param: Dictionary) -> void: if param["label"] and param["timer"]: param["blinking"] = false @@ -526,16 +571,23 @@ func stop_blinking(param: Dictionary) -> void: set_label_text_color(param["label"], param["base_color"]) +## Timer callback that toggles the visual state of the fuel warning label. +## @return: void func _on_fuel_blink_timer_timeout() -> void: if fuel["blinking"] and fuel["label"]: _toggle_label(fuel) +## Timer callback that toggles the visual state of the speed warning label. +## @return: void func _on_speed_blink_timer_timeout() -> void: if speed["blinking"] and speed["label"]: _toggle_label(speed) +## Swaps the text color of the given UI dictionary's label between its base and warning colors. +## @param param: The UI dictionary containing 'label', 'base_color', and 'warning_color'. +## @return: void func _toggle_label(param: Dictionary) -> void: if get_label_text_color(param["label"]) == param["base_color"]: set_label_text_color(param["label"], param["warning_color"]) @@ -543,6 +595,11 @@ func _toggle_label(param: Dictionary) -> void: set_label_text_color(param["label"], param["base_color"]) +## The main physics loop for the player. +## Handles forward acceleration/deceleration, lateral movement, boundary constraints, +## and broadcasts speed state changes to external observers. +## @param _delta: The time elapsed since the last physics frame. +## @return: void func _physics_process(_delta: float) -> void: # NEW: Guard against null references during teardown or tests if not is_instance_valid(_settings): @@ -565,9 +622,18 @@ func _physics_process(_delta: float) -> void: else: speed["speed"] = clamp(speed["speed"], speed["min"], speed["max"]) - # Emit signal if speed actually changed + # Emit signals if speed actually changed if old_speed != speed["speed"]: - speed_changed.emit(speed["speed"]) + # 1. Fixed the missing max_speed argument so the UI actually updates! + speed_changed.emit(speed["speed"], speed["max"]) + + # 2. Emit when we hit maximum speed + if speed["speed"] >= speed["max"]: + speed_maxed.emit() + + # 3. Emit when we fall below the safe threshold + if speed["speed"] <= LOW_YELLOW_THRESHOLD: + speed_low.emit(LOW_YELLOW_THRESHOLD) # Left/Right movement var lateral_input: float = Input.get_axis("move_left", "move_right") @@ -583,9 +649,7 @@ func _physics_process(_delta: float) -> void: player.position.x = clamp(player.position.x, player_x_min, player_x_max) player.position.y = clamp(player.position.y, player_y_min, player_y_max) - # Update UI - update_speed_bar() - check_speed_warning() + # REMOVED: update_speed_bar() and check_speed_warning() from here! # Perform player movement player.move_and_slide() From e0715d7bc739ce4232c63966992545086f73847c Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sat, 11 Apr 2026 21:05:24 -0700 Subject: [PATCH 05/36] Update player.gd --- scripts/player.gd | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/player.gd b/scripts/player.gd index c703ca4be..a92037f84 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -11,7 +11,7 @@ signal speed_changed(new_speed: float, max_speed: float) ## Emitted when speed falls below the safe threshold. signal speed_low(threshold: float) ## Emitted when the plane hits maximum velocity. -signal speed_maxed() +signal speed_maxed # Bounds hitbox scale (quarter texture = tight margin for top-down plane) const HITBOX_SCALE: float = 0.25 @@ -626,11 +626,11 @@ func _physics_process(_delta: float) -> void: if old_speed != speed["speed"]: # 1. Fixed the missing max_speed argument so the UI actually updates! speed_changed.emit(speed["speed"], speed["max"]) - + # 2. Emit when we hit maximum speed if speed["speed"] >= speed["max"]: speed_maxed.emit() - + # 3. Emit when we fall below the safe threshold if speed["speed"] <= LOW_YELLOW_THRESHOLD: speed_low.emit(LOW_YELLOW_THRESHOLD) From aa9fdc82501b57704a001c35a30dc30c61e6f9cb Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sat, 11 Apr 2026 21:11:25 -0700 Subject: [PATCH 06/36] Update test_player_movement_signals.gd --- test/gut/test_player_movement_signals.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/gut/test_player_movement_signals.gd b/test/gut/test_player_movement_signals.gd index c877c96ed..f96244f19 100644 --- a/test/gut/test_player_movement_signals.gd +++ b/test/gut/test_player_movement_signals.gd @@ -97,7 +97,7 @@ func test_ui_updates_on_speed_signal() -> void: _player.speed["speed"] = 500.0 # Force local sync # Fire the signal explicitly as the engine would - _player.speed_changed.emit(500.0) + _player.speed_changed.emit(500.0, _player.speed["max"]) assert_eq(_player.speed_bar.value, 500.0, "Progress bar must sync tightly with speed_changed.") From a861547b0b8283d4cba676b0cea078e186a4bd66 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sat, 11 Apr 2026 22:02:50 -0700 Subject: [PATCH 07/36] Add HUD, wire it up & refactor player/settings Add a standalone HUD (scripts/hud.gd + uid) and attach it to PlayerStatsPanel in main_scene.tscn; wire the HUD to the Player in scripts/main_scene.gd (stats_panel.setup_hud(player)). Refactor GameSettingsResource (scripts/game_settings_resource.gd) to expose speed-related exported properties (max/min speed, lateral speed, accel/decel, yellow fractions) with backing fields and emit setting_changed when updated. Clean up and decouple UI logic from player.gd: remove direct ProgressBar/Label manipulation and UI timers, use Globals.settings for fuel/speed values, emit speed_changed with correct max_speed, use settings values for physics (acceleration, limits, consumption), and improve rotor/weapon null-safety and logging. Other: update scene file load_steps and add hud script as an external resource in main_scene.tscn. These changes separate UI from game logic and centralize tuning in the settings resource. --- scenes/main_scene.tscn | 4 +- scripts/game_settings_resource.gd | 79 +++++ scripts/hud.gd | 372 ++++++++++++++++++++++ scripts/hud.gd.uid | 1 + scripts/main_scene.gd | 12 +- scripts/player.gd | 492 +++--------------------------- 6 files changed, 515 insertions(+), 445 deletions(-) create mode 100644 scripts/hud.gd create mode 100644 scripts/hud.gd.uid diff --git a/scenes/main_scene.tscn b/scenes/main_scene.tscn index 69e271aa9..6fb7610f4 100644 --- a/scenes/main_scene.tscn +++ b/scenes/main_scene.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=81 format=3 uid="uid://nnnc0qhx07i8"] +[gd_scene load_steps=82 format=3 uid="uid://nnnc0qhx07i8"] [ext_resource type="Script" uid="uid://ctm7qg12s2swt" path="res://scripts/main_scene.gd" id="1_7ykc4"] [ext_resource type="PackedScene" uid="uid://cb4n4cqkuddqg" path="res://scenes/pause_menu.tscn" id="1_w2twt"] @@ -76,6 +76,7 @@ [ext_resource type="Texture2D" uid="uid://btyfigdtk3x88" path="res://files/random_decor/crates_4.png" id="68_f3krf"] [ext_resource type="Texture2D" uid="uid://bvxu5x1awjrjv" path="res://files/random_decor/dirt_001.png" id="69_7tyuc"] [ext_resource type="Texture2D" uid="uid://f4hxu68qa4fi" path="res://files/random_decor/dirt_002.png" id="70_isor2"] +[ext_resource type="Script" uid="uid://blu5qujicfa7e" path="res://scripts/hud.gd" id="72_sgkfd"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pu3yx"] bg_color = Color(0.2627451, 0.2627451, 0.2627451, 0.5882353) @@ -123,6 +124,7 @@ offset_right = 1270.0 offset_bottom = 120.0 tooltip_text = "Player Status Panel" theme_override_styles/panel = SubResource("StyleBoxFlat_pu3yx") +script = ExtResource("72_sgkfd") metadata/_edit_use_anchors_ = true [node name="Stats" type="VBoxContainer" parent="PlayerStatsPanel"] diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index 8c963b4f8..ec851ddd1 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -22,6 +22,76 @@ signal setting_changed(setting_name: String, new_value: Variant) ## game-over states or low-fuel warnings without polling every frame. signal fuel_depleted +@export_group("Speed System") + +@export var max_speed: float = 713.0: + set(value): + var new_val: float = max(1.0, value) + if _max_speed == new_val: + return + _max_speed = new_val + setting_changed.emit("max_speed", _max_speed) + get: + return _max_speed + +@export var min_speed: float = 95.0: + set(value): + var new_val: float = max(0.0, value) + if _min_speed == new_val: + return + _min_speed = new_val + setting_changed.emit("min_speed", _min_speed) + get: + return _min_speed + +@export var lateral_speed: float = 250.0: + set(value): + if _lateral_speed == value: + return + _lateral_speed = value + setting_changed.emit("lateral_speed", _lateral_speed) + get: + return _lateral_speed + +@export var acceleration: float = 200.0: + set(value): + if _acceleration == value: + return + _acceleration = value + setting_changed.emit("acceleration", _acceleration) + get: + return _acceleration + +@export var deceleration: float = 100.0: + set(value): + if _deceleration == value: + return + _deceleration = value + setting_changed.emit("deceleration", _deceleration) + get: + return _deceleration + +@export var high_yellow_fraction: float = 0.80: + set(value): + var new_val: float = clamp(value, 0.0, 1.0) + if _high_yellow_fraction == new_val: + return + _high_yellow_fraction = new_val + setting_changed.emit("high_yellow_fraction", _high_yellow_fraction) + get: + return _high_yellow_fraction + +@export var low_yellow_fraction: float = 0.10: + set(value): + var new_val: float = clamp(value, 0.0, 1.0) + if _low_yellow_fraction == new_val: + return + _low_yellow_fraction = new_val + setting_changed.emit("low_yellow_fraction", _low_yellow_fraction) + get: + return _low_yellow_fraction + + @export_group("Fuel System") ## Maximum fuel capacity. @@ -167,6 +237,15 @@ var _medium_fuel_threshold: float = 50.0 var _low_fuel_threshold: float = 30.0 var _no_fuel_threshold: float = 15.0 +# Speed Backing Fields +var _max_speed: float = 713.0 +var _min_speed: float = 95.0 +var _lateral_speed: float = 250.0 +var _acceleration: float = 200.0 +var _deceleration: float = 100.0 +var _high_yellow_fraction: float = 0.80 +var _low_yellow_fraction: float = 0.10 + func _init() -> void: # This only runs if the values aren't already set (like in a .new() call) diff --git a/scripts/hud.gd b/scripts/hud.gd new file mode 100644 index 000000000..a70c1b8f3 --- /dev/null +++ b/scripts/hud.gd @@ -0,0 +1,372 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## hud.gd +## +## Heads-Up Display manager for SkyLockAssault. +## Handles all visual player statistics, including the Fuel and Speed progress bars, +## threshold calculations, and warning label animations. +## Operates entirely via Observer Patterns, completely decoupled from physics logic. +extends Panel + +# --- Speed Constants --- +# Moved from player.gd. These define the visual limits of the speed bar. +const MAX_SPEED: float = 713.0 # mph +const MIN_SPEED: float = 95.0 # mph + +const HIGH_YELLOW_FRACTION: float = 0.80 +const HIGH_RED_FRACTION: float = 0.90 +const LOW_YELLOW_FRACTION: float = 0.10 + +const HIGH_RED_THRESHOLD: float = MAX_SPEED * HIGH_RED_FRACTION +const HIGH_YELLOW_THRESHOLD: float = MAX_SPEED * HIGH_YELLOW_FRACTION +const LOW_YELLOW_THRESHOLD: float = MIN_SPEED + (MAX_SPEED - MIN_SPEED) * LOW_YELLOW_FRACTION +const LOW_RED_THRESHOLD: float = MIN_SPEED + +const DARK_RED: Color = Color(0.5, 0.0, 0.0) +const BLINK_INTERVAL: float = 0.5 + +# --- Internal State --- +var _settings: GameSettingsResource = null +var _current_speed: float = 250.0 + +var _fuel_state: Dictionary = {} +var _speed_state: Dictionary = {} + +var _fuel_bar_style: StyleBoxFlat +var _speed_bar_style: StyleBoxFlat + +# --- Node References --- +# Paths assume this script is attached directly to "PlayerStatsPanel" +@onready var fuel_bar: ProgressBar = $Stats/Fuel/FuelBar +@onready var fuel_label: Label = $Stats/Fuel/FuelLabel +@onready var fuel_blink_timer: Timer = $Stats/Fuel/FuelLabel/BlinkTimer + +@onready var speed_bar: ProgressBar = $Stats/Speed/SpeedBar +@onready var speed_label: Label = $Stats/Speed/SpeedLabel +@onready var speed_blink_timer: Timer = $Stats/Speed/SpeedLabel/BlinkTimer + + +## Called when the node enters the scene tree for the first time. +## Initializes UI styles, establishes local states, and connects to global settings. +## @return: void +func _ready() -> void: + _settings = Globals.settings if is_instance_valid(Globals) else null + + if not is_instance_valid(_settings): + push_error("HUD couldn't find Globals.settings! UI may not update correctly.") + return + + # Connect to global settings to automatically react to fuel updates + _settings.setting_changed.connect(_on_setting_changed) + _settings.fuel_depleted.connect(_on_player_out_of_fuel) + + # --- Fuel UI Setup --- + _fuel_bar_style = StyleBoxFlat.new() + set_bar_fill_style(fuel_bar, _fuel_bar_style) + fuel_bar.max_value = _settings.max_fuel + + _fuel_state = { + "label": fuel_label, + "timer": fuel_blink_timer, + "blinking": false, + "base_color": get_label_text_color(fuel_label), + "warning_color": Color.RED.lerp(DARK_RED, 1.0) + } + + if fuel_blink_timer: + fuel_blink_timer.wait_time = BLINK_INTERVAL + fuel_blink_timer.one_shot = false + fuel_blink_timer.timeout.connect(_on_fuel_blink_timer_timeout) + + # --- Speed UI Setup --- + _speed_bar_style = StyleBoxFlat.new() + set_bar_fill_style(speed_bar, _speed_bar_style) + speed_bar.max_value = MAX_SPEED + + _speed_state = { + "label": speed_label, + "timer": speed_blink_timer, + "blinking": false, + "base_color": get_label_text_color(speed_label), + "warning_color": Color.RED.lerp(DARK_RED, 1.0) + } + + if speed_blink_timer: + speed_blink_timer.wait_time = BLINK_INTERVAL + speed_blink_timer.one_shot = false + speed_blink_timer.timeout.connect(_on_speed_blink_timer_timeout) + + # Initial UI Draw + update_fuel_bar() + update_speed_bar() + + +## Wires the HUD to the Player node's exported signals. +## Call this from your main level script when instantiating the player and UI. +## @param player_node: The Player Node2D instance. +## @return: void +func setup_hud(player_node: Node2D) -> void: + if not is_instance_valid(player_node): + push_error("HUD setup failed: Invalid player node.") + return + + # Connect to the decoupled player signals + player_node.speed_changed.connect(_on_player_speed_changed) + + # Optional: Connect to the threshold signals if you want custom HUD behavior + # like screen shakes or global alarms when limits are reached. + # player_node.speed_maxed.connect(...) + # player_node.speed_low.connect(...) + + Globals.log_message("HUD successfully wired to Player signals.", Globals.LogLevel.DEBUG) + + +## Lifecycle callback triggered right before the node is removed from the tree. +## Safely disconnects global resource signals to prevent memory leaks. +## @return: void +func _exit_tree() -> void: + if is_instance_valid(_settings): + if _settings.setting_changed.is_connected(_on_setting_changed): + _settings.setting_changed.disconnect(_on_setting_changed) + if _settings.fuel_depleted.is_connected(_on_player_out_of_fuel): + _settings.fuel_depleted.disconnect(_on_player_out_of_fuel) + + +# ========================================== +# SIGNAL HANDLERS +# ========================================== + + +## Callback triggered externally by the Player node when its speed changes. +## @param new_speed: The current forward speed of the player. +## @param max_speed: The absolute maximum speed limit. +## @return: void +func _on_player_speed_changed(new_speed: float, max_speed: float) -> void: + _current_speed = new_speed + speed_bar.max_value = max_speed + update_speed_bar() + check_speed_warning() + + +## Observer pattern callback to react to updates from the global settings resource. +## @param setting_name: The name of the property that was modified. +## @param _new_value: The updated value of the property (unused directly here). +## @return: void +func _on_setting_changed(setting_name: String, _new_value: Variant) -> void: + if not is_instance_valid(_settings): + return + + if ( + setting_name + in [ + "current_fuel", + "max_fuel", + "high_fuel_threshold", + "medium_fuel_threshold", + "low_fuel_threshold", + "no_fuel_threshold" + ] + ): + if setting_name == "max_fuel": + fuel_bar.max_value = _settings.max_fuel + + update_fuel_bar() + check_fuel_warning() + + +## Signal handler for global engine failure. +## Triggers immediate UI feedback for a flameout state. +## @return: void +func _on_player_out_of_fuel() -> void: + _current_speed = 0.0 + update_speed_bar() + check_speed_warning() + + +# ========================================== +# UI UPDATE LOGIC +# ========================================== + + +## Updates the fuel bar's visual fill and color based on the current fuel level. +## @return: void +func update_fuel_bar() -> void: + if not is_instance_valid(_settings): + return + + var cur_fuel: float = _settings.current_fuel + var m_fuel: float = _settings.max_fuel + + fuel_bar.value = cur_fuel + var fuel_percent: float = 0.0 if m_fuel <= 0.0 else (cur_fuel / m_fuel) * 100.0 + var factor: float = 0.0 + + var high: float = _settings.high_fuel_threshold + var medium: float = _settings.medium_fuel_threshold + var low: float = _settings.low_fuel_threshold + var no_fuel: float = _settings.no_fuel_threshold + + if fuel_percent > high: + _fuel_bar_style.bg_color = Color.GREEN + elif fuel_percent >= medium: + var span: float = high - medium + factor = 1.0 if span <= 0.0 else clamp((high - fuel_percent) / span, 0.0, 1.0) + _fuel_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) + elif fuel_percent >= low: + var span: float = medium - low + factor = 1.0 if span <= 0.0 else clamp((medium - fuel_percent) / span, 0.0, 1.0) + _fuel_bar_style.bg_color = Color.YELLOW.lerp(Color.RED, factor) + elif fuel_percent >= no_fuel: + var span: float = low - no_fuel + factor = 1.0 if span <= 0.0 else clamp((low - fuel_percent) / span, 0.0, 1.0) + _fuel_bar_style.bg_color = Color.RED.lerp(DARK_RED, factor) + else: + _fuel_bar_style.bg_color = DARK_RED + + +## Updates the speed bar value and color based on current speed. +## @return: void +func update_speed_bar() -> void: + speed_bar.value = _current_speed + var factor: float = 0.0 + + if _current_speed >= HIGH_RED_THRESHOLD: + factor = clamp( + (_current_speed - HIGH_RED_THRESHOLD) / (MAX_SPEED - HIGH_RED_THRESHOLD), 0.0, 1.0 + ) + _speed_bar_style.bg_color = Color.YELLOW.lerp(DARK_RED, factor) + elif _current_speed >= HIGH_YELLOW_THRESHOLD: + factor = clamp( + (_current_speed - HIGH_YELLOW_THRESHOLD) / (HIGH_RED_THRESHOLD - HIGH_YELLOW_THRESHOLD), + 0.0, + 1.0 + ) + _speed_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) + elif _current_speed <= LOW_RED_THRESHOLD: + _speed_bar_style.bg_color = DARK_RED + elif _current_speed <= LOW_YELLOW_THRESHOLD: + factor = clamp( + (LOW_YELLOW_THRESHOLD - _current_speed) / (LOW_YELLOW_THRESHOLD - LOW_RED_THRESHOLD), + 0.0, + 1.0 + ) + _speed_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) + else: + _speed_bar_style.bg_color = Color.GREEN + + +# ========================================== +# WARNING & BLINK LOGIC +# ========================================== + + +## Checks if the current fuel has dropped below the low-fuel threshold. +## Activates or deactivates the UI warning blinker accordingly. +## @return: void +func check_fuel_warning() -> void: + if not is_instance_valid(_settings): + return + + var fuel_percent: float = ( + 0.0 if _settings.max_fuel <= 0.0 else (_settings.current_fuel / _settings.max_fuel) * 100.0 + ) + + if fuel_percent <= _settings.low_fuel_threshold and not _fuel_state["blinking"]: + start_blinking(_fuel_state) + elif fuel_percent > _settings.low_fuel_threshold and _fuel_state["blinking"]: + stop_blinking(_fuel_state) + + +## Checks speed and starts/stops label blinking if approaching or exceeding limits. +## @return: void +func check_speed_warning() -> void: + if ( + (_current_speed < LOW_YELLOW_THRESHOLD or _current_speed > HIGH_YELLOW_THRESHOLD) + and not _speed_state["blinking"] + ): + start_blinking(_speed_state) + elif ( + (LOW_YELLOW_THRESHOLD <= _current_speed and _current_speed <= HIGH_YELLOW_THRESHOLD) + and _speed_state["blinking"] + ): + stop_blinking(_speed_state) + + +## Initiates the blinking effect for a specific UI state dictionary. +## @param state: The target state dictionary. +## @return: void +func start_blinking(state: Dictionary) -> void: + if state["label"] and state["timer"]: + state["blinking"] = true + state["timer"].start() + _toggle_label(state) + + +## Halts the blinking effect for a specific UI state dictionary and restores its base color. +## @param state: The target state dictionary. +## @return: void +func stop_blinking(state: Dictionary) -> void: + if state["label"] and state["timer"]: + state["blinking"] = false + state["timer"].stop() + set_label_text_color(state["label"], state["base_color"]) + + +## Timer callback that toggles the visual state of the fuel warning label. +## @return: void +func _on_fuel_blink_timer_timeout() -> void: + if _fuel_state["blinking"] and _fuel_state["label"]: + _toggle_label(_fuel_state) + + +## Timer callback that toggles the visual state of the speed warning label. +## @return: void +func _on_speed_blink_timer_timeout() -> void: + if _speed_state["blinking"] and _speed_state["label"]: + _toggle_label(_speed_state) + + +## Swaps the text color of the given UI dictionary's label between its base and warning colors. +## @param state: The target state dictionary. +## @return: void +func _toggle_label(state: Dictionary) -> void: + if get_label_text_color(state["label"]) == state["base_color"]: + set_label_text_color(state["label"], state["warning_color"]) + else: + set_label_text_color(state["label"], state["base_color"]) + + +# ========================================== +# STYLING HELPERS +# ========================================== + + +## Retrieves the effective text color of a Label, considering theme overrides. +## @param label: The Label node to query. +## @return: The effective font color. +func get_label_text_color(label: Label) -> Color: + if label.has_theme_color_override("font_color"): + return label.get("theme_override_colors/font_color") + return label.get_theme_color("font_color", "Label") + + +## Applies a dynamic font color override to a specified label. +## @param label: The Label node to modify. +## @param new_color: The target Color to apply. +## @return: void +func set_label_text_color(label: Label, new_color: Color) -> void: + if label: + label.add_theme_color_override("font_color", new_color) + + +## Applies standard corner radiuses and assigns a custom stylebox to a ProgressBar. +## @param bar: The ProgressBar node to style. +## @param bar_fill_style: The StyleBoxFlat to configure and apply. +## @return: void +func set_bar_fill_style(bar: ProgressBar, bar_fill_style: StyleBoxFlat) -> void: + var corner_radius: int = 10 + bar_fill_style.corner_radius_bottom_left = corner_radius + bar_fill_style.corner_radius_top_left = corner_radius + bar_fill_style.corner_radius_bottom_right = corner_radius + bar_fill_style.corner_radius_top_right = corner_radius + bar.add_theme_stylebox_override("fill", bar_fill_style) diff --git a/scripts/hud.gd.uid b/scripts/hud.gd.uid new file mode 100644 index 000000000..c1323326b --- /dev/null +++ b/scripts/hud.gd.uid @@ -0,0 +1 @@ +uid://blu5qujicfa7e diff --git a/scripts/main_scene.gd b/scripts/main_scene.gd index bc158bcce..e9157f2f3 100644 --- a/scripts/main_scene.gd +++ b/scripts/main_scene.gd @@ -13,7 +13,8 @@ var _showing_unbound_warning: bool = false var _showing_unbound_key_message: bool = false @onready var player: Node2D = $Player -@onready var stats_panel: Panel = $PlayerStatsPanel +# @onready var stats_panel: Panel = $PlayerStatsPanel +@onready var stats_panel: Variant = $PlayerStatsPanel @onready var background: ParallaxBackground = $Background @onready var bushes_layer: ParallaxLayer = $Background/Bushes # Reference to the bushes layer @onready var decor_layer: ParallaxLayer = $Background/Decor # Reference to the decor layer @@ -28,6 +29,15 @@ func _ready() -> void: player.position = Vector2(viewport_size.x / 2, viewport_size.y / 1.2) stats_panel.visible = true Globals.log_message("Initializing main scene...", Globals.LogLevel.DEBUG) + + # ========================================================= + # THIS IS THE MISSING LINK THAT WAKES UP YOUR HUD! + # It passes the Player directly to the HUD script so the bars work. + # ========================================================= + if stats_panel.has_method("setup_hud"): + stats_panel.setup_hud(player) + else: + push_error("HUD Script is missing! Make sure 'hud.gd' is attached to the 'PlayerStatsPanel' node.") # Setup ground layer with tiling setup_parallax_layer($Background/Sand/Sprite2D, viewport_size, 2.0) # Sand layer diff --git a/scripts/player.gd b/scripts/player.gd index a92037f84..d50d9a24a 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -1,10 +1,11 @@ -## Copyright (C) 2025 Egor Kostan +## Copyright (C) 2026 Egor Kostan ## SPDX-License-Identifier: GPL-3.0-or-later ## player.gd extends Node2D ## Player controller for P-38 Lightning in SkyLockAssault. -## Manages movement, fuel, bounds, rotors (anim/sound), weapons. +## Manages movement, fuel consumption, bounds, rotors, and weapons. +## Completely decoupled from UI logic via Observer Patterns. ## Emitted when the player's forward speed changes. signal speed_changed(new_speed: float, max_speed: float) @@ -16,241 +17,86 @@ signal speed_maxed # Bounds hitbox scale (quarter texture = tight margin for top-down plane) const HITBOX_SCALE: float = 0.25 -# Speed -const MAX_SPEED: float = 713.0 # mph -const MIN_SPEED: float = 95.0 # mph - -# Speed threshold fractions (kept in one place to avoid divergence) -const HIGH_YELLOW_FRACTION: float = 0.80 -const HIGH_RED_FRACTION: float = 0.90 -const LOW_YELLOW_FRACTION: float = 0.10 - -# Gameplay / UI thresholds derived from fractions -const OVER_SPEED_THRESHOLD: float = MAX_SPEED * HIGH_RED_FRACTION -const HIGH_YELLOW_THRESHOLD: float = MAX_SPEED * HIGH_YELLOW_FRACTION - -# UI high red warning intentionally matches over-speed gameplay threshold -const HIGH_RED_THRESHOLD: float = OVER_SPEED_THRESHOLD -const LOW_YELLOW_THRESHOLD: float = MIN_SPEED + (MAX_SPEED - MIN_SPEED) * LOW_YELLOW_FRACTION -const LOW_RED_THRESHOLD: float = MIN_SPEED -const DARK_RED: Color = Color(0.5, 0.0, 0.0) -const BLINK_INTERVAL: float = 0.5 # Seconds between blinks - -# Exported vars first (for Inspector editing) -@export var lateral_speed: float = 250.0 -@export var acceleration: float = 200.0 -@export var deceleration: float = 100.0 - -# Regular vars for computed boundaries (no export needed if set in code) var screen_size: Vector2 var player_x_min: float = 0.0 var player_x_max: float = 0.0 var player_y_min: float = 0.0 var player_y_max: float = 0.0 -# Weapon system -var weapons: Array[Node] = [] # Fill in editor or _ready -var current_weapon: int = 0 + var rotor_left_sfx: AudioStreamPlayer2D var rotor_right_sfx: AudioStreamPlayer2D -var corner_radius: int = 10 -var fuel: Dictionary -var speed: Dictionary + +# Local state container for physics +var speed: Dictionary = {"speed": 250.0} # Cache the global settings to avoid singleton lookups in hot paths var _settings: GameSettingsResource = null -# Onreadys next +# Core Node References @onready var rotor_right: Node2D = $CharacterBody2D/RotorRight @onready var rotor_left: Node2D = $CharacterBody2D/RotorLeft @onready var player: CharacterBody2D = $CharacterBody2D @onready var player_sprite: Sprite2D = $CharacterBody2D/Sprite2D @onready var collision_shape: CollisionPolygon2D = $CharacterBody2D/CollisionPolygon2D -@onready var fuel_bar: ProgressBar = $"../PlayerStatsPanel/Stats/Fuel/FuelBar" -@onready var fuel_bar_fill_style: StyleBoxFlat = fuel_bar.get_theme_stylebox("fill") -@onready var fuel_label: Label = $"../PlayerStatsPanel/Stats/Fuel/FuelLabel" -@onready var fuel_label_blink_timer: Timer = $"../PlayerStatsPanel/Stats/Fuel/FuelLabel/BlinkTimer" @onready var fuel_timer: Timer = $FuelTimer -@onready -var speed_label_blink_timer: Timer = $"../PlayerStatsPanel/Stats/Speed/SpeedLabel/BlinkTimer" -@onready var speed_label: Label = $"../PlayerStatsPanel/Stats/Speed/SpeedLabel" -# Get the fill style -@onready var speed_bar: ProgressBar = $"../PlayerStatsPanel/Stats/Speed/SpeedBar" -@onready var speed_bar_fill_style: StyleBoxFlat = speed_bar.get_theme_stylebox("fill") -@onready var weapon: Node2D = $CharacterBody2D/Weapon # Path to your WeaponManager node +@onready var weapon: Node2D = $CharacterBody2D/Weapon ## Called when the node enters the scene tree for the first time. -## Initializes the player state, calculates screen boundaries, binds UI references, -## and connects core signals for the fuel and speed systems. +## Initializes the player state, calculates screen boundaries, binds inputs, +## and connects core signals for the fuel system. ## @return: void func _ready() -> void: # Safely cache the settings resource _settings = Globals.settings if is_instance_valid(Globals) else null if not is_instance_valid(_settings): - # NEW: Log the error, but generate a fallback resource so the player - # fully initializes and doesn't become a game-crashing "zombie" node. push_error("Player couldn't find Globals.settings! Using fallback defaults.") _settings = GameSettingsResource.new() - # NEW: Fix the "Split Brain" problem! - # If the Globals singleton exists, give it our new fallback resource - # so the rest of the game (like main_scene) shares the exact same data. if is_instance_valid(Globals): Globals.settings = _settings - # Auto-start rotors (overrides editor if needed) - rotor_left_sfx = rotor_left.get_node("AudioStreamPlayer2D") - rotor_right_sfx = rotor_right.get_node("AudioStreamPlayer2D") + # Auto-start rotors + rotor_left_sfx = rotor_left.get_node_or_null("AudioStreamPlayer2D") + rotor_right_sfx = rotor_right.get_node_or_null("AudioStreamPlayer2D") if rotor_left_sfx: rotor_left_sfx.bus = "SFX_Rotor_Left" - Globals.log_message("Twin rotors: LEFT stereo PAN active!", Globals.LogLevel.DEBUG) - else: - Globals.log_message("No left rotor SFX found", Globals.LogLevel.DEBUG) - if rotor_right_sfx: rotor_right_sfx.bus = "SFX_Rotor_Right" - Globals.log_message("Twin rotors: RIGHT stereo PAN active!", Globals.LogLevel.DEBUG) - else: - Globals.log_message("No right rotor SFX found", Globals.LogLevel.DEBUG) rotor_start(rotor_right, rotor_right_sfx) rotor_start(rotor_left, rotor_left_sfx) Globals.log_message("Rotors AUTO-STARTED at 24 FPS!", Globals.LogLevel.DEBUG) - # Set screen boundaries (safe null check + fallback) - screen_size = get_viewport_rect().size # Dynamic for web/resizes + # Set screen boundaries + screen_size = get_viewport_rect().size - var sprite_size: Vector2 = Vector2(174.0, 132.0) # Fallback if texture missing + var sprite_size: Vector2 = Vector2(174.0, 132.0) if player_sprite.texture != null: sprite_size = player_sprite.texture.get_size() - Globals.log_message("Player sprite size: " + str(sprite_size), Globals.LogLevel.DEBUG) else: - var warning_msg: String = ( - "Player sprite texture missing! Using fallback size: " + str(sprite_size) - ) - Globals.log_message(warning_msg, Globals.LogLevel.WARNING) - push_warning(warning_msg) + push_warning("Player sprite texture missing! Using fallback size.") player_x_min = (screen_size.x * -0.5) + (sprite_size[0] * HITBOX_SCALE) player_x_max = (screen_size.x * 0.5) - (sprite_size[0] * HITBOX_SCALE) player_y_min = (screen_size.y * -0.83) + (sprite_size[1] * HITBOX_SCALE) player_y_max = (screen_size.y / 6) - (sprite_size[1] * HITBOX_SCALE) - # After player_half_width/height calc - Globals.log_message( - ( - "Boundaries: x(" - + str(player_x_min) - + "-" - + str(player_x_max) - + ") y(" - + str(player_y_min) - + "-" - + str(player_y_max) - + ")" - ), - Globals.LogLevel.DEBUG - ) - - # Initialize fuel bar style - fuel_bar_fill_style = StyleBoxFlat.new() - set_bar_fill_style(fuel_bar, fuel_bar_fill_style) - # NEW: Using cached _settings as single source of truth - fuel_bar.max_value = _settings.max_fuel - - # NEW: Restored the unconditional fuel reset. Since the game doesn't use mid-run resumes, - # the player MUST spawn with a full tank to prevent infinite death loops from - # previous 0-fuel saves. + # Ensure the player always spawns with a full tank _settings.current_fuel = _settings.max_fuel - # Initialize speed bar style and value - speed_bar_fill_style = StyleBoxFlat.new() - set_bar_fill_style(speed_bar, speed_bar_fill_style) - speed_bar.max_value = MAX_SPEED - - # Initialize fuel bar style and value + # Initialize timers and observers fuel_timer.timeout.connect(_on_fuel_timer_timeout) fuel_timer.start() - # NEW: Connect to the global fuel_depleted signal to handle engine failure. _settings.fuel_depleted.connect(_on_player_out_of_fuel) - # NEW: Connect to the global setting_changed signal so the UI - # reacts to refuels/drains automatically. _settings.setting_changed.connect(_on_setting_changed) - # Initialize speed dictionary - # (Speed is still local to the player's physics, so it keeps its state here) - speed = { - "speed": 250.0, - "lateral_speed": lateral_speed, - "acceleration": acceleration, - "deceleration": deceleration, - "factor": 0.0, - "timer": speed_label_blink_timer, - "label": speed_label, - "max": MAX_SPEED, - "min": MIN_SPEED, - "bar": speed_bar, - "bar style": speed_bar_fill_style, - "blinking": false, - } - - # Initialize fuel dictionary - # (Fuel state now lives in _settings. This dict is ONLY for UI.) - fuel = { - "factor": 0.0, - "timer": fuel_label_blink_timer, - "label": fuel_label, - "bar": fuel_bar, - "bar style": fuel_bar_fill_style, - "blinking": false, - } - - # Base and warning colors per stat - fuel["base_color"] = get_label_text_color(fuel["label"]) - fuel["warning_color"] = Color.RED.lerp(Color(0.5, 0, 0), 1.0) - speed["base_color"] = get_label_text_color(speed["label"]) - speed["warning_color"] = Color.RED.lerp(Color(0.5, 0, 0), 1.0) - - # Initialize fuel blink timer - if fuel["timer"]: - fuel["timer"].wait_time = BLINK_INTERVAL - fuel["timer"].one_shot = false # Repeat indefinitely - fuel["timer"].timeout.connect(_on_fuel_blink_timer_timeout) - - # Initialize speed blink timer - if speed["timer"]: - speed["timer"].wait_time = BLINK_INTERVAL - speed["timer"].one_shot = false # Repeat indefinitely - speed["timer"].timeout.connect(_on_speed_blink_timer_timeout) - - # Connect speed signal - speed_changed.connect(_on_speed_changed) - - # Init speed bar - speed["bar"].max_value = speed["max"] # Set max speed value - update_speed_bar() # Ensure the bar updates with the initial speed - update_fuel_bar() # Set initial UI and color - - # Null-safe weapon log if weapon: - Globals.log_message( - "Player ready. Weapons loaded: " + str(weapon.weapon_types.size()), - Globals.LogLevel.DEBUG - ) + Globals.log_message("Player ready. Weapons loaded.", Globals.LogLevel.DEBUG) else: - push_error("Weapon node not found! Check player.tscn scene tree for $Weapon child.") - - -## Callback triggered when the player's speed changes. -## Updates the speed UI bar and checks for proximity to minimum/maximum limits. -## @param _new_speed: The current forward speed of the player. -## @param _max_speed: The absolute maximum speed limit. -## @return: void -func _on_speed_changed(_new_speed: float, _max_speed: float) -> void: - update_speed_bar() - check_speed_warning() + push_error("Weapon node not found! Check player.tscn scene tree.") ## Lifecycle callback triggered right before the node is removed from the tree. @@ -260,16 +106,12 @@ func _exit_tree() -> void: if is_instance_valid(_settings): if _settings.setting_changed.is_connected(_on_setting_changed): _settings.setting_changed.disconnect(_on_setting_changed) - if _settings.fuel_depleted.is_connected(_on_player_out_of_fuel): _settings.fuel_depleted.disconnect(_on_player_out_of_fuel) - if speed_changed.is_connected(_on_speed_changed): - speed_changed.disconnect(_on_speed_changed) - ## Observer pattern callback to react to updates from the global settings resource. -## Re-ignites engines if refueled, or updates UI limits when settings change. +## Re-ignites engines if refueled. ## @param setting_name: The name of the property that was modified. ## @param new_value: The updated value of the property. ## @return: void @@ -278,9 +120,8 @@ func _on_setting_changed(setting_name: String, new_value: Variant) -> void: return if setting_name == "current_fuel": - # NEW: Check if the engine was previously dead (timer stopped) and we just got refueled + # Reignite the engine if previously dead and we just got refueled if float(new_value) > 0.0 and fuel_timer.is_stopped(): - # Reignite the engine: restart the consumption timer and spin up the rotors! fuel_timer.start() rotor_start(rotor_right, rotor_right_sfx) rotor_start(rotor_left, rotor_left_sfx) @@ -288,87 +129,39 @@ func _on_setting_changed(setting_name: String, new_value: Variant) -> void: "Engine reignited! Rotors and fuel consumption resumed.", Globals.LogLevel.INFO ) - update_fuel_bar() - check_fuel_warning() - - elif ( - setting_name - in [ - "max_fuel", - "high_fuel_threshold", - "medium_fuel_threshold", - "low_fuel_threshold", - "no_fuel_threshold" - ] - ): - fuel_bar.max_value = _settings.max_fuel - update_fuel_bar() - check_fuel_warning() - ## Signal handler for engine failure triggered by the global fuel_depleted signal. -## Stops the plane, halts rotors, and broadcasts the flameout state to the UI. +## Stops the plane, halts rotors, and broadcasts the flameout state. ## @return: void func _on_player_out_of_fuel() -> void: Globals.log_message("Player is out of fuel! Engine flameout.", Globals.LogLevel.WARNING) - # NEW: Migrated the speed reset to ensure the plane actually stops flying when fuel hits 0 var old_speed: float = speed["speed"] speed["speed"] = 0.0 if old_speed != speed["speed"]: - speed_changed.emit(speed["speed"], speed["max"]) - speed_low.emit(LOW_YELLOW_THRESHOLD) + speed_changed.emit(speed["speed"], _settings.max_speed) + var low_yellow_thresh: float = ( + _settings.min_speed + + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction + ) + speed_low.emit(low_yellow_thresh) rotor_stop(rotor_right, rotor_right_sfx) rotor_stop(rotor_left, rotor_left_sfx) fuel_timer.stop() -## Retrieves the effective text color of a Label, considering theme overrides. -## @param label: The Label node to query. -## @return: The effective font color. -func get_label_text_color(label: Label) -> Color: - if label.has_theme_color_override("font_color"): - return label.get("theme_override_colors/font_color") - return label.get_theme_color("font_color", "Label") - - -## Applies a dynamic font color override to a specified label. -## @param label: The Label node to modify. -## @param new_color: The target Color to apply. -## @return: void -func set_label_text_color(label: Label, new_color: Color) -> void: - if label: - # Apply the color as a theme override - label.add_theme_color_override("font_color", new_color) - Globals.log_message("Label text color set to: " + str(new_color), Globals.LogLevel.DEBUG) - - -## Applies standard corner radiuses and assigns a custom stylebox to a ProgressBar. -## @param bar: The ProgressBar node to style. -## @param bar_fill_style: The StyleBoxFlat to configure and apply. -## @return: void -func set_bar_fill_style(bar: ProgressBar, bar_fill_style: StyleBoxFlat) -> void: - bar_fill_style.corner_radius_bottom_left = corner_radius - bar_fill_style.corner_radius_top_left = corner_radius - bar_fill_style.corner_radius_bottom_right = corner_radius - bar_fill_style.corner_radius_top_right = corner_radius - bar.add_theme_stylebox_override("fill", bar_fill_style) - - ## Captures core input events for the player, specifically weapon firing and swapping. ## @param event: The input event detected by the engine. ## @return: void func _input(event: InputEvent) -> void: - # Fire weapon if event.is_action_pressed("fire"): if weapon and weapon.has_method("fire"): weapon.fire() get_viewport().set_input_as_handled() - # Change weapon + if event.is_action_pressed("next_weapon"): - Globals.log_message("Next weapon input pressed", Globals.LogLevel.DEBUG) if weapon and weapon.has_method("switch_to") and weapon.get_num_weapons() > 1: var next: int = (weapon.current_index + 1) % weapon.get_num_weapons() weapon.switch_to(next) @@ -383,10 +176,6 @@ func rotor_start(rotor: Node2D, rotor_sfx: AudioStreamPlayer2D) -> void: if rotor.has_node("AnimatedSprite2D"): var anim_sprite: AnimatedSprite2D = rotor.get_node("AnimatedSprite2D") as AnimatedSprite2D anim_sprite.play("default") - else: - Globals.log_message( - "AnimatedSprite2D not found in rotor: " + rotor.name, Globals.LogLevel.WARNING - ) if rotor_sfx != null: rotor_sfx.play() @@ -399,102 +188,10 @@ func rotor_stop(rotor: Node2D, rotor_sfx: AudioStreamPlayer2D) -> void: if rotor.has_node("AnimatedSprite2D"): var anim_sprite: AnimatedSprite2D = rotor.get_node("AnimatedSprite2D") as AnimatedSprite2D anim_sprite.stop() - else: - Globals.log_message( - "AnimatedSprite2D not found in rotor: " + rotor.name, Globals.LogLevel.WARNING - ) if rotor_sfx != null: rotor_sfx.stop() -## Updates the fuel bar's visual fill and color based on the current fuel level. -## Transitions through Green, Yellow, and Red depending on configured resource thresholds. -## @return: void -func update_fuel_bar() -> void: - if not is_instance_valid(_settings): - return - - # Explicitly read current and max fuel from the global settings resource. - var cur_fuel: float = _settings.current_fuel - var m_fuel: float = _settings.max_fuel - - fuel["bar"].value = cur_fuel - var fuel_percent: float = 0.0 if m_fuel <= 0.0 else (cur_fuel / m_fuel) * 100.0 - - # Cache thresholds locally to keep the logic clean and readable - var high: float = _settings.high_fuel_threshold - var medium: float = _settings.medium_fuel_threshold - var low: float = _settings.low_fuel_threshold - var no_fuel: float = _settings.no_fuel_threshold - - if fuel_percent > high: - fuel["factor"] = 0.0 # Reset for consistency - fuel["bar style"].bg_color = Color.GREEN - - elif fuel_percent >= medium: - var span: float = high - medium - # Guard against division by zero or negative spans - fuel["factor"] = 1.0 if span <= 0.0 else clamp((high - fuel_percent) / span, 0.0, 1.0) - fuel["bar style"].bg_color = Color.GREEN.lerp(Color.YELLOW, fuel["factor"]) - - elif fuel_percent >= low: - var span: float = medium - low - fuel["factor"] = 1.0 if span <= 0.0 else clamp((medium - fuel_percent) / span, 0.0, 1.0) - fuel["bar style"].bg_color = Color.YELLOW.lerp(Color.RED, fuel["factor"]) - - elif fuel_percent >= no_fuel: - var span: float = low - no_fuel - fuel["factor"] = 1.0 if span <= 0.0 else clamp((low - fuel_percent) / span, 0.0, 1.0) - fuel["bar style"].bg_color = Color.RED.lerp(Color(0.5, 0, 0), fuel["factor"]) - - else: - fuel["factor"] = 1.0 # Explicitly set to max lerp (full dark red) - fuel["bar style"].bg_color = Color.RED.lerp(Color(0.5, 0, 0), fuel["factor"]) - - -## Updates the speed bar value and color based on current speed. -## Colors: green normal, yellow approaching limits, red/dark red at limits. -## Factor is always updated to represent normalized proximity to limits (0.0 safe, 1.0 danger). -## @return: void -func update_speed_bar() -> void: - speed["bar"].value = speed["speed"] - var speed_val: float = speed["speed"] - var factor: float = 0.0 # Always reset to safe/default - - if speed_val >= HIGH_RED_THRESHOLD: - # Proximity to high red limit, clamped into [0.0, 1.0] - factor = clamp( - (speed_val - HIGH_RED_THRESHOLD) / (MAX_SPEED - HIGH_RED_THRESHOLD), 0.0, 1.0 - ) - speed["bar style"].bg_color = Color.YELLOW.lerp(DARK_RED, factor) - elif speed_val >= HIGH_YELLOW_THRESHOLD: - # Proximity to high yellow limit, clamped into [0.0, 1.0] - factor = clamp( - (speed_val - HIGH_YELLOW_THRESHOLD) / (HIGH_RED_THRESHOLD - HIGH_YELLOW_THRESHOLD), - 0.0, - 1.0 - ) - speed["bar style"].bg_color = Color.GREEN.lerp(Color.YELLOW, factor) - elif speed_val <= LOW_RED_THRESHOLD: - # Full danger at/under low red limit - factor = 1.0 - speed["bar style"].bg_color = DARK_RED - elif speed_val <= LOW_YELLOW_THRESHOLD: - # Proximity to low yellow limit (inverted), clamped into [0.0, 1.0] - factor = clamp( - (LOW_YELLOW_THRESHOLD - speed_val) / (LOW_YELLOW_THRESHOLD - LOW_RED_THRESHOLD), - 0.0, - 1.0 - ) - speed["bar style"].bg_color = Color.GREEN.lerp(Color.YELLOW, factor) - else: - # Safe/green: explicit safe value - factor = 0.0 - speed["bar style"].bg_color = Color.GREEN - - speed["factor"] = factor # Always store the updated value - - ## Timer callback triggered every tick of the fuel timer. ## Calculates dynamic fuel consumption based on current speed and game difficulty. ## @return: void @@ -502,97 +199,11 @@ func _on_fuel_timer_timeout() -> void: if not is_instance_valid(_settings): return - # NEW: Calculate depletion based on Global settings and update the resource directly. - # NEW: Game over logic is now handled by _on_player_out_of_fuel via the fuel_depleted signal. - # NEW: UI updates are handled automatically via the setting_changed signal. - var normalized_speed: float = clamp(speed["speed"] / MAX_SPEED, 0.0, 1.0) + var normalized_speed: float = clamp(speed["speed"] / _settings.max_speed, 0.0, 1.0) var consumption: float = ( _settings.base_consumption_rate * normalized_speed * _settings.difficulty ) _settings.current_fuel -= consumption - # Keep Globals.log_message since it is a static utility, not the state object itself - # Globals.log_message("Fuel left: " + str(_settings.current_fuel), Globals.LogLevel.DEBUG) - - -## Checks if the current fuel has dropped below the low-fuel threshold. -## Activates or deactivates the UI warning blinker accordingly. -## @return: void -func check_fuel_warning() -> void: - if not is_instance_valid(_settings): - return - - # NEW: Calculate the fuel percentage first to ensure consistency with the UI bar colors - var cur_fuel: float = _settings.current_fuel - var m_fuel: float = _settings.max_fuel - var fuel_percent: float = 0.0 if m_fuel <= 0.0 else (cur_fuel / m_fuel) * 100.0 - - # NEW: Compare the calculated percentage against the threshold - if fuel_percent <= _settings.low_fuel_threshold and not fuel["blinking"]: - start_blinking(fuel) - # NEW: Compare the calculated percentage against the threshold - elif fuel_percent > _settings.low_fuel_threshold and fuel["blinking"]: - stop_blinking(fuel) - - -## Checks speed and starts/stops label blinking if approaching or exceeding limits. -## Blinking activates in yellow/red zones for low/high speeds. -## @return: void -func check_speed_warning() -> void: - if ( - (speed["speed"] < LOW_YELLOW_THRESHOLD or speed["speed"] > HIGH_YELLOW_THRESHOLD) - and not speed["blinking"] - ): - start_blinking(speed) - elif ( - LOW_YELLOW_THRESHOLD <= speed["speed"] - and speed["speed"] <= HIGH_YELLOW_THRESHOLD - and speed["blinking"] - ): - stop_blinking(speed) - - -## Initiates the blinking effect for a specific UI parameter dictionary (e.g., fuel or speed). -## @param param: A dictionary containing the target 'label', 'timer', and state flags. -## @return: void -func start_blinking(param: Dictionary) -> void: - if param["label"] and param["timer"]: - param["blinking"] = true - param["timer"].start() - _toggle_label(param) # Immediate first toggle - - -## Halts the blinking effect for a specific UI parameter dictionary and restores its base color. -## @param param: A dictionary containing the target 'label', 'timer', and state flags. -## @return: void -func stop_blinking(param: Dictionary) -> void: - if param["label"] and param["timer"]: - param["blinking"] = false - param["timer"].stop() - set_label_text_color(param["label"], param["base_color"]) - - -## Timer callback that toggles the visual state of the fuel warning label. -## @return: void -func _on_fuel_blink_timer_timeout() -> void: - if fuel["blinking"] and fuel["label"]: - _toggle_label(fuel) - - -## Timer callback that toggles the visual state of the speed warning label. -## @return: void -func _on_speed_blink_timer_timeout() -> void: - if speed["blinking"] and speed["label"]: - _toggle_label(speed) - - -## Swaps the text color of the given UI dictionary's label between its base and warning colors. -## @param param: The UI dictionary containing 'label', 'base_color', and 'warning_color'. -## @return: void -func _toggle_label(param: Dictionary) -> void: - if get_label_text_color(param["label"]) == param["base_color"]: - set_label_text_color(param["label"], param["warning_color"]) - else: - set_label_text_color(param["label"], param["base_color"]) ## The main physics loop for the player. @@ -601,55 +212,50 @@ func _toggle_label(param: Dictionary) -> void: ## @param _delta: The time elapsed since the last physics frame. ## @return: void func _physics_process(_delta: float) -> void: - # NEW: Guard against null references during teardown or tests if not is_instance_valid(_settings): return - # Track speed to emit signal on change var old_speed: float = speed["speed"] # Speed changes allowed only if fuel > 0 if Input.is_action_pressed("speed_up") and _settings.current_fuel > 0: - speed["speed"] += speed["acceleration"] * _delta + speed["speed"] += _settings.acceleration * _delta if Input.is_action_pressed("speed_down") and _settings.current_fuel > 0: - speed["speed"] -= speed["deceleration"] * _delta + speed["speed"] -= _settings.deceleration * _delta # Clamp current_speed between MIN_SPEED and MAX_SPEED if _settings.current_fuel == 0: - # No fuel left, airplane can't fly - speed["speed"] = clamp(speed["speed"], 0, speed["max"]) + speed["speed"] = clamp(speed["speed"], 0.0, _settings.max_speed) else: - speed["speed"] = clamp(speed["speed"], speed["min"], speed["max"]) + speed["speed"] = clamp(speed["speed"], _settings.min_speed, _settings.max_speed) # Emit signals if speed actually changed if old_speed != speed["speed"]: - # 1. Fixed the missing max_speed argument so the UI actually updates! - speed_changed.emit(speed["speed"], speed["max"]) + speed_changed.emit(speed["speed"], _settings.max_speed) - # 2. Emit when we hit maximum speed - if speed["speed"] >= speed["max"]: + # Check for maximum speed limit + if speed["speed"] >= _settings.max_speed: speed_maxed.emit() - # 3. Emit when we fall below the safe threshold - if speed["speed"] <= LOW_YELLOW_THRESHOLD: - speed_low.emit(LOW_YELLOW_THRESHOLD) + # Check for low speed warning + var low_yellow_thresh: float = ( + _settings.min_speed + + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction + ) + if speed["speed"] <= low_yellow_thresh: + speed_low.emit(low_yellow_thresh) # Left/Right movement var lateral_input: float = Input.get_axis("move_left", "move_right") - # Left/Right movement, only allowed when fuel > 0 and the player is moving if lateral_input and _settings.current_fuel > 0 and speed["speed"] > 0: - player.velocity.x = lateral_input * speed["lateral_speed"] - # Reset lateral velocity if no input + player.velocity.x = lateral_input * _settings.lateral_speed else: player.velocity.x = 0.0 - # Clamp player position within allowed ranged of coords + # Clamp player position within boundaries player.position.x = clamp(player.position.x, player_x_min, player_x_max) player.position.y = clamp(player.position.y, player_y_min, player_y_max) - # REMOVED: update_speed_bar() and check_speed_warning() from here! - - # Perform player movement player.move_and_slide() From f7d0a6c8ec562574aabbef1f7666fca0793f2c5e Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sat, 11 Apr 2026 22:03:44 -0700 Subject: [PATCH 08/36] Update game_settings_resource.gd --- scripts/game_settings_resource.gd | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index ec851ddd1..2ebb3c53d 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -91,7 +91,6 @@ signal fuel_depleted get: return _low_yellow_fraction - @export_group("Fuel System") ## Maximum fuel capacity. From 56a18a8dd5011abd2a9a312aedd168920f84c5fe Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Sat, 11 Apr 2026 22:04:04 -0700 Subject: [PATCH 09/36] Update main_scene.gd --- scripts/main_scene.gd | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/main_scene.gd b/scripts/main_scene.gd index e9157f2f3..10275848a 100644 --- a/scripts/main_scene.gd +++ b/scripts/main_scene.gd @@ -29,7 +29,7 @@ func _ready() -> void: player.position = Vector2(viewport_size.x / 2, viewport_size.y / 1.2) stats_panel.visible = true Globals.log_message("Initializing main scene...", Globals.LogLevel.DEBUG) - + # ========================================================= # THIS IS THE MISSING LINK THAT WAKES UP YOUR HUD! # It passes the Player directly to the HUD script so the bars work. @@ -37,7 +37,9 @@ func _ready() -> void: if stats_panel.has_method("setup_hud"): stats_panel.setup_hud(player) else: - push_error("HUD Script is missing! Make sure 'hud.gd' is attached to the 'PlayerStatsPanel' node.") + push_error( + "HUD Script is missing! Make sure 'hud.gd' is attached to the 'PlayerStatsPanel' node." + ) # Setup ground layer with tiling setup_parallax_layer($Background/Sand/Sprite2D, viewport_size, 2.0) # Sand layer From 3237fd16282780e9729f7ecd5082cead5804e012 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 19:37:43 -0700 Subject: [PATCH 10/36] Refactor tests to use Globals settings & HUD Update unit tests to match refactored game resources and HUD wiring: use Globals.settings.min_speed/max_speed instead of local MIN/MAX constants, emit speed_changed with resource values, and change _player typing to Variant for dynamic access. Wire mock PlayerStatsPanel to the Player and assign hud.gd script to the mock panel so UI elements (fuel/speed bars) are retrieved from the HUD. Fix AnimatedSpriteFrames setup for Godot 4 by removing add_animation("default") and adding a dummy frame to avoid crashes. Also ensure input actions are explicitly released after tests and clean up minor mocked node/commented code redundancies. --- test/gut/test_fuel_additional_edge_cases.gd | 8 ++--- test/gut/test_player_fuel_logic.gd | 28 ++++++++++------ test/gut/test_player_movement_signals.gd | 36 ++++++++++++--------- 3 files changed, 44 insertions(+), 28 deletions(-) diff --git a/test/gut/test_fuel_additional_edge_cases.gd b/test/gut/test_fuel_additional_edge_cases.gd index 16030b5a5..29b040efe 100644 --- a/test/gut/test_fuel_additional_edge_cases.gd +++ b/test/gut/test_fuel_additional_edge_cases.gd @@ -37,16 +37,16 @@ func test_fuel_consumption_with_scaling() -> void: Globals.settings.current_fuel = 100.0 Globals.settings.difficulty = 1.0 - # NEW: Simulate consumption at normal (minimum) speed. - player_root.speed["speed"] = player_root.MIN_SPEED + # NEW: Simulate consumption at normal (minimum) speed using the updated Resource. + player_root.speed["speed"] = Globals.settings.min_speed player_root._on_fuel_timer_timeout() var base_depletion: float = 100.0 - Globals.settings.current_fuel # NEW: Reset the fuel tank for the second measurement. Globals.settings.current_fuel = 100.0 - # NEW: Simulate consumption at an increased-consumption state (maximum speed). - player_root.speed["speed"] = player_root.MAX_SPEED + # NEW: Simulate consumption at an increased-consumption state (maximum speed) using the updated Resource. + player_root.speed["speed"] = Globals.settings.max_speed player_root._on_fuel_timer_timeout() var high_speed_depletion: float = 100.0 - Globals.settings.current_fuel diff --git a/test/gut/test_player_fuel_logic.gd b/test/gut/test_player_fuel_logic.gd index d45ccd211..e7b94611c 100644 --- a/test/gut/test_player_fuel_logic.gd +++ b/test/gut/test_player_fuel_logic.gd @@ -31,17 +31,26 @@ func before_each() -> void: ## :rtype: void func after_each() -> void: Globals.settings = _original_settings + + # NEW: Ensure ALL mocked actions are explicitly released to prevent test leakage + for action: String in ["speed_up", "speed_down", "move_left", "move_right"]: + if Input.is_action_pressed(action): + Input.action_release(action) + for action: String in _added_actions: InputMap.erase_action(action) _added_actions.clear() - Input.action_release("move_left") ## test_ui_updates_automatically_on_resource_change | Observer Pattern ## :rtype: void func test_ui_updates_automatically_on_resource_change() -> void: gut.p("Testing: Player UI responds seamlessly to external fuel updates.") - var fuel_bar: ProgressBar = _player.fuel_bar + # NEW: Get the extracted HUD panel and explicitly wire it to the Player + var hud_panel: Variant = _mock_root.get_node("PlayerStatsPanel") + hud_panel.setup_hud(_player) + + var fuel_bar: ProgressBar = hud_panel.fuel_bar Globals.settings.max_fuel = 200.0 # Because of the resource setter, current_fuel modification fires 'setting_changed' automatically @@ -136,6 +145,12 @@ func _build_mock_player_scene() -> Node: stats.add_child(fuel) stats.add_child(speed) panel.add_child(stats) + + # NEW: Assign the extracted hud.gd script directly to the mock panel + var hud_script := load("res://scripts/hud.gd") + if hud_script: + panel.set_script(hud_script) + root.add_child(panel) var PlayerScript := load(PLAYER_SCRIPT_PATH) @@ -152,13 +167,10 @@ func _build_mock_player_scene() -> Node: sfx.name = "AudioStreamPlayer2D" var anim: AnimatedSprite2D = AnimatedSprite2D.new() anim.name = "AnimatedSprite2D" - # var frames: SpriteFrames = SpriteFrames.new() - # frames.add_animation("default") - # anim.sprite_frames = frames + # NEW: Godot 4 automatically adds a "default" animation when you instantiate SpriteFrames. + # Removed the crash-causing frames.add_animation("default") call. var frames: SpriteFrames = SpriteFrames.new() - frames.add_animation("default") - # Add a dummy frame so play() actually engages and is_playing() returns true var dummy_tex: PlaceholderTexture2D = PlaceholderTexture2D.new() frames.add_frame("default", dummy_tex) anim.sprite_frames = frames @@ -171,8 +183,6 @@ func _build_mock_player_scene() -> Node: sprite.name = "Sprite2D" var coll: CollisionPolygon2D = CollisionPolygon2D.new() coll.name = "CollisionPolygon2D" - #var weapon: Node2D = Node2D.new() - #weapon.name = "Weapon" var weapon: Node2D = Node2D.new() weapon.name = "Weapon" diff --git a/test/gut/test_player_movement_signals.gd b/test/gut/test_player_movement_signals.gd index f96244f19..99381a9ca 100644 --- a/test/gut/test_player_movement_signals.gd +++ b/test/gut/test_player_movement_signals.gd @@ -2,14 +2,13 @@ ## SPDX-License-Identifier: GPL-3.0-or-later ## test_player_movement_signals.gd ## GUT unit tests for Player movement and the decoupled speed_changed signal. - extends "res://addons/gut/test.gd" # UPDATE THIS PATH if player.gd is located in a different folder const PLAYER_SCRIPT_PATH: String = "res://scripts/player.gd" var _mock_root: Node -var _player: Node2D +var _player: Variant # CHANGED: Use Variant to allow dynamic property access to player.gd variables var _original_settings: GameSettingsResource var _added_actions: Array[String] = [] @@ -93,13 +92,17 @@ func test_flameout_resets_speed_and_emits_signal() -> void: func test_ui_updates_on_speed_signal() -> void: gut.p("Testing: Target UI updates instantly when speed_changed fires.") - _player.speed_bar.value = 0.0 + # NEW: Get the extracted HUD panel and explicitly wire it to the Player + var hud_panel: Variant = _mock_root.get_node("PlayerStatsPanel") + hud_panel.setup_hud(_player) + + hud_panel.speed_bar.value = 0.0 _player.speed["speed"] = 500.0 # Force local sync - # Fire the signal explicitly as the engine would - _player.speed_changed.emit(500.0, _player.speed["max"]) + # NEW: Fire the signal explicitly using the Global Resource setting + _player.speed_changed.emit(500.0, Globals.settings.max_speed) - assert_eq(_player.speed_bar.value, 500.0, "Progress bar must sync tightly with speed_changed.") + assert_eq(hud_panel.speed_bar.value, 500.0, "Progress bar must sync tightly with speed_changed.") ## test_speed_clamps_to_max_and_min | Constraints ## :rtype: void @@ -107,8 +110,10 @@ func test_speed_clamps_to_max_and_min() -> void: gut.p("Testing: Speed values obey MIN and MAX constraints.") Globals.settings.current_fuel = 100.0 - var max_cap: float = _player.speed["max"] - var min_cap: float = _player.speed["min"] + + # NEW: Pull speed constraints directly from the Resource + var max_cap: float = Globals.settings.max_speed + var min_cap: float = Globals.settings.min_speed # --- 1. Test MAX Clamp --- _player.speed["speed"] = max_cap - 5.0 @@ -173,6 +178,12 @@ func _build_mock_player_scene() -> Node: stats.add_child(fuel) stats.add_child(speed) panel.add_child(stats) + + # NEW: Assign the extracted hud.gd script directly to the mock panel + var hud_script := load("res://scripts/hud.gd") + if hud_script: + panel.set_script(hud_script) + root.add_child(panel) # --- Core Player --- @@ -190,13 +201,10 @@ func _build_mock_player_scene() -> Node: sfx.name = "AudioStreamPlayer2D" var anim: AnimatedSprite2D = AnimatedSprite2D.new() anim.name = "AnimatedSprite2D" - # var frames: SpriteFrames = SpriteFrames.new() - # frames.add_animation("default") - # anim.sprite_frames = frames + # NEW: Godot 4 automatically adds a "default" animation when you instantiate SpriteFrames. + # Removed the crash-causing frames.add_animation("default") call. var frames: SpriteFrames = SpriteFrames.new() - frames.add_animation("default") - # Add a dummy frame so play() actually engages and is_playing() returns true var dummy_tex: PlaceholderTexture2D = PlaceholderTexture2D.new() frames.add_frame("default", dummy_tex) anim.sprite_frames = frames @@ -209,8 +217,6 @@ func _build_mock_player_scene() -> Node: sprite.name = "Sprite2D" var coll: CollisionPolygon2D = CollisionPolygon2D.new() coll.name = "CollisionPolygon2D" - # var weapon: Node2D = Node2D.new() - # weapon.name = "Weapon" var weapon: Node2D = Node2D.new() weapon.name = "Weapon" From ce6864df8eda3c4a1b080e0ed8056422480e1cc4 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 19:47:52 -0700 Subject: [PATCH 11/36] Update tests for Globals/HUD refactor Adjust unit and integration tests to match recent refactors that moved player constants/logic into Globals.settings and separated the HUD into PlayerStatsPanel. Replaces usages of player.MAX_SPEED, MIN_SPEED, base_fuel_drain, and local fuel bar references with Globals.settings.max_speed/min_speed/base_consumption_rate/current_fuel and hud.fuel_bar / hud.speed_bar. Also adapts blinking/state APIs and label accessors to the new HUD structure and updates assertions to derive values from dynamic max_fuel/max_speed rather than hardcoded 100/Player constants. --- test/gdunit4/test_difficulty.gd | 12 +- test/gdunit4/test_difficulty_integration.gd | 4 +- test/gdunit4/test_player.gd | 193 +++++++++----------- 3 files changed, 100 insertions(+), 109 deletions(-) diff --git a/test/gdunit4/test_difficulty.gd b/test/gdunit4/test_difficulty.gd index 3cda37348..1e6dc7201 100644 --- a/test/gdunit4/test_difficulty.gd +++ b/test/gdunit4/test_difficulty.gd @@ -47,7 +47,9 @@ func test_fuel_depletion_with_difficulty() -> void: # NEW: Reset the global fuel resource instead of the local dictionary using the dynamic baseline Globals.settings.current_fuel = start_fuel Globals.settings.difficulty = 1.0 - var normalized_speed: float = player_inst.speed["speed"] / player_inst.MAX_SPEED + + # NEW: Use Globals.settings.max_speed instead of the removed player_inst.MAX_SPEED + var normalized_speed: float = player_inst.speed["speed"] / Globals.settings.max_speed # OLD: var dep_1: float = player_inst.base_fuel_drain * normalized_speed * Globals.settings.difficulty # NEW: Use the global base_consumption_rate instead of the removed local base_fuel_drain @@ -63,7 +65,9 @@ func test_fuel_depletion_with_difficulty() -> void: # NEW: Reset the global fuel resource for the second test Globals.settings.current_fuel = start_fuel Globals.settings.difficulty = 2.0 - normalized_speed = player_inst.speed["speed"] / player_inst.MAX_SPEED # Re-derive + + # NEW: Use Globals.settings.max_speed instead of the removed player_inst.MAX_SPEED + normalized_speed = player_inst.speed["speed"] / Globals.settings.max_speed # OLD: var dep_2: float = player_inst.base_fuel_drain * normalized_speed * Globals.settings.difficulty # NEW: Use the global base_consumption_rate instead of the removed local base_fuel_drain @@ -79,7 +83,9 @@ func test_fuel_depletion_with_difficulty() -> void: # NEW: Reset the global fuel resource for the third test Globals.settings.current_fuel = start_fuel Globals.settings.difficulty = 0.5 - normalized_speed = player_inst.speed["speed"] / player_inst.MAX_SPEED + + # NEW: Use Globals.settings.max_speed instead of the removed player_inst.MAX_SPEED + normalized_speed = player_inst.speed["speed"] / Globals.settings.max_speed # OLD: var dep_05: float = player_inst.base_fuel_drain * normalized_speed * Globals.settings.difficulty # NEW: Use the global base_consumption_rate instead of the removed local base_fuel_drain diff --git a/test/gdunit4/test_difficulty_integration.gd b/test/gdunit4/test_difficulty_integration.gd index 2e276de76..603cd01b8 100644 --- a/test/gdunit4/test_difficulty_integration.gd +++ b/test/gdunit4/test_difficulty_integration.gd @@ -50,7 +50,9 @@ func test_difficulty_scales_fuel_and_weapon() -> void: # OLD: player.fuel["fuel"] = 100.0 # NEW: Set the fuel level using the dynamic baseline instead of hardcoded 100.0 Globals.settings.current_fuel = start_fuel - var normalized_speed: float = player.speed["speed"] / player.MAX_SPEED + + # NEW: Calculate normalized speed using the global max_speed, as MAX_SPEED was removed from player.gd + var normalized_speed: float = player.speed["speed"] / Globals.settings.max_speed # OLD: var expected_depletion: float = player.base_fuel_drain * normalized_speed * Globals.settings.difficulty # NEW: Reference base_consumption_rate from the global resource since it was removed from the player script diff --git a/test/gdunit4/test_player.gd b/test/gdunit4/test_player.gd index f006a2b8c..d601facda 100644 --- a/test/gdunit4/test_player.gd +++ b/test/gdunit4/test_player.gd @@ -30,8 +30,8 @@ func test_shared_depletion_helper() -> void: var player_root: Node = main_scene.get_node("Player") Globals.settings.difficulty = 2.0 - # NEW: Read the base consumption rate from the global settings since it was removed from player.gd. - var expected: float = Globals.settings.base_consumption_rate * (player_root.speed["speed"] / player_root.MAX_SPEED) * Globals.settings.difficulty + # NEW: Use global max_speed + var expected: float = Globals.settings.base_consumption_rate * (player_root.speed["speed"] / Globals.settings.max_speed) * Globals.settings.difficulty assert_float(TestHelpers.calculate_expected_depletion(player_root, Globals.settings.difficulty)).is_equal_approx(expected, 0.001) @@ -47,10 +47,6 @@ func test_player_present() -> void: assert_bool(player_root.is_inside_tree()).is_true() -# Test: Screen boundary clamping -# test_player.gd - Fixed clamping test: use float asserts with approx + epsilon -# test_player.gd - Final fix for test_clamping: use approx comparison for floats -# test_player.gd - Fixed is_equal_approx() error in test_clamping func test_clamping() -> void: var main_scene: Node = auto_free(load("res://scenes/main_scene.tscn").instantiate()) add_child(main_scene) @@ -78,21 +74,18 @@ func test_fuel_colors() -> void: add_child(main_scene) await await_idle_frame() - var player_root : Node = main_scene.get_node("Player") - var fuel_bar : ProgressBar = player_root.fuel["bar"] + var hud: Panel = main_scene.get_node("PlayerStatsPanel") # High fuel → Green - # NEW: Calculate 95% relative to the dynamic max_fuel Globals.settings.current_fuel = Globals.settings.max_fuel * 0.95 - player_root.update_fuel_bar() - var style_1 : StyleBoxFlat = fuel_bar.get_theme_stylebox("fill").duplicate() + hud.update_fuel_bar() + var style_1 : StyleBoxFlat = hud.fuel_bar.get_theme_stylebox("fill").duplicate() assert_that(style_1.bg_color).is_equal(Color.GREEN) - # Low fuel → Dark Red (consistent with gradual depletion) - # NEW: Calculate 10% relative to the dynamic max_fuel + # Low fuel → Dark Red Globals.settings.current_fuel = Globals.settings.max_fuel * 0.10 - player_root.update_fuel_bar() - var style_2 : StyleBoxFlat = fuel_bar.get_theme_stylebox("fill").duplicate() + hud.update_fuel_bar() + var style_2 : StyleBoxFlat = hud.fuel_bar.get_theme_stylebox("fill").duplicate() assert_that(style_2.bg_color).is_equal(Color(0.5, 0, 0, 1.0)) @@ -102,22 +95,18 @@ func test_fuel_colors_fixed() -> void: add_child(main_scene) await await_idle_frame() - var player_root : Node = main_scene.get_node("Player") - var fuel_bar : ProgressBar = player_root.fuel["bar"] + var hud: Panel = main_scene.get_node("PlayerStatsPanel") # Still full → Green - # NEW: Calculate 95% relative to max_fuel Globals.settings.current_fuel = Globals.settings.max_fuel * 0.95 - player_root.update_fuel_bar() - var style : StyleBoxFlat = player_root.fuel["bar"].get_theme_stylebox("fill").duplicate() + hud.update_fuel_bar() + var style : StyleBoxFlat = hud.fuel_bar.get_theme_stylebox("fill").duplicate() assert_that(style.bg_color).is_equal(Color.GREEN) # Between 90% and 50% → Lerp green → yellow - # NEW: Calculate 70% relative to max_fuel Globals.settings.current_fuel = Globals.settings.max_fuel * 0.70 - player_root.update_fuel_bar() - style = fuel_bar.get_theme_stylebox("fill").duplicate() - # Update the math here to rely on percentages rather than absolute 100-tank units + hud.update_fuel_bar() + style = hud.fuel_bar.get_theme_stylebox("fill").duplicate() var expected := Color.GREEN.lerp(Color.YELLOW, (0.90 - 0.70) / (0.90 - 0.50)) assert_bool(style.bg_color.is_equal_approx(expected)).is_true() @@ -128,24 +117,24 @@ func test_fuel_gradual_depletion_colors() -> void: add_child(main_scene) await await_idle_frame() - var player_root: Node = main_scene.get_node("Player") + var hud: Panel = main_scene.get_node("PlayerStatsPanel") # Start at 30% (should be red) Globals.settings.current_fuel = Globals.settings.max_fuel * 0.30 - player_root.update_fuel_bar() - var style: StyleBoxFlat = player_root.fuel["bar"].get_theme_stylebox("fill").duplicate() + hud.update_fuel_bar() + var style: StyleBoxFlat = hud.fuel_bar.get_theme_stylebox("fill").duplicate() assert_that(style.bg_color).is_equal(Color.RED) # Drop to 15% (dark red) Globals.settings.current_fuel = Globals.settings.max_fuel * 0.15 - player_root.update_fuel_bar() - style = player_root.fuel["bar"].get_theme_stylebox("fill").duplicate() + hud.update_fuel_bar() + style = hud.fuel_bar.get_theme_stylebox("fill").duplicate() assert_that(style.bg_color).is_equal(Color(0.5, 0, 0)) # Drop to 10% (still dark red) Globals.settings.current_fuel = Globals.settings.max_fuel * 0.10 - player_root.update_fuel_bar() - style = player_root.fuel["bar"].get_theme_stylebox("fill").duplicate() + hud.update_fuel_bar() + style = hud.fuel_bar.get_theme_stylebox("fill").duplicate() assert_that(style.bg_color).is_equal(Color(0.5, 0, 0)) @@ -178,40 +167,37 @@ func test_independent_blinking() -> void: add_child(main_scene) await await_idle_frame() - var player_root: Node = main_scene.get_node("Player") + var hud: Panel = main_scene.get_node("PlayerStatsPanel") # Force low fuel and high speed to trigger both - # NEW: Calculate 10% relative to max_fuel Globals.settings.current_fuel = Globals.settings.max_fuel * 0.10 - player_root.speed["speed"] = player_root.speed["max"] * 0.95 - player_root.check_fuel_warning() - player_root.check_speed_warning() + hud._current_speed = Globals.settings.max_speed * 0.95 + hud.check_fuel_warning() + hud.check_speed_warning() # Assert both are now at warning color after initial blink start - assert_that(player_root.get_label_text_color(player_root.fuel["label"])).is_equal(player_root.fuel["warning_color"]) - assert_that(player_root.get_label_text_color(player_root.speed["label"])).is_equal(player_root.speed["warning_color"]) + assert_that(hud.get_label_text_color(hud.fuel_label)).is_equal(hud._fuel_state["warning_color"]) + assert_that(hud.get_label_text_color(hud.speed_label)).is_equal(hud._speed_state["warning_color"]) # Toggle one, other unchanged - player_root._toggle_label(player_root.fuel) - assert_that(player_root.get_label_text_color(player_root.fuel["label"])).is_equal(player_root.fuel["base_color"]) - assert_that(player_root.get_label_text_color(player_root.speed["label"])).is_equal(player_root.speed["warning_color"]) + hud._toggle_label(hud._fuel_state) + assert_that(hud.get_label_text_color(hud.fuel_label)).is_equal(hud._fuel_state["base_color"]) + assert_that(hud.get_label_text_color(hud.speed_label)).is_equal(hud._speed_state["warning_color"]) -# Test: get_label_text_color returns override if set, else theme default # Test: get_label_text_color_override returns override if set, else theme default func test_get_label_text_color_override() -> void: var main_scene: Node = auto_free(load("res://scenes/main_scene.tscn").instantiate()) add_child(main_scene) await await_idle_frame() - var player_root: Node = main_scene.get_node("Player") - var fuel_label: Label = player_root.fuel["label"] + var hud: Panel = main_scene.get_node("PlayerStatsPanel") + var fuel_label: Label = hud.fuel_label # Clear any editor-set override to test from clean theme default fuel_label.remove_theme_color_override("font_color") - # Assume initial is theme default (not black transparent) - var initial_color: Color = player_root.get_label_text_color(fuel_label) + var initial_color: Color = hud.get_label_text_color(fuel_label) assert_bool(initial_color.is_equal_approx(Color(0, 0, 0, 0))).is_false() # Set override @@ -219,13 +205,13 @@ func test_get_label_text_color_override() -> void: fuel_label.add_theme_color_override("font_color", override_color) # Assert returns override - assert_that(player_root.get_label_text_color(fuel_label)).is_equal(override_color) + assert_that(hud.get_label_text_color(fuel_label)).is_equal(override_color) # Remove override fuel_label.remove_theme_color_override("font_color") # Assert back to initial - assert_that(player_root.get_label_text_color(fuel_label)).is_equal(initial_color) + assert_that(hud.get_label_text_color(fuel_label)).is_equal(initial_color) # Test: rotor_start/stop logs warning on missing AnimatedSprite2D @@ -258,42 +244,42 @@ func test_speed_blinking_thresholds() -> void: add_child(main_scene) await await_idle_frame() - var player_root: Node = main_scene.get_node("Player") + var hud: Panel = main_scene.get_node("PlayerStatsPanel") # Normal speed: no blink - player_root.speed["speed"] = (player_root.speed["min"] + player_root.HIGH_YELLOW_THRESHOLD) / 2.0 - player_root.check_speed_warning() - assert_bool(player_root.speed["blinking"]).is_false() + hud._current_speed = (Globals.settings.min_speed + hud.HIGH_YELLOW_THRESHOLD) / 2.0 + hud.check_speed_warning() + assert_bool(hud._speed_state["blinking"]).is_false() # Low yellow: start blink - player_root.speed["speed"] = player_root.LOW_YELLOW_THRESHOLD - 10.0 - player_root.check_speed_warning() - assert_bool(player_root.speed["blinking"]).is_true() + hud._current_speed = hud.LOW_YELLOW_THRESHOLD - 10.0 + hud.check_speed_warning() + assert_bool(hud._speed_state["blinking"]).is_true() # Low red: remains blinking - player_root.speed["speed"] = player_root.speed["min"] - 1.0 - player_root.check_speed_warning() - assert_bool(player_root.speed["blinking"]).is_true() + hud._current_speed = Globals.settings.min_speed - 1.0 + hud.check_speed_warning() + assert_bool(hud._speed_state["blinking"]).is_true() # Back to normal: stop blink - player_root.speed["speed"] = (player_root.LOW_YELLOW_THRESHOLD + player_root.HIGH_YELLOW_THRESHOLD) / 2.0 - player_root.check_speed_warning() - assert_bool(player_root.speed["blinking"]).is_false() + hud._current_speed = (hud.LOW_YELLOW_THRESHOLD + hud.HIGH_YELLOW_THRESHOLD) / 2.0 + hud.check_speed_warning() + assert_bool(hud._speed_state["blinking"]).is_false() # High yellow: start blink - player_root.speed["speed"] = player_root.HIGH_YELLOW_THRESHOLD + 10.0 - player_root.check_speed_warning() - assert_bool(player_root.speed["blinking"]).is_true() + hud._current_speed = hud.HIGH_YELLOW_THRESHOLD + 10.0 + hud.check_speed_warning() + assert_bool(hud._speed_state["blinking"]).is_true() # High red: remains blinking - player_root.speed["speed"] = player_root.HIGH_RED_THRESHOLD + 10.0 - player_root.check_speed_warning() - assert_bool(player_root.speed["blinking"]).is_true() + hud._current_speed = hud.HIGH_RED_THRESHOLD + 10.0 + hud.check_speed_warning() + assert_bool(hud._speed_state["blinking"]).is_true() # Back to normal: stop blink - player_root.speed["speed"] = (player_root.LOW_YELLOW_THRESHOLD + player_root.HIGH_YELLOW_THRESHOLD) / 2.0 - player_root.check_speed_warning() - assert_bool(player_root.speed["blinking"]).is_false() + hud._current_speed = (hud.LOW_YELLOW_THRESHOLD + hud.HIGH_YELLOW_THRESHOLD) / 2.0 + hud.check_speed_warning() + assert_bool(hud._speed_state["blinking"]).is_false() # Test: Player movement with input actions (updated for lateral-only refactor) @@ -342,51 +328,51 @@ func test_depletion_helper_difficulties() -> void: assert_float(dep_05).is_equal_approx(0.175315, 0.001) # 1 * (250/713) * 0.5 -# Test: Speed bar colors at various thresholds (fix Color.DARK_RED to custom) +# Test: Speed bar colors at various thresholds func test_speed_colors() -> void: var main_scene: Node = auto_free(load("res://scenes/main_scene.tscn").instantiate()) add_child(main_scene) await await_idle_frame() - var player_root: Node = main_scene.get_node("Player") - var speed_bar: ProgressBar = player_root.speed["bar"] + var hud: Panel = main_scene.get_node("PlayerStatsPanel") + var speed_bar: ProgressBar = hud.speed_bar - # Normal (green) - derive mid-safe speed from min/max - player_root.speed["speed"] = (player_root.speed["min"] + player_root.speed["max"]) / 2.0 - player_root.update_speed_bar() + # Normal (green) - derive mid-safe speed + hud._current_speed = (Globals.settings.min_speed + Globals.settings.max_speed) / 2.0 + hud.update_speed_bar() var style: StyleBoxFlat = speed_bar.get_theme_stylebox("fill").duplicate() assert_that(style.bg_color).is_equal(Color.GREEN) - # Approaching high (yellow lerp) - derive thresholds from fractions - var high_yellow: float = player_root.MAX_SPEED * player_root.HIGH_YELLOW_FRACTION - var high_red: float = player_root.MAX_SPEED * player_root.HIGH_RED_FRACTION - var mid_high_yellow: float = high_yellow + (high_red - high_yellow) / 2.0 # Derive mid-point - player_root.speed["speed"] = mid_high_yellow - player_root.update_speed_bar() + # Approaching high (yellow lerp) + var high_yellow: float = Globals.settings.max_speed * hud.HIGH_YELLOW_FRACTION + var high_red: float = Globals.settings.max_speed * hud.HIGH_RED_FRACTION + var mid_high_yellow: float = high_yellow + (high_red - high_yellow) / 2.0 + hud._current_speed = mid_high_yellow + hud.update_speed_bar() style = speed_bar.get_theme_stylebox("fill").duplicate() assert_bool(style.bg_color.is_equal_approx(Color.GREEN.lerp(Color.YELLOW, 0.5))).is_true() - # Overspeed (red lerp) - derive from max - var mid_high_red: float = high_red + (player_root.speed["max"] - high_red) / 2.0 # Derive mid-point - player_root.speed["speed"] = mid_high_red - player_root.update_speed_bar() + # Overspeed (red lerp) + var mid_high_red: float = high_red + (Globals.settings.max_speed - high_red) / 2.0 + hud._current_speed = mid_high_red + hud.update_speed_bar() style = speed_bar.get_theme_stylebox("fill").duplicate() - assert_bool(style.bg_color.is_equal_approx(Color.YELLOW.lerp(player_root.DARK_RED, 0.5))).is_true() - - # Approaching low (yellow lerp) - derive low thresholds from fractions - var low_yellow: float = player_root.MIN_SPEED + (player_root.MAX_SPEED - player_root.MIN_SPEED) * player_root.LOW_YELLOW_FRACTION - var low_red: float = player_root.MIN_SPEED - var mid_low_yellow: float = low_yellow - (low_yellow - low_red) / 2.0 # Derive mid-point - player_root.speed["speed"] = mid_low_yellow - player_root.update_speed_bar() + assert_bool(style.bg_color.is_equal_approx(Color.YELLOW.lerp(hud.DARK_RED, 0.5))).is_true() + + # Approaching low (yellow lerp) + var low_yellow: float = Globals.settings.min_speed + (Globals.settings.max_speed - Globals.settings.min_speed) * hud.LOW_YELLOW_FRACTION + var low_red: float = Globals.settings.min_speed + var mid_low_yellow: float = low_yellow - (low_yellow - low_red) / 2.0 + hud._current_speed = mid_low_yellow + hud.update_speed_bar() style = speed_bar.get_theme_stylebox("fill").duplicate() assert_bool(style.bg_color.is_equal_approx(Color.GREEN.lerp(Color.YELLOW, 0.5))).is_true() # Low red at min - player_root.speed["speed"] = player_root.MIN_SPEED - player_root.update_speed_bar() + hud._current_speed = Globals.settings.min_speed + hud.update_speed_bar() style = speed_bar.get_theme_stylebox("fill").duplicate() - assert_that(style.bg_color).is_equal(player_root.DARK_RED) + assert_that(style.bg_color).is_equal(hud.DARK_RED) # Test: Fuel initialization and depletion logic @@ -396,23 +382,20 @@ func test_fuel_depletion() -> void: await await_idle_frame() var player_root: Node = main_scene.get_node("Player") + var hud: Panel = main_scene.get_node("PlayerStatsPanel") # Initial state assert_float(Globals.settings.current_fuel).is_equal(Globals.settings.max_fuel) - # NEW: Bar value should assert against max_fuel directly instead of assuming 100.0 - assert_float(player_root.fuel["bar"].value).is_equal(Globals.settings.max_fuel) - - # Simulate one timer tick (derive expected from constants) - var normalized_speed: float = player_root.speed["speed"] / player_root.MAX_SPEED + assert_float(hud.fuel_bar.value).is_equal(Globals.settings.max_fuel) - # Use the global base_consumption_rate since the local drain variable was removed. + # Simulate one timer tick + var normalized_speed: float = player_root.speed["speed"] / Globals.settings.max_speed var expected_depletion: float = Globals.settings.base_consumption_rate * normalized_speed * Globals.settings.difficulty player_root._on_fuel_timer_timeout() assert_float(Globals.settings.current_fuel).is_equal_approx(Globals.settings.max_fuel - expected_depletion, 0.1) - # NEW: Bar value subtracts depletion from dynamic max_fuel rather than 100.0 - assert_float(player_root.fuel["bar"].value).is_equal_approx(Globals.settings.max_fuel - expected_depletion, 0.1) + assert_float(hud.fuel_bar.value).is_equal_approx(Globals.settings.max_fuel - expected_depletion, 0.1) # Force zero fuel Globals.settings.current_fuel = 0.0 From 7b8127cb9a2bdafc09d08e270fcfff90da4c39d4 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 19:53:35 -0700 Subject: [PATCH 12/36] Update test_helpers.gd --- test/gdunit4/test_helpers.gd | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/test/gdunit4/test_helpers.gd b/test/gdunit4/test_helpers.gd index b20699578..67120c37d 100644 --- a/test/gdunit4/test_helpers.gd +++ b/test/gdunit4/test_helpers.gd @@ -3,15 +3,13 @@ ## Shared test helpers for SkyLockAssault unit tests. ## Contains utility functions for calculations. -class_name TestHelpers extends RefCounted -## Calculates expected fuel depletion based on current formula. -## @param player: The player node instance. -## @param difficulty: The difficulty level to use. -## @return: The expected depletion amount. + +## Calculates the expected fuel depletion based on the global GameSettingsResource. static func calculate_expected_depletion(player_root: Node, difficulty: float) -> float: - var normalized_speed: float = player_root.speed["speed"] / player_root.MAX_SPEED - # OLD: return player_root.base_fuel_drain * normalized_speed * difficulty - # NEW: Fetch the consumption rate from the new global resource since the local drain variable was removed + # NEW: Use Globals.settings.max_speed instead of player_root.MAX_SPEED + var normalized_speed: float = player_root.speed["speed"] / Globals.settings.max_speed + + # NEW: Use Globals.settings.base_consumption_rate instead of player_root.base_fuel_drain return Globals.settings.base_consumption_rate * normalized_speed * difficulty From 54599614dd45cce2135f8e8d646384add26886f6 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:14:18 -0700 Subject: [PATCH 13/36] Update player.gd The speed change + signal emission logic is duplicated in both _physics_process and _on_player_out_of_fuel; consider centralizing this into a small helper (e.g., set_speed(new_speed: float) or _update_speed(new_speed: float)) that handles clamping and emitting speed_changed when the value actually changes. --- scripts/player.gd | 66 +++++++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/scripts/player.gd b/scripts/player.gd index d50d9a24a..a8a2fe699 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -130,22 +130,45 @@ func _on_setting_changed(setting_name: String, new_value: Variant) -> void: ) -## Signal handler for engine failure triggered by the global fuel_depleted signal. -## Stops the plane, halts rotors, and broadcasts the flameout state. -## @return: void -func _on_player_out_of_fuel() -> void: - Globals.log_message("Player is out of fuel! Engine flameout.", Globals.LogLevel.WARNING) +## NEW: Centralized helper to clamp speed and emit state changes. +## Resolves PR duplication feedback. +func _set_speed(target_speed: float) -> void: + if not is_instance_valid(_settings): + return var old_speed: float = speed["speed"] - speed["speed"] = 0.0 + # Clamp current_speed based on fuel state + if _settings.current_fuel == 0: + speed["speed"] = clamp(target_speed, 0.0, _settings.max_speed) + else: + speed["speed"] = clamp(target_speed, _settings.min_speed, _settings.max_speed) + + # Emit signals if speed actually changed if old_speed != speed["speed"]: speed_changed.emit(speed["speed"], _settings.max_speed) + + # Check for maximum speed limit + if speed["speed"] >= _settings.max_speed: + speed_maxed.emit() + + # Check for low speed warning var low_yellow_thresh: float = ( _settings.min_speed + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction ) - speed_low.emit(low_yellow_thresh) + if speed["speed"] <= low_yellow_thresh: + speed_low.emit(low_yellow_thresh) + + +## Signal handler for engine failure triggered by the global fuel_depleted signal. +## Stops the plane, halts rotors, and broadcasts the flameout state. +## @return: void +func _on_player_out_of_fuel() -> void: + Globals.log_message("Player is out of fuel! Engine flameout.", Globals.LogLevel.WARNING) + + # Use the new centralized helper + _set_speed(0.0) rotor_stop(rotor_right, rotor_right_sfx) rotor_stop(rotor_left, rotor_left_sfx) @@ -215,36 +238,17 @@ func _physics_process(_delta: float) -> void: if not is_instance_valid(_settings): return - var old_speed: float = speed["speed"] + var target_speed: float = speed["speed"] # Speed changes allowed only if fuel > 0 if Input.is_action_pressed("speed_up") and _settings.current_fuel > 0: - speed["speed"] += _settings.acceleration * _delta + target_speed += _settings.acceleration * _delta if Input.is_action_pressed("speed_down") and _settings.current_fuel > 0: - speed["speed"] -= _settings.deceleration * _delta - - # Clamp current_speed between MIN_SPEED and MAX_SPEED - if _settings.current_fuel == 0: - speed["speed"] = clamp(speed["speed"], 0.0, _settings.max_speed) - else: - speed["speed"] = clamp(speed["speed"], _settings.min_speed, _settings.max_speed) - - # Emit signals if speed actually changed - if old_speed != speed["speed"]: - speed_changed.emit(speed["speed"], _settings.max_speed) + target_speed -= _settings.deceleration * _delta - # Check for maximum speed limit - if speed["speed"] >= _settings.max_speed: - speed_maxed.emit() - - # Check for low speed warning - var low_yellow_thresh: float = ( - _settings.min_speed - + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction - ) - if speed["speed"] <= low_yellow_thresh: - speed_low.emit(low_yellow_thresh) + # Let the helper handle clamping and signal emission + _set_speed(target_speed) # Left/Right movement var lateral_input: float = Input.get_axis("move_left", "move_right") From 625942aab65de737ff76a33f0c35895c6d3eef3f Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:18:16 -0700 Subject: [PATCH 14/36] Update test_player_movement_signals.gd --- test/gut/test_player_movement_signals.gd | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/gut/test_player_movement_signals.gd b/test/gut/test_player_movement_signals.gd index 99381a9ca..0abf56aae 100644 --- a/test/gut/test_player_movement_signals.gd +++ b/test/gut/test_player_movement_signals.gd @@ -12,6 +12,7 @@ var _player: Variant # CHANGED: Use Variant to allow dynamic property access to var _original_settings: GameSettingsResource var _added_actions: Array[String] = [] + ## Per-test setup: Isolate memory and establish mock hierarchy. ## :rtype: void func before_each() -> void: @@ -29,6 +30,7 @@ func before_each() -> void: add_child_autoqfree(_mock_root) _player = _mock_root.get_node("Player") + ## Per-test cleanup. ## :rtype: void func after_each() -> void: @@ -41,6 +43,7 @@ func after_each() -> void: Input.action_release("speed_up") Input.action_release("speed_down") + ## test_physics_emits_speed_changed_on_acceleration | Signal Behavior ## :rtype: void func test_physics_emits_speed_changed_on_acceleration() -> void: @@ -57,6 +60,7 @@ func test_physics_emits_speed_changed_on_acceleration() -> void: assert_signal_emitted(_player, "speed_changed", "Signal must fire when speed up increases value.") assert_gt(float(_player.speed["speed"]), 100.0, "Speed logic should have increased current speed.") + ## test_physics_does_not_spam_speed_changed | Signal Efficiency ## :rtype: void func test_physics_does_not_spam_speed_changed() -> void: @@ -73,6 +77,7 @@ func test_physics_does_not_spam_speed_changed() -> void: assert_signal_emit_count(_player, "speed_changed", 0, "Signal must not emit when speed is unchanged.") + ## test_flameout_resets_speed_and_emits_signal | Edge Cases ## :rtype: void func test_flameout_resets_speed_and_emits_signal() -> void: @@ -81,12 +86,16 @@ func test_flameout_resets_speed_and_emits_signal() -> void: _player.speed["speed"] = 300.0 + # NEW FIX: Actually empty the mock fuel tank so _set_speed() allows a 0.0 value! + Globals.settings.current_fuel = 0.0 + # Manually trigger the flameout handler _player._on_player_out_of_fuel() assert_eq(float(_player.speed["speed"]), 0.0, "Speed must forcibly reset to 0.0 on zero fuel.") assert_signal_emitted(_player, "speed_changed", "Flameout must broadcast the speed halt to UI.") + ## test_ui_updates_on_speed_signal | UI Reactivity ## :rtype: void func test_ui_updates_on_speed_signal() -> void: @@ -104,6 +113,7 @@ func test_ui_updates_on_speed_signal() -> void: assert_eq(hud_panel.speed_bar.value, 500.0, "Progress bar must sync tightly with speed_changed.") + ## test_speed_clamps_to_max_and_min | Constraints ## :rtype: void func test_speed_clamps_to_max_and_min() -> void: From e765c1fae95982fa7978e1d41607e3b700ead4ed Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:23:45 -0700 Subject: [PATCH 15/36] Extract GUT mock builder to helper Add test/gut/gut_test_helper.gd (and uid) providing build_mock_player_scene() to construct a reusable mock Player HUD/scene for GUT tests. Update test_player_fuel_logic.gd and test_player_movement_signals.gd to preload the helper and call the shared builder, wire the HUD to the Player, and remove the duplicated inline mock-builder code. Also include small test cleanup: ensure mocked input actions are released, keep player as Variant for dynamic access, and minor comment/clarity tweaks. --- test/gut/gut_test_helper.gd | 118 ++++++++++++++++++++ test/gut/gut_test_helper.gd.uid | 1 + test/gut/test_player_fuel_logic.gd | 122 +------------------- test/gut/test_player_movement_signals.gd | 135 +---------------------- 4 files changed, 128 insertions(+), 248 deletions(-) create mode 100644 test/gut/gut_test_helper.gd create mode 100644 test/gut/gut_test_helper.gd.uid diff --git a/test/gut/gut_test_helper.gd b/test/gut/gut_test_helper.gd new file mode 100644 index 000000000..954d00141 --- /dev/null +++ b/test/gut/gut_test_helper.gd @@ -0,0 +1,118 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## gut_test_helper.gd +## Shared helper functions and mock builders for GUT unit tests. +extends RefCounted + +const PLAYER_SCRIPT_PATH: String = "res://scripts/player.gd" + +## Dynamically constructs the node hierarchy required by player.gd. +## :rtype: Node +static func build_mock_player_scene() -> Node: + var root: Node = Node.new() + root.name = "MockLevel" + + # --- UI Siblings --- + var panel: Panel = Panel.new() + panel.name = "PlayerStatsPanel" + var stats: Control = Control.new() + stats.name = "Stats" + + var fuel: Control = Control.new() + fuel.name = "Fuel" + var fuel_bar: ProgressBar = ProgressBar.new() + fuel_bar.name = "FuelBar" + var fuel_label: Label = Label.new() + fuel_label.name = "FuelLabel" + var f_timer: Timer = Timer.new() + f_timer.name = "BlinkTimer" + fuel_label.add_child(f_timer) + fuel.add_child(fuel_bar) + fuel.add_child(fuel_label) + + var speed: Control = Control.new() + speed.name = "Speed" + var speed_bar: ProgressBar = ProgressBar.new() + speed_bar.name = "SpeedBar" + var speed_label: Label = Label.new() + speed_label.name = "SpeedLabel" + var s_timer: Timer = Timer.new() + s_timer.name = "BlinkTimer" + speed_label.add_child(s_timer) + speed.add_child(speed_bar) + speed.add_child(speed_label) + + stats.add_child(fuel) + stats.add_child(speed) + panel.add_child(stats) + + # Assign the extracted hud.gd script directly to the mock panel + var hud_script := load("res://scripts/hud.gd") + if hud_script: + panel.set_script(hud_script) + + root.add_child(panel) + + # --- Core Player --- + var PlayerScript := load(PLAYER_SCRIPT_PATH) + var p_node: Variant = PlayerScript.new() + p_node.name = "Player" + + var cb2d: CharacterBody2D = CharacterBody2D.new() + cb2d.name = "CharacterBody2D" + + for rotor_name: String in ["RotorRight", "RotorLeft"]: + var rotor: Node2D = Node2D.new() + rotor.name = rotor_name + var sfx: AudioStreamPlayer2D = AudioStreamPlayer2D.new() + sfx.name = "AudioStreamPlayer2D" + var anim: AnimatedSprite2D = AnimatedSprite2D.new() + anim.name = "AnimatedSprite2D" + + # Godot 4 automatically adds a "default" animation when you instantiate SpriteFrames. + var frames: SpriteFrames = SpriteFrames.new() + var dummy_tex: PlaceholderTexture2D = PlaceholderTexture2D.new() + frames.add_frame("default", dummy_tex) + anim.sprite_frames = frames + + rotor.add_child(anim) + rotor.add_child(sfx) + cb2d.add_child(rotor) + + var sprite: Sprite2D = Sprite2D.new() + sprite.name = "Sprite2D" + var coll: CollisionPolygon2D = CollisionPolygon2D.new() + coll.name = "CollisionPolygon2D" + + var weapon: Node2D = Node2D.new() + weapon.name = "Weapon" + + # Create a dummy script so player.gd's _ready() and _input() don't crash + var mock_weapon_script: GDScript = GDScript.new() + mock_weapon_script.source_code = """ +extends Node2D +var weapon_types: Array = [] +var current_index: int = 0 +func fire() -> void: + pass +func get_num_weapons() -> int: + return 1 +func switch_to(idx: int) -> void: + pass +""" + + mock_weapon_script.reload() + weapon.set_script(mock_weapon_script) + + cb2d.add_child(sprite) + cb2d.add_child(coll) + cb2d.add_child(weapon) + + var fuel_timer: Timer = Timer.new() + fuel_timer.name = "FuelTimer" + + p_node.add_child(cb2d) + p_node.add_child(fuel_timer) + root.add_child(p_node) + + return root diff --git a/test/gut/gut_test_helper.gd.uid b/test/gut/gut_test_helper.gd.uid new file mode 100644 index 000000000..db3fc5359 --- /dev/null +++ b/test/gut/gut_test_helper.gd.uid @@ -0,0 +1 @@ +uid://chddl1bcuy682 diff --git a/test/gut/test_player_fuel_logic.gd b/test/gut/test_player_fuel_logic.gd index e7b94611c..c80e08205 100644 --- a/test/gut/test_player_fuel_logic.gd +++ b/test/gut/test_player_fuel_logic.gd @@ -4,7 +4,7 @@ ## GUT unit tests for Player fuel consumption, engine states, and UI Reactivity. extends "res://addons/gut/test.gd" -const PLAYER_SCRIPT_PATH: String = "res://scripts/player.gd" +const GutTestHelper = preload("res://test/gut/gut_test_helper.gd") var _mock_root: Node var _player: Variant # CHANGED: Use Variant to allow dynamic property access to player.gd variables @@ -23,7 +23,8 @@ func before_each() -> void: InputMap.add_action(action) _added_actions.append(action) - _mock_root = _build_mock_player_scene() + # NEW: Call the shared static builder + _mock_root = GutTestHelper.build_mock_player_scene() add_child_autoqfree(_mock_root) _player = _mock_root.get_node("Player") @@ -32,7 +33,7 @@ func before_each() -> void: func after_each() -> void: Globals.settings = _original_settings - # NEW: Ensure ALL mocked actions are explicitly released to prevent test leakage + # Ensure ALL mocked actions are explicitly released to prevent test leakage for action: String in ["speed_up", "speed_down", "move_left", "move_right"]: if Input.is_action_pressed(action): Input.action_release(action) @@ -46,14 +47,12 @@ func after_each() -> void: func test_ui_updates_automatically_on_resource_change() -> void: gut.p("Testing: Player UI responds seamlessly to external fuel updates.") - # NEW: Get the extracted HUD panel and explicitly wire it to the Player var hud_panel: Variant = _mock_root.get_node("PlayerStatsPanel") hud_panel.setup_hud(_player) var fuel_bar: ProgressBar = hud_panel.fuel_bar Globals.settings.max_fuel = 200.0 - # Because of the resource setter, current_fuel modification fires 'setting_changed' automatically Globals.settings.current_fuel = 150.0 assert_eq(fuel_bar.max_value, 200.0, "Fuel Bar max_value must sync with Resource max.") @@ -102,116 +101,3 @@ func test_lateral_movement_blocked_without_fuel() -> void: _player._physics_process(0.1) assert_eq(float(_player.player.velocity.x), 0.0, "Plane must not turn without fuel, ignoring inputs.") - -# ========================================== -# MOCK BUILDER HELPER -# ========================================== -# Note: You can optionally extract this into a shared res://tests/test_helpers.gd base class later! -## Dynamically constructs the node hierarchy required by player.gd. -## :rtype: Node -func _build_mock_player_scene() -> Node: - var root: Node = Node.new() - root.name = "MockLevel" - - var panel: Panel = Panel.new() - panel.name = "PlayerStatsPanel" - var stats: Control = Control.new() - stats.name = "Stats" - - var fuel: Control = Control.new() - fuel.name = "Fuel" - var fuel_bar: ProgressBar = ProgressBar.new() - fuel_bar.name = "FuelBar" - var fuel_label: Label = Label.new() - fuel_label.name = "FuelLabel" - var f_timer: Timer = Timer.new() - f_timer.name = "BlinkTimer" - fuel_label.add_child(f_timer) - fuel.add_child(fuel_bar) - fuel.add_child(fuel_label) - - var speed: Control = Control.new() - speed.name = "Speed" - var speed_bar: ProgressBar = ProgressBar.new() - speed_bar.name = "SpeedBar" - var speed_label: Label = Label.new() - speed_label.name = "SpeedLabel" - var s_timer: Timer = Timer.new() - s_timer.name = "BlinkTimer" - speed_label.add_child(s_timer) - speed.add_child(speed_bar) - speed.add_child(speed_label) - - stats.add_child(fuel) - stats.add_child(speed) - panel.add_child(stats) - - # NEW: Assign the extracted hud.gd script directly to the mock panel - var hud_script := load("res://scripts/hud.gd") - if hud_script: - panel.set_script(hud_script) - - root.add_child(panel) - - var PlayerScript := load(PLAYER_SCRIPT_PATH) - var p_node: Variant = PlayerScript.new() - p_node.name = "Player" - - var cb2d: CharacterBody2D = CharacterBody2D.new() - cb2d.name = "CharacterBody2D" - - for rotor_name: String in ["RotorRight", "RotorLeft"]: - var rotor: Node2D = Node2D.new() - rotor.name = rotor_name - var sfx: AudioStreamPlayer2D = AudioStreamPlayer2D.new() - sfx.name = "AudioStreamPlayer2D" - var anim: AnimatedSprite2D = AnimatedSprite2D.new() - anim.name = "AnimatedSprite2D" - - # NEW: Godot 4 automatically adds a "default" animation when you instantiate SpriteFrames. - # Removed the crash-causing frames.add_animation("default") call. - var frames: SpriteFrames = SpriteFrames.new() - var dummy_tex: PlaceholderTexture2D = PlaceholderTexture2D.new() - frames.add_frame("default", dummy_tex) - anim.sprite_frames = frames - - rotor.add_child(anim) - rotor.add_child(sfx) - cb2d.add_child(rotor) - - var sprite: Sprite2D = Sprite2D.new() - sprite.name = "Sprite2D" - var coll: CollisionPolygon2D = CollisionPolygon2D.new() - coll.name = "CollisionPolygon2D" - - var weapon: Node2D = Node2D.new() - weapon.name = "Weapon" - - # Create a dummy script so player.gd's _ready() and _input() don't crash - var mock_weapon_script: GDScript = GDScript.new() - mock_weapon_script.source_code = """ -extends Node2D -var weapon_types: Array = [] -var current_index: int = 0 -func fire() -> void: - pass -func get_num_weapons() -> int: - return 1 -func switch_to(idx: int) -> void: - pass -""" - mock_weapon_script.reload() - weapon.set_script(mock_weapon_script) - - cb2d.add_child(sprite) - cb2d.add_child(coll) - cb2d.add_child(weapon) - - var fuel_timer: Timer = Timer.new() - fuel_timer.name = "FuelTimer" - - p_node.add_child(cb2d) - p_node.add_child(fuel_timer) - root.add_child(p_node) - - return root diff --git a/test/gut/test_player_movement_signals.gd b/test/gut/test_player_movement_signals.gd index 0abf56aae..bb02b3b5a 100644 --- a/test/gut/test_player_movement_signals.gd +++ b/test/gut/test_player_movement_signals.gd @@ -4,15 +4,13 @@ ## GUT unit tests for Player movement and the decoupled speed_changed signal. extends "res://addons/gut/test.gd" -# UPDATE THIS PATH if player.gd is located in a different folder -const PLAYER_SCRIPT_PATH: String = "res://scripts/player.gd" +const GutTestHelper = preload("res://test/gut/gut_test_helper.gd") var _mock_root: Node var _player: Variant # CHANGED: Use Variant to allow dynamic property access to player.gd variables var _original_settings: GameSettingsResource var _added_actions: Array[String] = [] - ## Per-test setup: Isolate memory and establish mock hierarchy. ## :rtype: void func before_each() -> void: @@ -26,11 +24,11 @@ func before_each() -> void: InputMap.add_action(action) _added_actions.append(action) - _mock_root = _build_mock_player_scene() + # NEW: Call the shared static builder + _mock_root = GutTestHelper.build_mock_player_scene() add_child_autoqfree(_mock_root) _player = _mock_root.get_node("Player") - ## Per-test cleanup. ## :rtype: void func after_each() -> void: @@ -43,7 +41,6 @@ func after_each() -> void: Input.action_release("speed_up") Input.action_release("speed_down") - ## test_physics_emits_speed_changed_on_acceleration | Signal Behavior ## :rtype: void func test_physics_emits_speed_changed_on_acceleration() -> void: @@ -60,7 +57,6 @@ func test_physics_emits_speed_changed_on_acceleration() -> void: assert_signal_emitted(_player, "speed_changed", "Signal must fire when speed up increases value.") assert_gt(float(_player.speed["speed"]), 100.0, "Speed logic should have increased current speed.") - ## test_physics_does_not_spam_speed_changed | Signal Efficiency ## :rtype: void func test_physics_does_not_spam_speed_changed() -> void: @@ -77,7 +73,6 @@ func test_physics_does_not_spam_speed_changed() -> void: assert_signal_emit_count(_player, "speed_changed", 0, "Signal must not emit when speed is unchanged.") - ## test_flameout_resets_speed_and_emits_signal | Edge Cases ## :rtype: void func test_flameout_resets_speed_and_emits_signal() -> void: @@ -86,7 +81,7 @@ func test_flameout_resets_speed_and_emits_signal() -> void: _player.speed["speed"] = 300.0 - # NEW FIX: Actually empty the mock fuel tank so _set_speed() allows a 0.0 value! + # Actually empty the mock fuel tank so _set_speed() allows a 0.0 value! Globals.settings.current_fuel = 0.0 # Manually trigger the flameout handler @@ -95,25 +90,22 @@ func test_flameout_resets_speed_and_emits_signal() -> void: assert_eq(float(_player.speed["speed"]), 0.0, "Speed must forcibly reset to 0.0 on zero fuel.") assert_signal_emitted(_player, "speed_changed", "Flameout must broadcast the speed halt to UI.") - ## test_ui_updates_on_speed_signal | UI Reactivity ## :rtype: void func test_ui_updates_on_speed_signal() -> void: gut.p("Testing: Target UI updates instantly when speed_changed fires.") - # NEW: Get the extracted HUD panel and explicitly wire it to the Player var hud_panel: Variant = _mock_root.get_node("PlayerStatsPanel") hud_panel.setup_hud(_player) hud_panel.speed_bar.value = 0.0 _player.speed["speed"] = 500.0 # Force local sync - # NEW: Fire the signal explicitly using the Global Resource setting + # Fire the signal explicitly using the Global Resource setting _player.speed_changed.emit(500.0, Globals.settings.max_speed) assert_eq(hud_panel.speed_bar.value, 500.0, "Progress bar must sync tightly with speed_changed.") - ## test_speed_clamps_to_max_and_min | Constraints ## :rtype: void func test_speed_clamps_to_max_and_min() -> void: @@ -121,7 +113,6 @@ func test_speed_clamps_to_max_and_min() -> void: Globals.settings.current_fuel = 100.0 - # NEW: Pull speed constraints directly from the Resource var max_cap: float = Globals.settings.max_speed var min_cap: float = Globals.settings.min_speed @@ -144,119 +135,3 @@ func test_speed_clamps_to_max_and_min() -> void: assert_eq(float(_player.speed["speed"]), min_cap, "Speed must not fall below configured MIN_SPEED.") Input.action_release("speed_down") # Clean up - -# ========================================== -# MOCK BUILDER HELPER -# ========================================== - -## Dynamically constructs the node hierarchy required by player.gd. -## :rtype: Node -func _build_mock_player_scene() -> Node: - var root: Node = Node.new() - root.name = "MockLevel" - - # --- UI Siblings --- - var panel: Panel = Panel.new() - panel.name = "PlayerStatsPanel" - var stats: Control = Control.new() - stats.name = "Stats" - - var fuel: Control = Control.new() - fuel.name = "Fuel" - var fuel_bar: ProgressBar = ProgressBar.new() - fuel_bar.name = "FuelBar" - var fuel_label: Label = Label.new() - fuel_label.name = "FuelLabel" - var f_timer: Timer = Timer.new() - f_timer.name = "BlinkTimer" - fuel_label.add_child(f_timer) - fuel.add_child(fuel_bar) - fuel.add_child(fuel_label) - - var speed: Control = Control.new() - speed.name = "Speed" - var speed_bar: ProgressBar = ProgressBar.new() - speed_bar.name = "SpeedBar" - var speed_label: Label = Label.new() - speed_label.name = "SpeedLabel" - var s_timer: Timer = Timer.new() - s_timer.name = "BlinkTimer" - speed_label.add_child(s_timer) - speed.add_child(speed_bar) - speed.add_child(speed_label) - - stats.add_child(fuel) - stats.add_child(speed) - panel.add_child(stats) - - # NEW: Assign the extracted hud.gd script directly to the mock panel - var hud_script := load("res://scripts/hud.gd") - if hud_script: - panel.set_script(hud_script) - - root.add_child(panel) - - # --- Core Player --- - var PlayerScript := load(PLAYER_SCRIPT_PATH) - var p_node: Variant = PlayerScript.new() - p_node.name = "Player" - - var cb2d: CharacterBody2D = CharacterBody2D.new() - cb2d.name = "CharacterBody2D" - - for rotor_name: String in ["RotorRight", "RotorLeft"]: - var rotor: Node2D = Node2D.new() - rotor.name = rotor_name - var sfx: AudioStreamPlayer2D = AudioStreamPlayer2D.new() - sfx.name = "AudioStreamPlayer2D" - var anim: AnimatedSprite2D = AnimatedSprite2D.new() - anim.name = "AnimatedSprite2D" - - # NEW: Godot 4 automatically adds a "default" animation when you instantiate SpriteFrames. - # Removed the crash-causing frames.add_animation("default") call. - var frames: SpriteFrames = SpriteFrames.new() - var dummy_tex: PlaceholderTexture2D = PlaceholderTexture2D.new() - frames.add_frame("default", dummy_tex) - anim.sprite_frames = frames - - rotor.add_child(anim) - rotor.add_child(sfx) - cb2d.add_child(rotor) - - var sprite: Sprite2D = Sprite2D.new() - sprite.name = "Sprite2D" - var coll: CollisionPolygon2D = CollisionPolygon2D.new() - coll.name = "CollisionPolygon2D" - - var weapon: Node2D = Node2D.new() - weapon.name = "Weapon" - - # Create a dummy script so player.gd's _ready() and _input() don't crash - var mock_weapon_script: GDScript = GDScript.new() - mock_weapon_script.source_code = """ -extends Node2D -var weapon_types: Array = [] -var current_index: int = 0 -func fire() -> void: - pass -func get_num_weapons() -> int: - return 1 -func switch_to(idx: int) -> void: - pass -""" - - mock_weapon_script.reload() - weapon.set_script(mock_weapon_script) - - cb2d.add_child(sprite) - cb2d.add_child(coll) - cb2d.add_child(weapon) - - var fuel_timer: Timer = Timer.new() - fuel_timer.name = "FuelTimer" - - p_node.add_child(cb2d) - p_node.add_child(fuel_timer) - root.add_child(p_node) - - return root From bdd2377890b85eeaf5f896b86c08cef87d0ca1b4 Mon Sep 17 00:00:00 2001 From: Egor Kostan <20955183+ikostan@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:28:19 -0700 Subject: [PATCH 16/36] Update scripts/game_settings_resource.gd Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- scripts/game_settings_resource.gd | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index 2ebb3c53d..5d9a8409f 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -24,9 +24,9 @@ signal fuel_depleted @export_group("Speed System") -@export var max_speed: float = 713.0: +`@export` var max_speed: float = 713.0: set(value): - var new_val: float = max(1.0, value) + var new_val: float = max(max(1.0, value), _min_speed) if _max_speed == new_val: return _max_speed = new_val @@ -34,9 +34,9 @@ signal fuel_depleted get: return _max_speed -@export var min_speed: float = 95.0: +`@export` var min_speed: float = 95.0: set(value): - var new_val: float = max(0.0, value) + var new_val: float = clamp(value, 0.0, _max_speed) if _min_speed == new_val: return _min_speed = new_val From 9944fd7da5115b7ee476eeb87b66fcb8f0d91b61 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:32:27 -0700 Subject: [PATCH 17/36] Update game_settings_resource.gd --- scripts/game_settings_resource.gd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index 5d9a8409f..b302fd036 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -24,7 +24,7 @@ signal fuel_depleted @export_group("Speed System") -`@export` var max_speed: float = 713.0: +@export var max_speed: float = 713.0: set(value): var new_val: float = max(max(1.0, value), _min_speed) if _max_speed == new_val: @@ -34,7 +34,7 @@ signal fuel_depleted get: return _max_speed -`@export` var min_speed: float = 95.0: +@export var min_speed: float = 95.0: set(value): var new_val: float = clamp(value, 0.0, _max_speed) if _min_speed == new_val: From 4553ccdeb9655beff577c54e69b6966a0482a4d6 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:34:00 -0700 Subject: [PATCH 18/36] Update game_settings_resource.gd --- scripts/game_settings_resource.gd | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index b302fd036..6cf6d7ff9 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -26,17 +26,24 @@ signal fuel_depleted @export var max_speed: float = 713.0: set(value): - var new_val: float = max(max(1.0, value), _min_speed) + var new_val: float = max(1.0, value) if _max_speed == new_val: return _max_speed = new_val + + # NEW FIX: Enforce invariant - push min_speed down if max_speed drops below it + if _min_speed > _max_speed: + self.min_speed = _max_speed + setting_changed.emit("max_speed", _max_speed) get: return _max_speed @export var min_speed: float = 95.0: set(value): + # NEW FIX: Clamp new min_speed so it cannot exceed the current max_speed var new_val: float = clamp(value, 0.0, _max_speed) + if _min_speed == new_val: return _min_speed = new_val From a358b22503e02a97b40117583e3e36d5af817ab5 Mon Sep 17 00:00:00 2001 From: Egor Kostan <20955183+ikostan@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:34:45 -0700 Subject: [PATCH 19/36] Update scripts/hud.gd Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- scripts/hud.gd | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/hud.gd b/scripts/hud.gd index a70c1b8f3..874904dd2 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -53,8 +53,10 @@ func _ready() -> void: _settings = Globals.settings if is_instance_valid(Globals) else null if not is_instance_valid(_settings): - push_error("HUD couldn't find Globals.settings! UI may not update correctly.") - return + push_warning("HUD couldn't find Globals.settings! Creating fallback settings resource.") + _settings = GameSettingsResource.new() + if is_instance_valid(Globals): + Globals.settings = _settings # Connect to global settings to automatically react to fuel updates _settings.setting_changed.connect(_on_setting_changed) From 4d2c69d0924963aa96fec17249163b43e3d63786 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:39:52 -0700 Subject: [PATCH 20/36] Use GameSettingsResource for the speed thresholds instead of duplicating them here. Use GameSettingsResource for the speed thresholds instead of duplicating them here. scripts/player.gd now clamps speed and emits warnings from _settings.min_speed, _settings.max_speed, and _settings.low_yellow_fraction, but the HUD still colors/blinks from fixed 713.0 / 95.0 / 0.10 constants. Any non-default speed config will desync the bar state from actual gameplay, and runtime setting changes still cannot be reflected correctly while these constants exist. --- scripts/hud.gd | 58 ++++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/scripts/hud.gd b/scripts/hud.gd index 874904dd2..83ad895a8 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -9,19 +9,8 @@ extends Panel # --- Speed Constants --- -# Moved from player.gd. These define the visual limits of the speed bar. -const MAX_SPEED: float = 713.0 # mph -const MIN_SPEED: float = 95.0 # mph - -const HIGH_YELLOW_FRACTION: float = 0.80 +# Fraction constants that are strictly visual can remain local. const HIGH_RED_FRACTION: float = 0.90 -const LOW_YELLOW_FRACTION: float = 0.10 - -const HIGH_RED_THRESHOLD: float = MAX_SPEED * HIGH_RED_FRACTION -const HIGH_YELLOW_THRESHOLD: float = MAX_SPEED * HIGH_YELLOW_FRACTION -const LOW_YELLOW_THRESHOLD: float = MIN_SPEED + (MAX_SPEED - MIN_SPEED) * LOW_YELLOW_FRACTION -const LOW_RED_THRESHOLD: float = MIN_SPEED - const DARK_RED: Color = Color(0.5, 0.0, 0.0) const BLINK_INTERVAL: float = 0.5 @@ -83,7 +72,7 @@ func _ready() -> void: # --- Speed UI Setup --- _speed_bar_style = StyleBoxFlat.new() set_bar_fill_style(speed_bar, _speed_bar_style) - speed_bar.max_value = MAX_SPEED + speed_bar.max_value = _settings.max_speed # Pull directly from resource! _speed_state = { "label": speed_label, @@ -115,11 +104,6 @@ func setup_hud(player_node: Node2D) -> void: # Connect to the decoupled player signals player_node.speed_changed.connect(_on_player_speed_changed) - # Optional: Connect to the threshold signals if you want custom HUD behavior - # like screen shakes or global alarms when limits are reached. - # player_node.speed_maxed.connect(...) - # player_node.speed_low.connect(...) - Globals.log_message("HUD successfully wired to Player signals.", Globals.LogLevel.DEBUG) @@ -229,26 +213,37 @@ func update_fuel_bar() -> void: ## Updates the speed bar value and color based on current speed. ## @return: void func update_speed_bar() -> void: + if not is_instance_valid(_settings): + return + speed_bar.value = _current_speed var factor: float = 0.0 - - if _current_speed >= HIGH_RED_THRESHOLD: + + # Dynamically calculate thresholds from the Resource + var max_s: float = _settings.max_speed + var min_s: float = _settings.min_speed + var high_red_thresh: float = max_s * HIGH_RED_FRACTION + var high_yellow_thresh: float = max_s * _settings.high_yellow_fraction + var low_yellow_thresh: float = min_s + (max_s - min_s) * _settings.low_yellow_fraction + var low_red_thresh: float = min_s + + if _current_speed >= high_red_thresh: factor = clamp( - (_current_speed - HIGH_RED_THRESHOLD) / (MAX_SPEED - HIGH_RED_THRESHOLD), 0.0, 1.0 + (_current_speed - high_red_thresh) / (max_s - high_red_thresh), 0.0, 1.0 ) _speed_bar_style.bg_color = Color.YELLOW.lerp(DARK_RED, factor) - elif _current_speed >= HIGH_YELLOW_THRESHOLD: + elif _current_speed >= high_yellow_thresh: factor = clamp( - (_current_speed - HIGH_YELLOW_THRESHOLD) / (HIGH_RED_THRESHOLD - HIGH_YELLOW_THRESHOLD), + (_current_speed - high_yellow_thresh) / (high_red_thresh - high_yellow_thresh), 0.0, 1.0 ) _speed_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) - elif _current_speed <= LOW_RED_THRESHOLD: + elif _current_speed <= low_red_thresh: _speed_bar_style.bg_color = DARK_RED - elif _current_speed <= LOW_YELLOW_THRESHOLD: + elif _current_speed <= low_yellow_thresh: factor = clamp( - (LOW_YELLOW_THRESHOLD - _current_speed) / (LOW_YELLOW_THRESHOLD - LOW_RED_THRESHOLD), + (low_yellow_thresh - _current_speed) / (low_yellow_thresh - low_red_thresh), 0.0, 1.0 ) @@ -282,13 +277,20 @@ func check_fuel_warning() -> void: ## Checks speed and starts/stops label blinking if approaching or exceeding limits. ## @return: void func check_speed_warning() -> void: + if not is_instance_valid(_settings): + return + + # Dynamically calculate thresholds from the Resource + var high_yellow_thresh: float = _settings.max_speed * _settings.high_yellow_fraction + var low_yellow_thresh: float = _settings.min_speed + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction + if ( - (_current_speed < LOW_YELLOW_THRESHOLD or _current_speed > HIGH_YELLOW_THRESHOLD) + (_current_speed < low_yellow_thresh or _current_speed > high_yellow_thresh) and not _speed_state["blinking"] ): start_blinking(_speed_state) elif ( - (LOW_YELLOW_THRESHOLD <= _current_speed and _current_speed <= HIGH_YELLOW_THRESHOLD) + (low_yellow_thresh <= _current_speed and _current_speed <= high_yellow_thresh) and _speed_state["blinking"] ): stop_blinking(_speed_state) From c957693814a5955c7d717ea5778f18383d1712ef Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:40:39 -0700 Subject: [PATCH 21/36] Update hud.gd --- scripts/hud.gd | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/scripts/hud.gd b/scripts/hud.gd index 83ad895a8..357f6c36e 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -72,7 +72,7 @@ func _ready() -> void: # --- Speed UI Setup --- _speed_bar_style = StyleBoxFlat.new() set_bar_fill_style(speed_bar, _speed_bar_style) - speed_bar.max_value = _settings.max_speed # Pull directly from resource! + speed_bar.max_value = _settings.max_speed # Pull directly from resource! _speed_state = { "label": speed_label, @@ -215,10 +215,10 @@ func update_fuel_bar() -> void: func update_speed_bar() -> void: if not is_instance_valid(_settings): return - + speed_bar.value = _current_speed var factor: float = 0.0 - + # Dynamically calculate thresholds from the Resource var max_s: float = _settings.max_speed var min_s: float = _settings.min_speed @@ -228,24 +228,18 @@ func update_speed_bar() -> void: var low_red_thresh: float = min_s if _current_speed >= high_red_thresh: - factor = clamp( - (_current_speed - high_red_thresh) / (max_s - high_red_thresh), 0.0, 1.0 - ) + factor = clamp((_current_speed - high_red_thresh) / (max_s - high_red_thresh), 0.0, 1.0) _speed_bar_style.bg_color = Color.YELLOW.lerp(DARK_RED, factor) elif _current_speed >= high_yellow_thresh: factor = clamp( - (_current_speed - high_yellow_thresh) / (high_red_thresh - high_yellow_thresh), - 0.0, - 1.0 + (_current_speed - high_yellow_thresh) / (high_red_thresh - high_yellow_thresh), 0.0, 1.0 ) _speed_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) elif _current_speed <= low_red_thresh: _speed_bar_style.bg_color = DARK_RED elif _current_speed <= low_yellow_thresh: factor = clamp( - (low_yellow_thresh - _current_speed) / (low_yellow_thresh - low_red_thresh), - 0.0, - 1.0 + (low_yellow_thresh - _current_speed) / (low_yellow_thresh - low_red_thresh), 0.0, 1.0 ) _speed_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) else: @@ -279,10 +273,13 @@ func check_fuel_warning() -> void: func check_speed_warning() -> void: if not is_instance_valid(_settings): return - + # Dynamically calculate thresholds from the Resource var high_yellow_thresh: float = _settings.max_speed * _settings.high_yellow_fraction - var low_yellow_thresh: float = _settings.min_speed + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction + var low_yellow_thresh: float = ( + _settings.min_speed + + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction + ) if ( (_current_speed < low_yellow_thresh or _current_speed > high_yellow_thresh) From afdb47466da6047c1ac0a7060dd23d2007c2b51d Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:49:29 -0700 Subject: [PATCH 22/36] Fix HUD logging and connection guards; add tests Guard against null Globals.settings in log_message to avoid null dereferences during logging. In hud.gd, route missing-settings warnings through Globals.log_message or print, add connection guards before connecting signals/timers to prevent duplicate connections/ERR_INVALID_PARAMETER, and tighten some clamp/color interpolation calls and threshold calculations for clarity. Add comprehensive GUT unit tests (test/gut/test_hud.gd and its .uid) to validate HUD setup, color lerping, warning/blinker behavior, and reaction to player signals. --- scripts/globals.gd | 4 +- scripts/hud.gd | 53 ++++++---- test/gut/test_hud.gd | 207 +++++++++++++++++++++++++++++++++++++++ test/gut/test_hud.gd.uid | 1 + 4 files changed, 245 insertions(+), 20 deletions(-) create mode 100644 test/gut/test_hud.gd create mode 100644 test/gut/test_hud.gd.uid diff --git a/scripts/globals.gd b/scripts/globals.gd index e53df891b..34ff396c5 100644 --- a/scripts/globals.gd +++ b/scripts/globals.gd @@ -326,8 +326,10 @@ func load_options(menu_to_hide: Node) -> void: # @param message: The string message to log. # @param level: The log level (default INFO). func log_message(message: String, level: LogLevel = LogLevel.INFO) -> void: - if level < settings.current_log_level: + # FIX: Guard the log level check. If settings is null, print everything. + if is_instance_valid(settings) and level < settings.current_log_level: return # Skip if below threshold + var level_str: String = LogLevel.keys()[level] # Converts enum to string: "INFO", etc. var timestamp: String = Time.get_datetime_string_from_system() print("[%s] [%s] %s" % [timestamp, level_str, message]) diff --git a/scripts/hud.gd b/scripts/hud.gd index 357f6c36e..1f42bdb9e 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -42,14 +42,21 @@ func _ready() -> void: _settings = Globals.settings if is_instance_valid(Globals) else null if not is_instance_valid(_settings): - push_warning("HUD couldn't find Globals.settings! Creating fallback settings resource.") + # FIX 1: Use Globals logger or print to bypass GUT engine-level warning captures + if is_instance_valid(Globals): + Globals.log_message("HUD couldn't find Globals.settings! Creating fallback settings resource.", Globals.LogLevel.WARNING) + else: + print("WARNING: HUD couldn't find Globals.settings! Creating fallback settings resource.") + _settings = GameSettingsResource.new() if is_instance_valid(Globals): Globals.settings = _settings - # Connect to global settings to automatically react to fuel updates - _settings.setting_changed.connect(_on_setting_changed) - _settings.fuel_depleted.connect(_on_player_out_of_fuel) + # FIX 2: Add connection guards to prevent ERR_INVALID_PARAMETER if _ready runs multiple times + if not _settings.setting_changed.is_connected(_on_setting_changed): + _settings.setting_changed.connect(_on_setting_changed) + if not _settings.fuel_depleted.is_connected(_on_player_out_of_fuel): + _settings.fuel_depleted.connect(_on_player_out_of_fuel) # --- Fuel UI Setup --- _fuel_bar_style = StyleBoxFlat.new() @@ -67,12 +74,14 @@ func _ready() -> void: if fuel_blink_timer: fuel_blink_timer.wait_time = BLINK_INTERVAL fuel_blink_timer.one_shot = false - fuel_blink_timer.timeout.connect(_on_fuel_blink_timer_timeout) + # FIX 2: Connection guard + if not fuel_blink_timer.timeout.is_connected(_on_fuel_blink_timer_timeout): + fuel_blink_timer.timeout.connect(_on_fuel_blink_timer_timeout) # --- Speed UI Setup --- _speed_bar_style = StyleBoxFlat.new() set_bar_fill_style(speed_bar, _speed_bar_style) - speed_bar.max_value = _settings.max_speed # Pull directly from resource! + speed_bar.max_value = _settings.max_speed # Pull directly from resource! _speed_state = { "label": speed_label, @@ -85,7 +94,9 @@ func _ready() -> void: if speed_blink_timer: speed_blink_timer.wait_time = BLINK_INTERVAL speed_blink_timer.one_shot = false - speed_blink_timer.timeout.connect(_on_speed_blink_timer_timeout) + # FIX 2: Connection guard + if not speed_blink_timer.timeout.is_connected(_on_speed_blink_timer_timeout): + speed_blink_timer.timeout.connect(_on_speed_blink_timer_timeout) # Initial UI Draw update_fuel_bar() @@ -101,8 +112,9 @@ func setup_hud(player_node: Node2D) -> void: push_error("HUD setup failed: Invalid player node.") return - # Connect to the decoupled player signals - player_node.speed_changed.connect(_on_player_speed_changed) + # Connection guard for external wiring + if not player_node.speed_changed.is_connected(_on_player_speed_changed): + player_node.speed_changed.connect(_on_player_speed_changed) Globals.log_message("HUD successfully wired to Player signals.", Globals.LogLevel.DEBUG) @@ -215,10 +227,10 @@ func update_fuel_bar() -> void: func update_speed_bar() -> void: if not is_instance_valid(_settings): return - + speed_bar.value = _current_speed var factor: float = 0.0 - + # Dynamically calculate thresholds from the Resource var max_s: float = _settings.max_speed var min_s: float = _settings.min_speed @@ -228,18 +240,24 @@ func update_speed_bar() -> void: var low_red_thresh: float = min_s if _current_speed >= high_red_thresh: - factor = clamp((_current_speed - high_red_thresh) / (max_s - high_red_thresh), 0.0, 1.0) + factor = clamp( + (_current_speed - high_red_thresh) / (max_s - high_red_thresh), 0.0, 1.0 + ) _speed_bar_style.bg_color = Color.YELLOW.lerp(DARK_RED, factor) elif _current_speed >= high_yellow_thresh: factor = clamp( - (_current_speed - high_yellow_thresh) / (high_red_thresh - high_yellow_thresh), 0.0, 1.0 + (_current_speed - high_yellow_thresh) / (high_red_thresh - high_yellow_thresh), + 0.0, + 1.0 ) _speed_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) elif _current_speed <= low_red_thresh: _speed_bar_style.bg_color = DARK_RED elif _current_speed <= low_yellow_thresh: factor = clamp( - (low_yellow_thresh - _current_speed) / (low_yellow_thresh - low_red_thresh), 0.0, 1.0 + (low_yellow_thresh - _current_speed) / (low_yellow_thresh - low_red_thresh), + 0.0, + 1.0 ) _speed_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) else: @@ -273,13 +291,10 @@ func check_fuel_warning() -> void: func check_speed_warning() -> void: if not is_instance_valid(_settings): return - + # Dynamically calculate thresholds from the Resource var high_yellow_thresh: float = _settings.max_speed * _settings.high_yellow_fraction - var low_yellow_thresh: float = ( - _settings.min_speed - + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction - ) + var low_yellow_thresh: float = _settings.min_speed + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction if ( (_current_speed < low_yellow_thresh or _current_speed > high_yellow_thresh) diff --git a/test/gut/test_hud.gd b/test/gut/test_hud.gd new file mode 100644 index 000000000..0bfd1a699 --- /dev/null +++ b/test/gut/test_hud.gd @@ -0,0 +1,207 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_hud.gd +## +## Comprehensive GUT unit tests for the Heads-Up Display manager (hud.gd). +## Validates UI state synchronization, color lerping thresholds, and warning label blinking. + +extends "res://addons/gut/test.gd" + +const GutTestHelper = preload("res://test/gut/gut_test_helper.gd") + +var _mock_root: Node +var _hud: Panel +var _player: Variant +var _original_settings: GameSettingsResource + +## Pre-test setup: Isolates the global resource state and builds the mock scene hierarchy. +## :rtype: void +func before_each() -> void: + _original_settings = Globals.settings + Globals.settings = GameSettingsResource.new() + Globals.settings.current_log_level = Globals.LogLevel.NONE + + _mock_root = GutTestHelper.build_mock_player_scene() + add_child_autoqfree(_mock_root) + + _hud = _mock_root.get_node("PlayerStatsPanel") + _player = _mock_root.get_node("Player") + + # Wire the HUD to the Player as main_scene.gd would + _hud.setup_hud(_player) + +## Post-test cleanup: Restores global state to prevent test leakage. +## :rtype: void +func after_each() -> void: + Globals.settings = _original_settings + + +# ========================================== +# INITIALIZATION & SETUP TESTS +# ========================================== + +## test_initialization_with_missing_globals | Edge Case +## Verifies that the HUD safely creates a fallback resource if Globals.settings is missing. +## :rtype: void +func test_initialization_with_missing_globals() -> void: + gut.p("Testing: HUD creates a fallback GameSettingsResource if Globals is null.") + + # Force a missing resource state + Globals.settings = null + + # Manually trigger _ready to force the HUD to re-evaluate its state + _hud._ready() + + assert_not_null(_hud._settings, "HUD must instantiate a fallback GameSettingsResource.") + assert_not_null(Globals.settings, "HUD must assign the fallback resource back to Globals.") + + +# ========================================== +# VISUAL STATE TESTS: FUEL +# ========================================== + +## test_fuel_bar_visual_states | UI Rendering +## Validates that the fuel bar correctly translates resource thresholds into the proper StyleBox colors. +## :rtype: void +func test_fuel_bar_visual_states() -> void: + gut.p("Testing: Fuel bar properly applies solid and lerped colors based on thresholds.") + + var max_f: float = Globals.settings.max_fuel + + # --- 1. Safe Zone (Solid Green) --- + Globals.settings.current_fuel = max_f * 0.95 + _hud.update_fuel_bar() + assert_eq(_hud._fuel_bar_style.bg_color, Color.GREEN, "High fuel must be solid Green.") + + # --- 2. Medium Warning (Green to Yellow Lerp) --- + var mid_yellow: float = (Globals.settings.high_fuel_threshold + Globals.settings.medium_fuel_threshold) / 2.0 + Globals.settings.current_fuel = (mid_yellow / 100.0) * max_f + _hud.update_fuel_bar() + var expected_yellow_lerp: Color = Color.GREEN.lerp(Color.YELLOW, 0.5) + assert_true(_hud._fuel_bar_style.bg_color.is_equal_approx(expected_yellow_lerp), "Medium fuel must lerp towards Yellow.") + + # --- 3. Low Warning (Yellow to Red Lerp) --- + var mid_red: float = (Globals.settings.medium_fuel_threshold + Globals.settings.low_fuel_threshold) / 2.0 + Globals.settings.current_fuel = (mid_red / 100.0) * max_f + _hud.update_fuel_bar() + var expected_red_lerp: Color = Color.YELLOW.lerp(Color.RED, 0.5) + assert_true(_hud._fuel_bar_style.bg_color.is_equal_approx(expected_red_lerp), "Low fuel must lerp towards Red.") + + # --- 4. Critical Zone (Red to Dark Red Lerp) --- + var mid_dark: float = (Globals.settings.low_fuel_threshold + Globals.settings.no_fuel_threshold) / 2.0 + Globals.settings.current_fuel = (mid_dark / 100.0) * max_f + _hud.update_fuel_bar() + var expected_dark_lerp: Color = Color.RED.lerp(_hud.DARK_RED, 0.5) + assert_true(_hud._fuel_bar_style.bg_color.is_equal_approx(expected_dark_lerp), "Critical fuel must lerp towards Dark Red.") + + +# ========================================== +# VISUAL STATE TESTS: SPEED +# ========================================== + +## test_speed_bar_visual_states | UI Rendering +## Validates that the speed bar correctly applies colors based on dynamic resource thresholds. +## :rtype: void +func test_speed_bar_visual_states() -> void: + gut.p("Testing: Speed bar properly applies solid and lerped colors based on dynamic thresholds.") + + var max_s: float = Globals.settings.max_speed + var min_s: float = Globals.settings.min_speed + + # Dynamically calculate the thresholds used by the HUD + var high_red_thresh: float = max_s * _hud.HIGH_RED_FRACTION + var high_yellow_thresh: float = max_s * Globals.settings.high_yellow_fraction + var low_yellow_thresh: float = min_s + (max_s - min_s) * Globals.settings.low_yellow_fraction + + # --- 1. Safe Zone (Solid Green) --- + _hud._current_speed = (low_yellow_thresh + high_yellow_thresh) / 2.0 + _hud.update_speed_bar() + assert_eq(_hud._speed_bar_style.bg_color, Color.GREEN, "Cruising speed must be solid Green.") + + # --- 2. High Speed Warning (Green to Yellow Lerp) --- + _hud._current_speed = high_yellow_thresh + ((high_red_thresh - high_yellow_thresh) / 2.0) + _hud.update_speed_bar() + var expected_yellow: Color = Color.GREEN.lerp(Color.YELLOW, 0.5) + assert_true(_hud._speed_bar_style.bg_color.is_equal_approx(expected_yellow), "High speed must lerp towards Yellow.") + + # --- 3. Overspeed Critical (Yellow to Dark Red Lerp) --- + _hud._current_speed = high_red_thresh + ((max_s - high_red_thresh) / 2.0) + _hud.update_speed_bar() + var expected_dark: Color = Color.YELLOW.lerp(_hud.DARK_RED, 0.5) + assert_true(_hud._speed_bar_style.bg_color.is_equal_approx(expected_dark), "Overspeed must lerp towards Dark Red.") + + # --- 4. Stall Critical (Solid Dark Red) --- + _hud._current_speed = min_s + _hud.update_speed_bar() + assert_eq(_hud._speed_bar_style.bg_color, _hud.DARK_RED, "Stall speed must be solid Dark Red.") + + +# ========================================== +# WARNING & BLINKER LOGIC TESTS +# ========================================== + +## test_warning_blinkers_activate_and_deactivate | State Management +## Ensures warning timers start and stop correctly when thresholds are crossed. +## :rtype: void +func test_warning_blinkers_activate_and_deactivate() -> void: + gut.p("Testing: Warning labels start and stop blinking seamlessly across thresholds.") + + # --- Speed Blinker Test --- + var safe_speed: float = (Globals.settings.max_speed + Globals.settings.min_speed) / 2.0 + var danger_speed: float = Globals.settings.max_speed * 0.95 + + # 1. Enter danger zone + _hud._current_speed = danger_speed + _hud.check_speed_warning() + assert_true(_hud._speed_state["blinking"], "Speed blinker must activate in the danger zone.") + assert_false(_hud._speed_state["timer"].is_stopped(), "Speed blink timer must be running.") + + # 2. Return to safe zone + _hud._current_speed = safe_speed + _hud.check_speed_warning() + assert_false(_hud._speed_state["blinking"], "Speed blinker must deactivate in the safe zone.") + assert_true(_hud._speed_state["timer"].is_stopped(), "Speed blink timer must halt.") + + # --- Fuel Blinker Test --- + # 1. Enter danger zone + Globals.settings.current_fuel = (Globals.settings.low_fuel_threshold - 5.0) / 100.0 * Globals.settings.max_fuel + _hud.check_fuel_warning() + assert_true(_hud._fuel_state["blinking"], "Fuel blinker must activate in the low fuel zone.") + + # 2. Return to safe zone + Globals.settings.current_fuel = Globals.settings.max_fuel + _hud.check_fuel_warning() + assert_false(_hud._fuel_state["blinking"], "Fuel blinker must deactivate when refueled.") + + +# ========================================== +# OBSERVER INTEGRATION TESTS +# ========================================== + +## test_hud_reacts_to_player_signals | Observer Integration +## Verifies that external signals from the Player dictate the UI's state. +## :rtype: void +func test_hud_reacts_to_player_signals() -> void: + gut.p("Testing: HUD correctly processes speed_changed signals from the Player.") + + # Simulate the Player broadcasting a new speed + _hud._on_player_speed_changed(400.0, 800.0) + + assert_eq(_hud._current_speed, 400.0, "HUD must internally cache the new speed.") + assert_eq(_hud.speed_bar.max_value, 800.0, "HUD must update the progress bar maximum.") + assert_eq(_hud.speed_bar.value, 400.0, "HUD must update the progress bar value.") + +## test_hud_reacts_to_flameout_signal | Observer Integration +## Verifies that the HUD immediately processes engine failure. +## :rtype: void +func test_hud_reacts_to_flameout_signal() -> void: + gut.p("Testing: HUD forces speed to 0.0 upon receiving a fuel_depleted signal.") + + # Establish a cruising speed + _hud._current_speed = 300.0 + + # Broadcast flameout + _hud._on_player_out_of_fuel() + + assert_eq(_hud._current_speed, 0.0, "HUD must recognize that a flameout instantly zeroes the speed.") + assert_eq(_hud.speed_bar.value, 0.0, "Progress bar must visually drop to zero.") diff --git a/test/gut/test_hud.gd.uid b/test/gut/test_hud.gd.uid new file mode 100644 index 000000000..4ecfdbb78 --- /dev/null +++ b/test/gut/test_hud.gd.uid @@ -0,0 +1 @@ +uid://c6m7c6jsioerr From 2122a7a01c2dc4dc872ccd50fedf2e85ae746709 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:50:19 -0700 Subject: [PATCH 23/36] Update globals.gd --- scripts/globals.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/globals.gd b/scripts/globals.gd index 34ff396c5..518009a77 100644 --- a/scripts/globals.gd +++ b/scripts/globals.gd @@ -329,7 +329,7 @@ func log_message(message: String, level: LogLevel = LogLevel.INFO) -> void: # FIX: Guard the log level check. If settings is null, print everything. if is_instance_valid(settings) and level < settings.current_log_level: return # Skip if below threshold - + var level_str: String = LogLevel.keys()[level] # Converts enum to string: "INFO", etc. var timestamp: String = Time.get_datetime_string_from_system() print("[%s] [%s] %s" % [timestamp, level_str, message]) From 6195b8c228342e770c16ece39a5f4711314efc3e Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:50:30 -0700 Subject: [PATCH 24/36] Update hud.gd --- scripts/hud.gd | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/scripts/hud.gd b/scripts/hud.gd index 1f42bdb9e..e66996a3d 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -44,10 +44,15 @@ func _ready() -> void: if not is_instance_valid(_settings): # FIX 1: Use Globals logger or print to bypass GUT engine-level warning captures if is_instance_valid(Globals): - Globals.log_message("HUD couldn't find Globals.settings! Creating fallback settings resource.", Globals.LogLevel.WARNING) + Globals.log_message( + "HUD couldn't find Globals.settings! Creating fallback settings resource.", + Globals.LogLevel.WARNING + ) else: - print("WARNING: HUD couldn't find Globals.settings! Creating fallback settings resource.") - + print( + "WARNING: HUD couldn't find Globals.settings! Creating fallback settings resource." + ) + _settings = GameSettingsResource.new() if is_instance_valid(Globals): Globals.settings = _settings @@ -81,7 +86,7 @@ func _ready() -> void: # --- Speed UI Setup --- _speed_bar_style = StyleBoxFlat.new() set_bar_fill_style(speed_bar, _speed_bar_style) - speed_bar.max_value = _settings.max_speed # Pull directly from resource! + speed_bar.max_value = _settings.max_speed # Pull directly from resource! _speed_state = { "label": speed_label, @@ -227,10 +232,10 @@ func update_fuel_bar() -> void: func update_speed_bar() -> void: if not is_instance_valid(_settings): return - + speed_bar.value = _current_speed var factor: float = 0.0 - + # Dynamically calculate thresholds from the Resource var max_s: float = _settings.max_speed var min_s: float = _settings.min_speed @@ -240,24 +245,18 @@ func update_speed_bar() -> void: var low_red_thresh: float = min_s if _current_speed >= high_red_thresh: - factor = clamp( - (_current_speed - high_red_thresh) / (max_s - high_red_thresh), 0.0, 1.0 - ) + factor = clamp((_current_speed - high_red_thresh) / (max_s - high_red_thresh), 0.0, 1.0) _speed_bar_style.bg_color = Color.YELLOW.lerp(DARK_RED, factor) elif _current_speed >= high_yellow_thresh: factor = clamp( - (_current_speed - high_yellow_thresh) / (high_red_thresh - high_yellow_thresh), - 0.0, - 1.0 + (_current_speed - high_yellow_thresh) / (high_red_thresh - high_yellow_thresh), 0.0, 1.0 ) _speed_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) elif _current_speed <= low_red_thresh: _speed_bar_style.bg_color = DARK_RED elif _current_speed <= low_yellow_thresh: factor = clamp( - (low_yellow_thresh - _current_speed) / (low_yellow_thresh - low_red_thresh), - 0.0, - 1.0 + (low_yellow_thresh - _current_speed) / (low_yellow_thresh - low_red_thresh), 0.0, 1.0 ) _speed_bar_style.bg_color = Color.GREEN.lerp(Color.YELLOW, factor) else: @@ -291,10 +290,13 @@ func check_fuel_warning() -> void: func check_speed_warning() -> void: if not is_instance_valid(_settings): return - + # Dynamically calculate thresholds from the Resource var high_yellow_thresh: float = _settings.max_speed * _settings.high_yellow_fraction - var low_yellow_thresh: float = _settings.min_speed + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction + var low_yellow_thresh: float = ( + _settings.min_speed + + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction + ) if ( (_current_speed < low_yellow_thresh or _current_speed > high_yellow_thresh) From 7557229ebf3a6c80e6c864d7b18e6489a2cb8c91 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 20:54:51 -0700 Subject: [PATCH 25/36] Update test_player.gd --- test/gdunit4/test_player.gd | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/test/gdunit4/test_player.gd b/test/gdunit4/test_player.gd index d601facda..ffecfdacb 100644 --- a/test/gdunit4/test_player.gd +++ b/test/gdunit4/test_player.gd @@ -246,13 +246,20 @@ func test_speed_blinking_thresholds() -> void: var hud: Panel = main_scene.get_node("PlayerStatsPanel") + # NEW: Calculate thresholds dynamically using the Resource + var max_s: float = Globals.settings.max_speed + var min_s: float = Globals.settings.min_speed + var high_yellow_thresh: float = max_s * Globals.settings.high_yellow_fraction + var high_red_thresh: float = max_s * hud.HIGH_RED_FRACTION + var low_yellow_thresh: float = min_s + (max_s - min_s) * Globals.settings.low_yellow_fraction + # Normal speed: no blink - hud._current_speed = (Globals.settings.min_speed + hud.HIGH_YELLOW_THRESHOLD) / 2.0 + hud._current_speed = (Globals.settings.min_speed + high_yellow_thresh) / 2.0 hud.check_speed_warning() assert_bool(hud._speed_state["blinking"]).is_false() # Low yellow: start blink - hud._current_speed = hud.LOW_YELLOW_THRESHOLD - 10.0 + hud._current_speed = low_yellow_thresh - 10.0 hud.check_speed_warning() assert_bool(hud._speed_state["blinking"]).is_true() @@ -262,22 +269,22 @@ func test_speed_blinking_thresholds() -> void: assert_bool(hud._speed_state["blinking"]).is_true() # Back to normal: stop blink - hud._current_speed = (hud.LOW_YELLOW_THRESHOLD + hud.HIGH_YELLOW_THRESHOLD) / 2.0 + hud._current_speed = (low_yellow_thresh + high_yellow_thresh) / 2.0 hud.check_speed_warning() assert_bool(hud._speed_state["blinking"]).is_false() # High yellow: start blink - hud._current_speed = hud.HIGH_YELLOW_THRESHOLD + 10.0 + hud._current_speed = high_yellow_thresh + 10.0 hud.check_speed_warning() assert_bool(hud._speed_state["blinking"]).is_true() # High red: remains blinking - hud._current_speed = hud.HIGH_RED_THRESHOLD + 10.0 + hud._current_speed = high_red_thresh + 10.0 hud.check_speed_warning() assert_bool(hud._speed_state["blinking"]).is_true() # Back to normal: stop blink - hud._current_speed = (hud.LOW_YELLOW_THRESHOLD + hud.HIGH_YELLOW_THRESHOLD) / 2.0 + hud._current_speed = (low_yellow_thresh + high_yellow_thresh) / 2.0 hud.check_speed_warning() assert_bool(hud._speed_state["blinking"]).is_false() @@ -337,15 +344,19 @@ func test_speed_colors() -> void: var hud: Panel = main_scene.get_node("PlayerStatsPanel") var speed_bar: ProgressBar = hud.speed_bar + # NEW: Calculate thresholds dynamically using the Resource + var max_s: float = Globals.settings.max_speed + var min_s: float = Globals.settings.min_speed + # Normal (green) - derive mid-safe speed - hud._current_speed = (Globals.settings.min_speed + Globals.settings.max_speed) / 2.0 + hud._current_speed = (min_s + max_s) / 2.0 hud.update_speed_bar() var style: StyleBoxFlat = speed_bar.get_theme_stylebox("fill").duplicate() assert_that(style.bg_color).is_equal(Color.GREEN) # Approaching high (yellow lerp) - var high_yellow: float = Globals.settings.max_speed * hud.HIGH_YELLOW_FRACTION - var high_red: float = Globals.settings.max_speed * hud.HIGH_RED_FRACTION + var high_yellow: float = max_s * Globals.settings.high_yellow_fraction + var high_red: float = max_s * hud.HIGH_RED_FRACTION var mid_high_yellow: float = high_yellow + (high_red - high_yellow) / 2.0 hud._current_speed = mid_high_yellow hud.update_speed_bar() @@ -353,15 +364,15 @@ func test_speed_colors() -> void: assert_bool(style.bg_color.is_equal_approx(Color.GREEN.lerp(Color.YELLOW, 0.5))).is_true() # Overspeed (red lerp) - var mid_high_red: float = high_red + (Globals.settings.max_speed - high_red) / 2.0 + var mid_high_red: float = high_red + (max_s - high_red) / 2.0 hud._current_speed = mid_high_red hud.update_speed_bar() style = speed_bar.get_theme_stylebox("fill").duplicate() assert_bool(style.bg_color.is_equal_approx(Color.YELLOW.lerp(hud.DARK_RED, 0.5))).is_true() # Approaching low (yellow lerp) - var low_yellow: float = Globals.settings.min_speed + (Globals.settings.max_speed - Globals.settings.min_speed) * hud.LOW_YELLOW_FRACTION - var low_red: float = Globals.settings.min_speed + var low_yellow: float = min_s + (max_s - min_s) * Globals.settings.low_yellow_fraction + var low_red: float = min_s var mid_low_yellow: float = low_yellow - (low_yellow - low_red) / 2.0 hud._current_speed = mid_low_yellow hud.update_speed_bar() @@ -369,7 +380,7 @@ func test_speed_colors() -> void: assert_bool(style.bg_color.is_equal_approx(Color.GREEN.lerp(Color.YELLOW, 0.5))).is_true() # Low red at min - hud._current_speed = Globals.settings.min_speed + hud._current_speed = min_s hud.update_speed_bar() style = speed_bar.get_theme_stylebox("fill").duplicate() assert_that(style.bg_color).is_equal(hud.DARK_RED) From 5d8f016f3da5f600524fc07e4ea605abe7159010 Mon Sep 17 00:00:00 2001 From: Egor Kostan <20955183+ikostan@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:59:15 -0700 Subject: [PATCH 26/36] Update scripts/game_settings_resource.gd Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- scripts/game_settings_resource.gd | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index 6cf6d7ff9..b47e4f550 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -51,29 +51,32 @@ signal fuel_depleted get: return _min_speed -@export var lateral_speed: float = 250.0: +`@export` var lateral_speed: float = 250.0: set(value): - if _lateral_speed == value: + var new_val: float = max(0.0, value) + if _lateral_speed == new_val: return - _lateral_speed = value + _lateral_speed = new_val setting_changed.emit("lateral_speed", _lateral_speed) get: return _lateral_speed -@export var acceleration: float = 200.0: +`@export` var acceleration: float = 200.0: set(value): - if _acceleration == value: + var new_val: float = max(0.0, value) + if _acceleration == new_val: return - _acceleration = value + _acceleration = new_val setting_changed.emit("acceleration", _acceleration) get: return _acceleration -@export var deceleration: float = 100.0: +`@export` var deceleration: float = 100.0: set(value): - if _deceleration == value: + var new_val: float = max(0.0, value) + if _deceleration == new_val: return - _deceleration = value + _deceleration = new_val setting_changed.emit("deceleration", _deceleration) get: return _deceleration From 4e289dcf4f251b182f0875efa8f9419d3d00bbf9 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:00:42 -0700 Subject: [PATCH 27/36] Update game_settings_resource.gd --- scripts/game_settings_resource.gd | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index b47e4f550..00e7fe3f5 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -51,7 +51,7 @@ signal fuel_depleted get: return _min_speed -`@export` var lateral_speed: float = 250.0: +@export var lateral_speed: float = 250.0: set(value): var new_val: float = max(0.0, value) if _lateral_speed == new_val: @@ -61,7 +61,7 @@ signal fuel_depleted get: return _lateral_speed -`@export` var acceleration: float = 200.0: +@export var acceleration: float = 200.0: set(value): var new_val: float = max(0.0, value) if _acceleration == new_val: @@ -71,7 +71,7 @@ signal fuel_depleted get: return _acceleration -`@export` var deceleration: float = 100.0: +@export var deceleration: float = 100.0: set(value): var new_val: float = max(0.0, value) if _deceleration == new_val: From 0746137139dcc4dab9985909cdecfac1f01d2334 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:14:09 -0700 Subject: [PATCH 28/36] Update hud.gd While Godot 4.x does a fantastic job of automatically cleaning up signal connections when a node is completely destroyed, CodeRabbit is raising a valid architectural concern: if the player respawns or the Player node is hot-swapped while the PlayerStatsPanel remains active in the scene tree, the HUD will hold onto a dangling reference and try to listen to a ghost. To resolve this, we need to cache a reference to the player when setup_hud is called, and explicitly sever that connection in _exit_tree(). --- scripts/hud.gd | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/hud.gd b/scripts/hud.gd index e66996a3d..c0052971a 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -23,6 +23,7 @@ var _speed_state: Dictionary = {} var _fuel_bar_style: StyleBoxFlat var _speed_bar_style: StyleBoxFlat +var _connected_player: Node2D = null # NEW: Track the player for clean disconnects # --- Node References --- # Paths assume this script is attached directly to "PlayerStatsPanel" @@ -117,9 +118,16 @@ func setup_hud(player_node: Node2D) -> void: push_error("HUD setup failed: Invalid player node.") return + # NEW FIX: Safely disconnect the old player if we are hot-swapping nodes + if is_instance_valid(_connected_player) and _connected_player != player_node: + if _connected_player.speed_changed.is_connected(_on_player_speed_changed): + _connected_player.speed_changed.disconnect(_on_player_speed_changed) + + _connected_player = player_node + # Connection guard for external wiring - if not player_node.speed_changed.is_connected(_on_player_speed_changed): - player_node.speed_changed.connect(_on_player_speed_changed) + if not _connected_player.speed_changed.is_connected(_on_player_speed_changed): + _connected_player.speed_changed.connect(_on_player_speed_changed) Globals.log_message("HUD successfully wired to Player signals.", Globals.LogLevel.DEBUG) @@ -128,6 +136,11 @@ func setup_hud(player_node: Node2D) -> void: ## Safely disconnects global resource signals to prevent memory leaks. ## @return: void func _exit_tree() -> void: + # NEW FIX: Explicitly sever the connection to the player + if is_instance_valid(_connected_player): + if _connected_player.speed_changed.is_connected(_on_player_speed_changed): + _connected_player.speed_changed.disconnect(_on_player_speed_changed) + if is_instance_valid(_settings): if _settings.setting_changed.is_connected(_on_setting_changed): _settings.setting_changed.disconnect(_on_setting_changed) From 6cc9a94e145057e4ac36699b9c633e8b1c88d0f2 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:18:13 -0700 Subject: [PATCH 29/36] issue (bug_risk): high_yellow_fraction and low_yellow_fraction can be configured into an inverted or overlapping range, which leads to ambiguous threshold logic in the HUD. issue (bug_risk): high_yellow_fraction and low_yellow_fraction can be configured into an inverted or overlapping range, which leads to ambiguous threshold logic in the HUD. Both values are clamped to [0, 1], but nothing enforces low_yellow_fraction <= high_yellow_fraction. If low is set above high, the thresholds in hud.gd (high_yellow_thresh, low_yellow_thresh) can invert or collapse, making the yellow zone behavior unpredictable. Consider enforcing the ordering (e.g., each setter clamps against the other field or normalizes the pair so low <= high) to avoid these visual glitches when changed from the inspector. --- scripts/game_settings_resource.gd | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index 00e7fe3f5..994fa763f 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -87,13 +87,20 @@ signal fuel_depleted if _high_yellow_fraction == new_val: return _high_yellow_fraction = new_val + + # NEW FIX: Enforce invariant - push low_yellow down if high_yellow drops below it + if _low_yellow_fraction > _high_yellow_fraction: + self.low_yellow_fraction = _high_yellow_fraction + setting_changed.emit("high_yellow_fraction", _high_yellow_fraction) get: return _high_yellow_fraction @export var low_yellow_fraction: float = 0.10: set(value): - var new_val: float = clamp(value, 0.0, 1.0) + # NEW FIX: Clamp new low_yellow so it cannot exceed the current high_yellow + var new_val: float = clamp(value, 0.0, _high_yellow_fraction) + if _low_yellow_fraction == new_val: return _low_yellow_fraction = new_val From 3ee148e0360cd6d2f52baea170d2bc6ec31e0cd7 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:24:21 -0700 Subject: [PATCH 30/36] issue (bug_risk): setup_hud assumes the player_node has a speed_changed signal but the signature is only Node2D, which can lead to runtime errors if miswired. issue (bug_risk): setup_hud assumes the player_node has a speed_changed signal but the signature is only Node2D, which can lead to runtime errors if miswired. Because setup_hud takes a generic Node2D but immediately accesses player_node.speed_changed, passing the wrong node type (or later renaming/removing the signal) will cause a runtime error. To make this safer, either narrow the parameter type to your concrete Player script that defines speed_changed, or check player_node.has_signal("speed_changed") and report a clear error instead of crashing. --- scripts/hud.gd | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/hud.gd b/scripts/hud.gd index c0052971a..1bc81e937 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -118,7 +118,12 @@ func setup_hud(player_node: Node2D) -> void: push_error("HUD setup failed: Invalid player node.") return - # NEW FIX: Safely disconnect the old player if we are hot-swapping nodes + # NEW FIX: Verify the signal actually exists before attempting to access it! + if not player_node.has_signal("speed_changed"): + push_error("HUD setup failed: Provided node lacks 'speed_changed' signal.") + return + + # Safely disconnect the old player if we are hot-swapping nodes if is_instance_valid(_connected_player) and _connected_player != player_node: if _connected_player.speed_changed.is_connected(_on_player_speed_changed): _connected_player.speed_changed.disconnect(_on_player_speed_changed) From 910d718d10108c7900ca70e2bfda988c8b0aeb0f Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:27:52 -0700 Subject: [PATCH 31/36] Release simulated actions before erasing them. Release simulated actions before erasing them. If speed_up / speed_down were added in before_each(), erasing them first can make the later Input.action_release(...) calls hit undefined actions during cleanup. Release first, then remove any temporary InputMap entries. --- test/gut/test_player_movement_signals.gd | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/gut/test_player_movement_signals.gd b/test/gut/test_player_movement_signals.gd index bb02b3b5a..e63d88a0e 100644 --- a/test/gut/test_player_movement_signals.gd +++ b/test/gut/test_player_movement_signals.gd @@ -32,14 +32,15 @@ func before_each() -> void: ## Per-test cleanup. ## :rtype: void func after_each() -> void: + # Force-release simulated inputs to prevent test leakage + Input.action_release("speed_up") + Input.action_release("speed_down") + Globals.settings = _original_settings for action: String in _added_actions: InputMap.erase_action(action) _added_actions.clear() - - # Force-release simulated inputs to prevent test leakage - Input.action_release("speed_up") - Input.action_release("speed_down") + ## test_physics_emits_speed_changed_on_acceleration | Signal Behavior ## :rtype: void From 6d0f630176ef9bc11723841ec190c2272d64b8ed Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:31:09 -0700 Subject: [PATCH 32/36] This flameout assertion is currently ambiguous. This flameout assertion is currently ambiguous. Globals.settings.current_fuel = 0.0 already emits fuel_depleted, and scripts/player.gd handles that by calling _on_player_out_of_fuel() through the live connection. That means assert_signal_emitted() can succeed before the explicit manual call on Line 88, so the test does not isolate the handler it is naming. --- test/gut/test_player_movement_signals.gd | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/gut/test_player_movement_signals.gd b/test/gut/test_player_movement_signals.gd index e63d88a0e..7e28b7b84 100644 --- a/test/gut/test_player_movement_signals.gd +++ b/test/gut/test_player_movement_signals.gd @@ -82,8 +82,10 @@ func test_flameout_resets_speed_and_emits_signal() -> void: _player.speed["speed"] = 300.0 - # Actually empty the mock fuel tank so _set_speed() allows a 0.0 value! - Globals.settings.current_fuel = 0.0 + # NEW FIX: Use the private backing field `_current_fuel` to bypass the public setter. + # This sets up the empty tank condition without automatically triggering the fuel_depleted signal, + # ensuring our manual call below is actually what we are testing! + Globals.settings._current_fuel = 0.0 # Manually trigger the flameout handler _player._on_player_out_of_fuel() From efecc657746a19aa3ede5676e9235d2362e412e8 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:42:08 -0700 Subject: [PATCH 33/36] suggestion (bug_risk): HUD does not react to speed-related setting changes, so speed UI may be stale until the next speed signal. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit suggestion (bug_risk): HUD does not react to speed-related setting changes, so speed UI may be stale until the next speed signal. _on_setting_changed currently only responds to fuel-related properties. If max_speed, min_speed, or the yellow/red fractions are changed while speed is constant, the HUD thresholds and speed_bar.max_value won’t update until the next speed_changed signal. If runtime tuning is intended, consider handling speed-related settings here as well so the HUD updates immediately even when speed doesn’t change. --- scripts/hud.gd | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/hud.gd b/scripts/hud.gd index 1bc81e937..b3999cd2d 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -177,6 +177,7 @@ func _on_setting_changed(setting_name: String, _new_value: Variant) -> void: if not is_instance_valid(_settings): return + # --- Handle Fuel Updates --- if ( setting_name in [ @@ -194,6 +195,15 @@ func _on_setting_changed(setting_name: String, _new_value: Variant) -> void: update_fuel_bar() check_fuel_warning() + # --- Handle Speed Updates --- + # NEW FIX: React immediately to dynamic threshold or speed limit changes + elif setting_name in ["max_speed", "min_speed", "high_yellow_fraction", "low_yellow_fraction"]: + if setting_name == "max_speed": + speed_bar.max_value = _settings.max_speed + + update_speed_bar() + check_speed_warning() + ## Signal handler for global engine failure. ## Triggers immediate UI feedback for a flameout state. From c69e1fc8e1b68ef84c8fb990c02c9244f9ff9894 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:53:44 -0700 Subject: [PATCH 34/36] Expose HUD accessors and update tests Add several public accessor methods to scripts/hud.gd (get_current_speed, get_settings, get_fuel_bar_color, get_speed_bar_color, is_fuel_warning_active, is_speed_warning_active, is_speed_timer_running) to allow safe external inspection of HUD state for testing and encapsulation. Refactor test/gut/test_hud.gd to use these new accessors and to drive HUD behavior via public interfaces: emit Player speed_changed signals and modify Globals.settings for fuel changes instead of poking private HUD fields or calling internal methods. Tests now verify visual colors and blinker/timer state through the public API, improving encapsulation and making tests more realistic. --- scripts/hud.gd | 52 ++++++++++++++++++++++++++ test/gut/test_hud.gd | 87 +++++++++++++++++--------------------------- 2 files changed, 85 insertions(+), 54 deletions(-) diff --git a/scripts/hud.gd b/scripts/hud.gd index b3999cd2d..79583eb61 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -416,3 +416,55 @@ func set_bar_fill_style(bar: ProgressBar, bar_fill_style: StyleBoxFlat) -> void: bar_fill_style.corner_radius_bottom_right = corner_radius bar_fill_style.corner_radius_top_right = corner_radius bar.add_theme_stylebox_override("fill", bar_fill_style) + +# ========================================== +# PUBLIC ACCESSORS (TESTING & EXTERNAL QUERY) +# ========================================== + +## Retrieves the current forward speed cached by the HUD. +## @return: float - The player's current speed value. +func get_current_speed() -> float: + return _current_speed + + +## Retrieves the active game settings resource driving the HUD's logic. +## @return: GameSettingsResource - The global settings data container. +func get_settings() -> GameSettingsResource: + return _settings + + +## Retrieves the current computed background color of the fuel progress bar. +## Useful for verifying threshold lerping logic in unit tests. +## @return: Color - The current StyleBoxFlat background color, or Color.TRANSPARENT if uninitialized. +func get_fuel_bar_color() -> Color: + if _fuel_bar_style: + return _fuel_bar_style.bg_color + return Color.TRANSPARENT + + +## Retrieves the current computed background color of the speed progress bar. +## Useful for verifying threshold lerping logic in unit tests. +## @return: Color - The current StyleBoxFlat background color, or Color.TRANSPARENT if uninitialized. +func get_speed_bar_color() -> Color: + if _speed_bar_style: + return _speed_bar_style.bg_color + return Color.TRANSPARENT + + +## Checks if the fuel warning label is currently in a blinking state. +## @return: bool - True if the fuel warning is active and blinking, false otherwise. +func is_fuel_warning_active() -> bool: + return _fuel_state.get("blinking", false) + + +## Checks if the speed warning label is currently in a blinking state. +## @return: bool - True if the speed warning is active and blinking, false otherwise. +func is_speed_warning_active() -> bool: + return _speed_state.get("blinking", false) + + +## Verifies if the underlying SceneTree Timer for the speed blinker is actively running. +## @return: bool - True if the timer node is valid and not stopped, false otherwise. +func is_speed_timer_running() -> bool: + var timer: Timer = _speed_state.get("timer") + return is_instance_valid(timer) and not timer.is_stopped() diff --git a/test/gut/test_hud.gd b/test/gut/test_hud.gd index 0bfd1a699..a237ff0a9 100644 --- a/test/gut/test_hud.gd +++ b/test/gut/test_hud.gd @@ -41,8 +41,6 @@ func after_each() -> void: # ========================================== ## test_initialization_with_missing_globals | Edge Case -## Verifies that the HUD safely creates a fallback resource if Globals.settings is missing. -## :rtype: void func test_initialization_with_missing_globals() -> void: gut.p("Testing: HUD creates a fallback GameSettingsResource if Globals is null.") @@ -52,7 +50,7 @@ func test_initialization_with_missing_globals() -> void: # Manually trigger _ready to force the HUD to re-evaluate its state _hud._ready() - assert_not_null(_hud._settings, "HUD must instantiate a fallback GameSettingsResource.") + assert_not_null(_hud.get_settings(), "HUD must instantiate a fallback GameSettingsResource.") assert_not_null(Globals.settings, "HUD must assign the fallback resource back to Globals.") @@ -61,8 +59,6 @@ func test_initialization_with_missing_globals() -> void: # ========================================== ## test_fuel_bar_visual_states | UI Rendering -## Validates that the fuel bar correctly translates resource thresholds into the proper StyleBox colors. -## :rtype: void func test_fuel_bar_visual_states() -> void: gut.p("Testing: Fuel bar properly applies solid and lerped colors based on thresholds.") @@ -70,29 +66,25 @@ func test_fuel_bar_visual_states() -> void: # --- 1. Safe Zone (Solid Green) --- Globals.settings.current_fuel = max_f * 0.95 - _hud.update_fuel_bar() - assert_eq(_hud._fuel_bar_style.bg_color, Color.GREEN, "High fuel must be solid Green.") + assert_eq(_hud.get_fuel_bar_color(), Color.GREEN, "High fuel must be solid Green.") # --- 2. Medium Warning (Green to Yellow Lerp) --- var mid_yellow: float = (Globals.settings.high_fuel_threshold + Globals.settings.medium_fuel_threshold) / 2.0 Globals.settings.current_fuel = (mid_yellow / 100.0) * max_f - _hud.update_fuel_bar() var expected_yellow_lerp: Color = Color.GREEN.lerp(Color.YELLOW, 0.5) - assert_true(_hud._fuel_bar_style.bg_color.is_equal_approx(expected_yellow_lerp), "Medium fuel must lerp towards Yellow.") + assert_true(_hud.get_fuel_bar_color().is_equal_approx(expected_yellow_lerp), "Medium fuel must lerp towards Yellow.") # --- 3. Low Warning (Yellow to Red Lerp) --- var mid_red: float = (Globals.settings.medium_fuel_threshold + Globals.settings.low_fuel_threshold) / 2.0 Globals.settings.current_fuel = (mid_red / 100.0) * max_f - _hud.update_fuel_bar() var expected_red_lerp: Color = Color.YELLOW.lerp(Color.RED, 0.5) - assert_true(_hud._fuel_bar_style.bg_color.is_equal_approx(expected_red_lerp), "Low fuel must lerp towards Red.") + assert_true(_hud.get_fuel_bar_color().is_equal_approx(expected_red_lerp), "Low fuel must lerp towards Red.") # --- 4. Critical Zone (Red to Dark Red Lerp) --- var mid_dark: float = (Globals.settings.low_fuel_threshold + Globals.settings.no_fuel_threshold) / 2.0 Globals.settings.current_fuel = (mid_dark / 100.0) * max_f - _hud.update_fuel_bar() var expected_dark_lerp: Color = Color.RED.lerp(_hud.DARK_RED, 0.5) - assert_true(_hud._fuel_bar_style.bg_color.is_equal_approx(expected_dark_lerp), "Critical fuel must lerp towards Dark Red.") + assert_true(_hud.get_fuel_bar_color().is_equal_approx(expected_dark_lerp), "Critical fuel must lerp towards Dark Red.") # ========================================== @@ -100,8 +92,6 @@ func test_fuel_bar_visual_states() -> void: # ========================================== ## test_speed_bar_visual_states | UI Rendering -## Validates that the speed bar correctly applies colors based on dynamic resource thresholds. -## :rtype: void func test_speed_bar_visual_states() -> void: gut.p("Testing: Speed bar properly applies solid and lerped colors based on dynamic thresholds.") @@ -114,26 +104,25 @@ func test_speed_bar_visual_states() -> void: var low_yellow_thresh: float = min_s + (max_s - min_s) * Globals.settings.low_yellow_fraction # --- 1. Safe Zone (Solid Green) --- - _hud._current_speed = (low_yellow_thresh + high_yellow_thresh) / 2.0 - _hud.update_speed_bar() - assert_eq(_hud._speed_bar_style.bg_color, Color.GREEN, "Cruising speed must be solid Green.") + var safe_speed: float = (low_yellow_thresh + high_yellow_thresh) / 2.0 + _player.speed_changed.emit(safe_speed, max_s) + assert_eq(_hud.get_speed_bar_color(), Color.GREEN, "Cruising speed must be solid Green.") # --- 2. High Speed Warning (Green to Yellow Lerp) --- - _hud._current_speed = high_yellow_thresh + ((high_red_thresh - high_yellow_thresh) / 2.0) - _hud.update_speed_bar() + var high_speed: float = high_yellow_thresh + ((high_red_thresh - high_yellow_thresh) / 2.0) + _player.speed_changed.emit(high_speed, max_s) var expected_yellow: Color = Color.GREEN.lerp(Color.YELLOW, 0.5) - assert_true(_hud._speed_bar_style.bg_color.is_equal_approx(expected_yellow), "High speed must lerp towards Yellow.") + assert_true(_hud.get_speed_bar_color().is_equal_approx(expected_yellow), "High speed must lerp towards Yellow.") # --- 3. Overspeed Critical (Yellow to Dark Red Lerp) --- - _hud._current_speed = high_red_thresh + ((max_s - high_red_thresh) / 2.0) - _hud.update_speed_bar() + var overspeed: float = high_red_thresh + ((max_s - high_red_thresh) / 2.0) + _player.speed_changed.emit(overspeed, max_s) var expected_dark: Color = Color.YELLOW.lerp(_hud.DARK_RED, 0.5) - assert_true(_hud._speed_bar_style.bg_color.is_equal_approx(expected_dark), "Overspeed must lerp towards Dark Red.") + assert_true(_hud.get_speed_bar_color().is_equal_approx(expected_dark), "Overspeed must lerp towards Dark Red.") # --- 4. Stall Critical (Solid Dark Red) --- - _hud._current_speed = min_s - _hud.update_speed_bar() - assert_eq(_hud._speed_bar_style.bg_color, _hud.DARK_RED, "Stall speed must be solid Dark Red.") + _player.speed_changed.emit(min_s, max_s) + assert_eq(_hud.get_speed_bar_color(), _hud.DARK_RED, "Stall speed must be solid Dark Red.") # ========================================== @@ -141,8 +130,6 @@ func test_speed_bar_visual_states() -> void: # ========================================== ## test_warning_blinkers_activate_and_deactivate | State Management -## Ensures warning timers start and stop correctly when thresholds are crossed. -## :rtype: void func test_warning_blinkers_activate_and_deactivate() -> void: gut.p("Testing: Warning labels start and stop blinking seamlessly across thresholds.") @@ -150,28 +137,24 @@ func test_warning_blinkers_activate_and_deactivate() -> void: var safe_speed: float = (Globals.settings.max_speed + Globals.settings.min_speed) / 2.0 var danger_speed: float = Globals.settings.max_speed * 0.95 - # 1. Enter danger zone - _hud._current_speed = danger_speed - _hud.check_speed_warning() - assert_true(_hud._speed_state["blinking"], "Speed blinker must activate in the danger zone.") - assert_false(_hud._speed_state["timer"].is_stopped(), "Speed blink timer must be running.") + # 1. Enter danger zone via simulated Player emission + _player.speed_changed.emit(danger_speed, Globals.settings.max_speed) + assert_true(_hud.is_speed_warning_active(), "Speed blinker must activate in the danger zone.") + assert_true(_hud.is_speed_timer_running(), "Speed blink timer must be running.") # 2. Return to safe zone - _hud._current_speed = safe_speed - _hud.check_speed_warning() - assert_false(_hud._speed_state["blinking"], "Speed blinker must deactivate in the safe zone.") - assert_true(_hud._speed_state["timer"].is_stopped(), "Speed blink timer must halt.") + _player.speed_changed.emit(safe_speed, Globals.settings.max_speed) + assert_false(_hud.is_speed_warning_active(), "Speed blinker must deactivate in the safe zone.") + assert_false(_hud.is_speed_timer_running(), "Speed blink timer must halt.") # --- Fuel Blinker Test --- - # 1. Enter danger zone + # 1. Enter danger zone via Resource update Globals.settings.current_fuel = (Globals.settings.low_fuel_threshold - 5.0) / 100.0 * Globals.settings.max_fuel - _hud.check_fuel_warning() - assert_true(_hud._fuel_state["blinking"], "Fuel blinker must activate in the low fuel zone.") + assert_true(_hud.is_fuel_warning_active(), "Fuel blinker must activate in the low fuel zone.") # 2. Return to safe zone Globals.settings.current_fuel = Globals.settings.max_fuel - _hud.check_fuel_warning() - assert_false(_hud._fuel_state["blinking"], "Fuel blinker must deactivate when refueled.") + assert_false(_hud.is_fuel_warning_active(), "Fuel blinker must deactivate when refueled.") # ========================================== @@ -179,29 +162,25 @@ func test_warning_blinkers_activate_and_deactivate() -> void: # ========================================== ## test_hud_reacts_to_player_signals | Observer Integration -## Verifies that external signals from the Player dictate the UI's state. -## :rtype: void func test_hud_reacts_to_player_signals() -> void: gut.p("Testing: HUD correctly processes speed_changed signals from the Player.") - # Simulate the Player broadcasting a new speed - _hud._on_player_speed_changed(400.0, 800.0) + # Simulate the Player broadcasting a new speed natively + _player.speed_changed.emit(400.0, 800.0) - assert_eq(_hud._current_speed, 400.0, "HUD must internally cache the new speed.") + assert_eq(_hud.get_current_speed(), 400.0, "HUD must internally cache the new speed.") assert_eq(_hud.speed_bar.max_value, 800.0, "HUD must update the progress bar maximum.") assert_eq(_hud.speed_bar.value, 400.0, "HUD must update the progress bar value.") ## test_hud_reacts_to_flameout_signal | Observer Integration -## Verifies that the HUD immediately processes engine failure. -## :rtype: void func test_hud_reacts_to_flameout_signal() -> void: gut.p("Testing: HUD forces speed to 0.0 upon receiving a fuel_depleted signal.") # Establish a cruising speed - _hud._current_speed = 300.0 + _player.speed_changed.emit(300.0, Globals.settings.max_speed) - # Broadcast flameout - _hud._on_player_out_of_fuel() + # Broadcast flameout globally + Globals.settings.current_fuel = 0.0 - assert_eq(_hud._current_speed, 0.0, "HUD must recognize that a flameout instantly zeroes the speed.") + assert_eq(_hud.get_current_speed(), 0.0, "HUD must recognize that a flameout instantly zeroes the speed.") assert_eq(_hud.speed_bar.value, 0.0, "Progress bar must visually drop to zero.") From d6f09f5590be40d644a4c4d1ef5745e594aca076 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:54:30 -0700 Subject: [PATCH 35/36] Update hud.gd --- scripts/hud.gd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/hud.gd b/scripts/hud.gd index 79583eb61..210aa77eb 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -417,10 +417,12 @@ func set_bar_fill_style(bar: ProgressBar, bar_fill_style: StyleBoxFlat) -> void: bar_fill_style.corner_radius_top_right = corner_radius bar.add_theme_stylebox_override("fill", bar_fill_style) + # ========================================== # PUBLIC ACCESSORS (TESTING & EXTERNAL QUERY) # ========================================== + ## Retrieves the current forward speed cached by the HUD. ## @return: float - The player's current speed value. func get_current_speed() -> float: From 27dff10f34fb0988924b066c854a123fc9320ec3 Mon Sep 17 00:00:00 2001 From: Egor Kostan Date: Mon, 13 Apr 2026 21:56:03 -0700 Subject: [PATCH 36/36] Update hud.gd --- scripts/hud.gd | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/hud.gd b/scripts/hud.gd index 210aa77eb..585ea976d 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -437,7 +437,8 @@ func get_settings() -> GameSettingsResource: ## Retrieves the current computed background color of the fuel progress bar. ## Useful for verifying threshold lerping logic in unit tests. -## @return: Color - The current StyleBoxFlat background color, or Color.TRANSPARENT if uninitialized. +## @return: Color - The current StyleBoxFlat background color, or Color. +## TRANSPARENT if uninitialized. func get_fuel_bar_color() -> Color: if _fuel_bar_style: return _fuel_bar_style.bg_color @@ -446,7 +447,8 @@ func get_fuel_bar_color() -> Color: ## Retrieves the current computed background color of the speed progress bar. ## Useful for verifying threshold lerping logic in unit tests. -## @return: Color - The current StyleBoxFlat background color, or Color.TRANSPARENT if uninitialized. +## @return: Color - The current StyleBoxFlat background color, or Color. +## TRANSPARENT if uninitialized. func get_speed_bar_color() -> Color: if _speed_bar_style: return _speed_bar_style.bg_color