diff --git a/scenes/main_scene.tscn b/scenes/main_scene.tscn index 6fb7610f4..888ca363d 100644 --- a/scenes/main_scene.tscn +++ b/scenes/main_scene.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=82 format=3 uid="uid://nnnc0qhx07i8"] +[gd_scene load_steps=83 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"] @@ -77,6 +77,7 @@ [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"] +[ext_resource type="Script" uid="uid://b5x2ehdthhrla" path="res://scripts/parallax_manager.gd" id="76_qj6t7"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pu3yx"] bg_color = Color(0.2627451, 0.2627451, 0.2627451, 0.5882353) @@ -204,6 +205,7 @@ step = 1.0 [node name="PauseMenu" parent="." instance=ExtResource("1_w2twt")] [node name="Background" type="ParallaxBackground" parent="."] +script = ExtResource("76_qj6t7") [node name="Sand" type="ParallaxLayer" parent="Background"] diff --git a/scripts/main_scene.gd b/scripts/main_scene.gd index 13afd0cbf..041c4e3a7 100644 --- a/scripts/main_scene.gd +++ b/scripts/main_scene.gd @@ -1,4 +1,4 @@ -## Copyright (C) 2025 Egor Kostan +## Copyright (C) 2026 Egor Kostan ## SPDX-License-Identifier: GPL-3.0-or-later ## main_scene.gd ## Main scene script for SkyLockAssault. @@ -52,6 +52,67 @@ func _ready() -> void: # Setup decor layer with random instances setup_decor_layer(viewport_size) + # ========================================================= + # DEPENDENCY INJECTION: Parallax Background + # ========================================================= + # Safely extract settings once to use for both injection and priming. + # The is_instance_valid(Globals) guard prevents hard crashes during + # isolated GUT tests where Autoloads may not be fully initialized. + # Also guard against a null or freed Globals.settings so background.setup + # only ever receives a valid GameSettingsResource or null. + var settings_res: GameSettingsResource = ( + Globals.settings + if ( + is_instance_valid(Globals) + and Globals.settings != null + and is_instance_valid(Globals.settings) + ) + else null + ) + + if background.has_method("setup"): + background.setup(settings_res) + else: + push_warning( + "Parallax background is missing the `setup` method. Settings injection failed." + ) + + # Wire up the signal architecture for the parallax background + if player.has_signal("speed_changed") and background.has_method("update_speed"): + # 1. Guard against duplicate connections + if not player.speed_changed.is_connected(background.update_speed): + player.speed_changed.connect(background.update_speed) + + # 2. Prime the background securely via a public method + if background.has_method("prime_speed"): + background.prime_speed(player.current_speed) + else: + push_warning("Parallax background is missing the `prime_speed` method.") + + elif not player.has_signal("speed_changed"): + push_warning( + ( + "Parallax background not wired: player is missing the `speed_changed` signal. " + + "Verify that the Player node defines and emits `speed_changed`." + ) + ) + elif not background.has_method("update_speed"): + push_warning( + ( + "Parallax background not wired: background is missing" + + " `update_speed` method. " + + "Ensure the background script implements " + + " `update_speed(speed: float, max_speed: float)`." + ) + ) + # ========================================================= + # FLOAT DEGRADATION SAFEGUARD + # ========================================================= + # Delegate wrap period calculation entirely to the ParallaxManager. + # This preserves encapsulation so main_scene doesn't need to know layer specifics. + if background.has_method("auto_calculate_wrap_period"): + background.auto_calculate_wrap_period() + # 2. Detect when player presses a key/button that has NO binding at all # Only significant inputs (axes above deadzone) are checked to prevent @@ -217,23 +278,7 @@ func setup_decor_layer(viewport: Vector2) -> void: decor_layer.motion_mirroring = Vector2(0, layer_height) -func _process(delta: float) -> void: - # NEW: Safely grab the settings resource and guard against null crashes - # during scene transitions, engine shutdown, or isolated GUT tests. - var settings_res: GameSettingsResource = ( - Globals.settings if is_instance_valid(Globals) else null - ) - if not is_instance_valid(settings_res): - return - - # Use the safe local reference for difficulty - var scroll_speed: float = player.speed["speed"] * delta * settings_res.difficulty * 0.8 - background.scroll_offset.y += scroll_speed - - # Use the safe local reference for current_fuel - if settings_res.current_fuel <= 0: - background.scroll_offset = Vector2(0, 0) - +func _process(_delta: float) -> void: # 1. Critical unbound controls warning (shown ONCE per session) # Flag stays true until player fixes bindings (e.g., in key_mapping.gd after remap). # Do NOT reset here — that would make it repeat every 4s (bug fixed). diff --git a/scripts/parallax_manager.gd b/scripts/parallax_manager.gd new file mode 100644 index 000000000..b5494d1f2 --- /dev/null +++ b/scripts/parallax_manager.gd @@ -0,0 +1,163 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## parallax_manager.gd +## Manages the scrolling speed of the parallax background based on player velocity. +## Decoupled via Dependency Injection and the Observer Pattern. + +class_name ParallaxManager +extends ParallaxBackground + +## Base multiplier applied to the final scroll math to scale the speed visually. +const SCROLL_MULTIPLIER: float = 0.8 + +## Optional wrap limit to prevent float32 precision degradation over long sessions. +## Should be a common multiple of the layers' (motion_mirroring.y / motion_scale.y). +@export var wrap_period: float = 0.0 + +var _current_speed: float = 0.0 +var _difficulty: float = 1.0 +var _out_of_fuel: bool = false + + +## Injects the game settings resource and wires up observer signals. +## Prevents tight coupling to global singletons in the process loop. +## @param settings: GameSettingsResource - The configuration resource. +## @return: void +func setup(settings: GameSettingsResource) -> void: + if not is_instance_valid(settings): + return + + _difficulty = settings.difficulty + _out_of_fuel = (settings.current_fuel <= 0.0) + + 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_fuel_depleted): + settings.fuel_depleted.connect(_on_fuel_depleted) + + +## Public method to prime the background's initial speed. +## Keeps private signal handlers properly encapsulated. +## @param initial_speed: float - The starting forward speed. +## @return: void +func prime_speed(initial_speed: float) -> void: + _current_speed = initial_speed + + +## Helper to find the Greatest Common Divisor for LCM calculations. +func _gcd(a: int, b: int) -> int: + while b != 0: + var temp: int = b + b = a % b + a = temp + return a + + +## Helper to find the Least Common Multiple to sync disparate layer periods. +func _lcm(a: int, b: int) -> int: + if a == 0 or b == 0: + return 0 + return absi((a * b) / _gcd(a, b)) + + +## Public method to dynamically calculate the optimal wrap limit +## based on the properties of its ParallaxLayer children. +## Uses the Least Common Multiple (LCM) to ensure non-commensurate layers don't jump. +## IMPORTANT: For flawless wrapping, (motion_mirroring.y / motion_scale.y) MUST result +## in a whole number. Fractional periods will be rounded and may cause visual drift over time. +## Must be called after all layers have had their mirroring and scale set. +## @return: void +func auto_calculate_wrap_period() -> void: + var computed_lcm: int = 1 + var periods: Array[int] = [] + + # 1. Collect all valid layer periods as integers (pixels) + for child in get_children(): + if child is ParallaxLayer: + var layer_scale: float = child.motion_scale.y + var layer_mirror: float = child.motion_mirroring.y + + if layer_scale > 0.0 and layer_mirror > 0.0: + var period: float = layer_mirror / layer_scale + var rounded_period: int = roundi(period) + + # Drift Safeguard: Warn if the period is not a clean integer + if not is_equal_approx(period, float(rounded_period)): + push_warning( + ( + "ParallaxManager: Layer '" + + child.name + + "' has a fractional wrap period (" + + str(period) + + "). Rounding to " + + str(rounded_period) + + " may cause visual drift. " + + "Ensure (motion_mirroring.y / motion_scale.y) yields a whole number." + ) + ) + + periods.append(rounded_period) + + # 2. Calculate the universal LCM of all collected periods + if periods.size() > 0: + computed_lcm = periods[0] + for i in range(1, periods.size()): + computed_lcm = _lcm(computed_lcm, periods[i]) + + # 3. Only overwrite the exported wrap_period if we successfully calculated a valid LCM. + # This protects values set manually via the Godot Inspector. + if computed_lcm > 1: + wrap_period = float(computed_lcm) + + # 4. Warn the developer if the background is scrolling forever with no safeguard + if wrap_period <= 0.0: + push_warning( + ( + "ParallaxManager: No valid wrap limit calculated or set. " + + "Float precision degradation may occur during long sessions." + ) + ) + + +## Public method to update the scrolling speed. +## Designed to be safely connected to external speed_changed signals. +## @param new_speed: float - The new forward speed. +## @param _max_speed: float - The maximum speed threshold (unused, defaults to 0.0). +## @return: void +func update_speed(new_speed: float, _max_speed: float = 0.0) -> void: + _current_speed = new_speed + + +## Observer callback for specific setting updates (difficulty and refueling). +## @param setting_name: String - The name of the changed setting. +## @param new_value: Variant - The updated value. +## @return: void +func _on_setting_changed(setting_name: String, new_value: Variant) -> void: + if setting_name == "difficulty": + _difficulty = float(new_value) + elif setting_name == "current_fuel" and float(new_value) > 0.0: + _out_of_fuel = false + + +## Observer callback to instantly snap the background when fuel runs out. +## @return: void +func _on_fuel_depleted() -> void: + _out_of_fuel = true + scroll_offset = Vector2.ZERO + + +## Called every physics/rendering frame. +## Updates scroll offset based entirely on cached local variables +## and wraps to preserve float precision. +## @param delta: float - The elapsed time since the previous frame. +## @return: void +func _process(delta: float) -> void: + if _out_of_fuel: + scroll_offset = Vector2.ZERO + else: + var scroll_amount: float = _current_speed * delta * _difficulty * SCROLL_MULTIPLIER + scroll_offset.y += scroll_amount + + # Prevent float precision degradation by wrapping modulo the period + if wrap_period > 0.0: + scroll_offset.y = wrapf(scroll_offset.y, 0.0, wrap_period) diff --git a/scripts/parallax_manager.gd.uid b/scripts/parallax_manager.gd.uid new file mode 100644 index 000000000..947ef9ab6 --- /dev/null +++ b/scripts/parallax_manager.gd.uid @@ -0,0 +1 @@ +uid://b5x2ehdthhrla diff --git a/scripts/player.gd b/scripts/player.gd index a8a2fe699..06b35e615 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -27,7 +27,10 @@ var rotor_left_sfx: AudioStreamPlayer2D var rotor_right_sfx: AudioStreamPlayer2D # Local state container for physics -var speed: Dictionary = {"speed": 250.0} +# var speed: Dictionary = {"speed": 250.0} + +## The player's current forward speed. +var current_speed: float = 250.0 # Cache the global settings to avoid singleton lookups in hot paths var _settings: GameSettingsResource = null @@ -136,20 +139,20 @@ func _set_speed(target_speed: float) -> void: if not is_instance_valid(_settings): return - var old_speed: float = speed["speed"] + var old_speed: float = current_speed # Clamp current_speed based on fuel state if _settings.current_fuel == 0: - speed["speed"] = clamp(target_speed, 0.0, _settings.max_speed) + current_speed = clamp(target_speed, 0.0, _settings.max_speed) else: - speed["speed"] = clamp(target_speed, _settings.min_speed, _settings.max_speed) + current_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) + if old_speed != current_speed: + speed_changed.emit(current_speed, _settings.max_speed) # Check for maximum speed limit - if speed["speed"] >= _settings.max_speed: + if current_speed >= _settings.max_speed: speed_maxed.emit() # Check for low speed warning @@ -157,7 +160,7 @@ func _set_speed(target_speed: float) -> void: _settings.min_speed + (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction ) - if speed["speed"] <= low_yellow_thresh: + if current_speed <= low_yellow_thresh: speed_low.emit(low_yellow_thresh) @@ -222,7 +225,7 @@ func _on_fuel_timer_timeout() -> void: if not is_instance_valid(_settings): return - var normalized_speed: float = clamp(speed["speed"] / _settings.max_speed, 0.0, 1.0) + var normalized_speed: float = clamp(current_speed / _settings.max_speed, 0.0, 1.0) var consumption: float = ( _settings.base_consumption_rate * normalized_speed * _settings.difficulty ) @@ -238,7 +241,7 @@ func _physics_process(_delta: float) -> void: if not is_instance_valid(_settings): return - var target_speed: float = speed["speed"] + var target_speed: float = current_speed # Speed changes allowed only if fuel > 0 if Input.is_action_pressed("speed_up") and _settings.current_fuel > 0: @@ -253,7 +256,7 @@ func _physics_process(_delta: float) -> void: # Left/Right movement var lateral_input: float = Input.get_axis("move_left", "move_right") - if lateral_input and _settings.current_fuel > 0 and speed["speed"] > 0: + if lateral_input and _settings.current_fuel > 0 and current_speed > 0: player.velocity.x = lateral_input * _settings.lateral_speed else: player.velocity.x = 0.0 diff --git a/test/gdunit4/test_difficulty.gd b/test/gdunit4/test_difficulty.gd index 1e6dc7201..e9a9d6498 100644 --- a/test/gdunit4/test_difficulty.gd +++ b/test/gdunit4/test_difficulty.gd @@ -49,7 +49,7 @@ func test_fuel_depletion_with_difficulty() -> void: Globals.settings.difficulty = 1.0 # 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 + var normalized_speed: float = player_inst.current_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 @@ -67,7 +67,7 @@ func test_fuel_depletion_with_difficulty() -> void: Globals.settings.difficulty = 2.0 # NEW: Use Globals.settings.max_speed instead of the removed player_inst.MAX_SPEED - normalized_speed = player_inst.speed["speed"] / Globals.settings.max_speed + normalized_speed = player_inst.current_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 @@ -85,7 +85,7 @@ func test_fuel_depletion_with_difficulty() -> void: Globals.settings.difficulty = 0.5 # NEW: Use Globals.settings.max_speed instead of the removed player_inst.MAX_SPEED - normalized_speed = player_inst.speed["speed"] / Globals.settings.max_speed + normalized_speed = player_inst.current_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 603cd01b8..5b2211785 100644 --- a/test/gdunit4/test_difficulty_integration.gd +++ b/test/gdunit4/test_difficulty_integration.gd @@ -52,7 +52,7 @@ func test_difficulty_scales_fuel_and_weapon() -> void: Globals.settings.current_fuel = start_fuel # 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 + var normalized_speed: float = player.current_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_helpers.gd b/test/gdunit4/test_helpers.gd index 67120c37d..b48ca4a6c 100644 --- a/test/gdunit4/test_helpers.gd +++ b/test/gdunit4/test_helpers.gd @@ -9,7 +9,7 @@ extends RefCounted ## Calculates the expected fuel depletion based on the global GameSettingsResource. static func calculate_expected_depletion(player_root: Node, difficulty: float) -> float: # NEW: Use Globals.settings.max_speed instead of player_root.MAX_SPEED - var normalized_speed: float = player_root.speed["speed"] / Globals.settings.max_speed + var normalized_speed: float = player_root.current_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 diff --git a/test/gdunit4/test_player.gd b/test/gdunit4/test_player.gd index ffecfdacb..4f49c0698 100644 --- a/test/gdunit4/test_player.gd +++ b/test/gdunit4/test_player.gd @@ -20,8 +20,6 @@ func after_test() -> void: Globals.settings.difficulty = original_difficulty # Restore after each test -## Tests shared helper calculates depletion correctly. -## @return: void func test_shared_depletion_helper() -> void: var main_scene: Node = auto_free(load("res://scenes/main_scene.tscn").instantiate()) add_child(main_scene) @@ -30,8 +28,8 @@ func test_shared_depletion_helper() -> void: var player_root: Node = main_scene.get_node("Player") Globals.settings.difficulty = 2.0 - # NEW: Use global max_speed - var expected: float = Globals.settings.base_consumption_rate * (player_root.speed["speed"] / Globals.settings.max_speed) * Globals.settings.difficulty + # CHANGED: Use current_speed instead of speed["speed"] + var expected: float = Globals.settings.base_consumption_rate * (player_root.current_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) @@ -305,10 +303,11 @@ func test_movement() -> void: Input.action_release("move_left") # Speed up (no velocity change, just speed var) - var initial_speed: float = player_root.speed["speed"] + # CHANGED: Use current_speed + var initial_speed: float = player_root.current_speed Input.action_press("speed_up") player_root._physics_process(1.0/60.0) - assert_float(player_root.speed["speed"]).is_greater(initial_speed) # Increases speed var + assert_float(player_root.current_speed).is_greater(initial_speed) # Increases speed var assert_vector(body.velocity).is_equal(Vector2(0.0, 0.0)) # No y velocity Input.action_release("speed_up") @@ -400,7 +399,8 @@ func test_fuel_depletion() -> void: assert_float(hud.fuel_bar.value).is_equal(Globals.settings.max_fuel) # Simulate one timer tick - var normalized_speed: float = player_root.speed["speed"] / Globals.settings.max_speed + # CHANGED: Use current_speed + var normalized_speed: float = player_root.current_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() @@ -412,5 +412,6 @@ func test_fuel_depletion() -> void: Globals.settings.current_fuel = 0.0 player_root._on_fuel_timer_timeout() - assert_float(player_root.speed["speed"]).is_equal(0.0) + # CHANGED: Use current_speed + assert_float(player_root.current_speed).is_equal(0.0) assert_bool(player_root.fuel_timer.is_stopped()).is_true() diff --git a/test/gut/test_fuel_additional_edge_cases.gd b/test/gut/test_fuel_additional_edge_cases.gd index 29b040efe..d39c600ae 100644 --- a/test/gut/test_fuel_additional_edge_cases.gd +++ b/test/gut/test_fuel_additional_edge_cases.gd @@ -26,7 +26,7 @@ func after_each() -> void: func test_fuel_consumption_with_scaling() -> void: gut.p("Testing: Fuel consumption scales up when moving at a higher speed.") - # NEW: Instantiate the main scene locally and use GUT's add_child_autoqfree(). + # NEW: Instantiate the main scene locally and use GUT's add_child_autoqfree(). # This ensures the scene and all its dynamically generated Sprite2D children are safely queued for deletion. main_scene = load("res://scenes/main_scene.tscn").instantiate() add_child_autoqfree(main_scene) @@ -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 using the updated Resource. - player_root.speed["speed"] = Globals.settings.min_speed + # CHANGED: Use current_speed instead of speed["speed"] + player_root.current_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) using the updated Resource. - player_root.speed["speed"] = Globals.settings.max_speed + # CHANGED: Use current_speed instead of speed["speed"] + player_root.current_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_parallax_manager.gd b/test/gut/test_parallax_manager.gd new file mode 100644 index 000000000..7cad17cdb --- /dev/null +++ b/test/gut/test_parallax_manager.gd @@ -0,0 +1,218 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_parallax_manager.gd +## +## GUT unit tests for the ParallaxManager script. +## Validates observer pattern synchronization, scroll offset math, and fallback safety. +extends "res://addons/gut/test.gd" + +var _parallax_manager: ParallaxManager +var _original_settings: GameSettingsResource + + +## Per-test setup: Isolates the global resource state and instantiates the manager. +## :rtype: void +func before_each() -> void: + _original_settings = Globals.settings + Globals.settings = GameSettingsResource.new() + Globals.settings.difficulty = 1.0 + Globals.settings.current_fuel = 100.0 # Ensure scroll is not gated by flameout reset + + _parallax_manager = ParallaxManager.new() + add_child_autofree(_parallax_manager) + _parallax_manager.set_process(false) # Only run _process explicitly from tests + + # NEW: Inject the test settings into the manager + _parallax_manager.setup(Globals.settings) + + +## Post-test cleanup: Restores global state to prevent test leakage. +## :rtype: void +func after_each() -> void: + Globals.settings = _original_settings + + +# ========================================== +# OBSERVER INTEGRATION TESTS +# ========================================== + +## test_speed_update_from_signal | Observer Integration +## :rtype: void +func test_speed_update_from_signal() -> void: + gut.p("Testing: ParallaxManager correctly caches speed from the player's signal.") + + # Simulate the Player broadcasting a new speed of 250.0 + _parallax_manager.update_speed(250.0, 500.0) + + assert_eq( + _parallax_manager._current_speed, + 250.0, + "Manager must update _current_speed when signal callback is invoked." + ) + + +# ========================================== +# PROCESS & MATH LOGIC TESTS +# ========================================== + +## test_scroll_offset_math | Process Logic +## :rtype: void +func test_scroll_offset_math() -> void: + gut.p("Testing: Process loop correctly calculates the scroll offset increment based on difficulty.") + + # 1. Setup specific variables for predictable math + Globals.settings.difficulty = 2.0 + _parallax_manager._current_speed = 100.0 + _parallax_manager.scroll_offset.y = 0.0 + + # 2. Simulate one physics frame + var delta: float = 0.5 + _parallax_manager._process(delta) + + # 3. Verify the math + # Expected math: speed(100.0) * delta(0.5) * diff(2.0) * multiplier(0.8) = 80.0 + var expected_offset: float = 100.0 * 0.5 * 2.0 * 0.8 + + assert_almost_eq( + _parallax_manager.scroll_offset.y, + expected_offset, + 0.01, + "Scroll offset must accurately reflect the delta, speed, and difficulty multiplier." + ) + + +## test_zero_speed_stops_scroll | State Management +## :rtype: void +func test_zero_speed_stops_scroll() -> void: + gut.p("Testing: A speed of 0.0 results in a halted background scroll.") + + # 1. Setup flameout/halt state + _parallax_manager._current_speed = 0.0 + var initial_offset: float = 125.5 + _parallax_manager.scroll_offset.y = initial_offset + + # 2. Simulate processing frame + _parallax_manager._process(1.0) + + # 3. Verify no movement + assert_eq( + _parallax_manager.scroll_offset.y, + initial_offset, + "Scroll offset must remain completely unchanged when speed is zero." + ) + + +## test_flameout_resets_offset | State Management +## :rtype: void +func test_flameout_resets_offset() -> void: + gut.p("Testing: current_fuel <= 0 resets scroll_offset to Vector2.ZERO.") + Globals.settings.current_fuel = 0.0 + _parallax_manager._current_speed = 100.0 + _parallax_manager.scroll_offset = Vector2(42.0, 125.5) + _parallax_manager._process(0.5) + assert_eq( + _parallax_manager.scroll_offset, + Vector2.ZERO, + "Offset must reset to ZERO when fuel is depleted." + ) + + +## test_flameout_recovery_resumes_scroll | State Management +## Tests the exact recovery path of the ParallaxManager after a flameout event. +## Verifies that pushing a positive fuel value via the global Observer pattern +## successfully flips the internal `_out_of_fuel` boolean back to false, allowing +## the `_process` loop to seamlessly resume parallax scrolling without needing a scene reload. +## :rtype: void +func test_flameout_recovery_resumes_scroll() -> void: + gut.p("Testing: Refueling after a flameout clears the _out_of_fuel state and resumes scrolling.") + + # 1. Setup initial speed and force the flameout state + _parallax_manager.prime_speed(100.0) + _parallax_manager._on_fuel_depleted() # Simulates the global fuel_depleted signal + + # Verify the background is hard-stopped (Baseline Assertion) + _parallax_manager._process(1.0) + assert_eq( + _parallax_manager.scroll_offset.y, + 0.0, + "PRE-CONDITION: Scroll must be completely locked to ZERO during a flameout." + ) + + # 2. Simulate Refueling via the Observer Pattern + # This mimics `main_scene.gd` or `player.gd` updating the global resource. + # It triggers the specific `elif` branch in `_on_setting_changed` to clear `_out_of_fuel`. + _parallax_manager._on_setting_changed("current_fuel", 50.0) + + # 3. Simulate the next physics frame post-refuel + _parallax_manager._process(1.0) + + # 4. Verify the math resumed correctly + # Expected math: speed(100.0) * delta(1.0) * diff(1.0) * multiplier(0.8) = 80.0 + var expected_offset: float = 100.0 * 1.0 * 1.0 * 0.8 + + assert_almost_eq( + _parallax_manager.scroll_offset.y, + expected_offset, + 0.01, + "POST-CONDITION: Scroll offset must resume incrementing seamlessly once fuel is restored." + ) + + +# ========================================== +# SAFETY & EDGE CASE TESTS +# ========================================== + +## test_process_safe_with_null_globals_after_setup | Safety Constraint +## :rtype: void +func test_process_safe_with_null_globals_after_setup() -> void: + gut.p("Testing: ParallaxManager continues using cached state and does not crash if Globals drop.") + + # 1. Force a null state (simulating scene transition or engine shutdown) + Globals.settings = null + + _parallax_manager.prime_speed(100.0) + _parallax_manager.scroll_offset.y = 0.0 + + # 2. Simulate processing frame + var delta: float = 1.0 + _parallax_manager._process(delta) + + # 3. Verify the math used the cached difficulty (1.0 from before_each) + # Expected math: speed(100.0) * delta(1.0) * cached_diff(1.0) * multiplier(0.8) = 80.0 + var expected_offset: float = 100.0 * 1.0 * 1.0 * 0.8 + + assert_almost_eq( + _parallax_manager.scroll_offset.y, + expected_offset, + 0.01, + "Process must use cached difficulty and avoid null instance errors when Globals are missing." + ) + + +## test_process_uses_default_values_without_setup | Initialization +## :rtype: void +func test_process_uses_default_values_without_setup() -> void: + gut.p("Testing: ParallaxManager uses safe default values (difficulty 1.0) if setup() is never called.") + + # 1. Create a fresh manager without calling setup() + var uninitialized_manager: ParallaxManager = ParallaxManager.new() + add_child_autofree(uninitialized_manager) + uninitialized_manager.set_process(false) # Only run _process explicitly from tests + + uninitialized_manager.prime_speed(100.0) + uninitialized_manager.scroll_offset.y = 0.0 + + # 2. Simulate processing frame + var delta: float = 1.0 + uninitialized_manager._process(delta) + + # 3. Verify the math used the default initialized difficulty of 1.0 + # Expected math: speed(100.0) * delta(1.0) * default_diff(1.0) * multiplier(0.8) = 80.0 + var expected_offset: float = 100.0 * 1.0 * 1.0 * 0.8 + + assert_almost_eq( + uninitialized_manager.scroll_offset.y, + expected_offset, + 0.01, + "Process must use its baseline difficulty of 1.0 if dependency injection never occurs." + ) diff --git a/test/gut/test_parallax_manager.gd.uid b/test/gut/test_parallax_manager.gd.uid new file mode 100644 index 000000000..1b743fc69 --- /dev/null +++ b/test/gut/test_parallax_manager.gd.uid @@ -0,0 +1 @@ +uid://8158s2i62s66 diff --git a/test/gut/test_player_fuel_logic.gd b/test/gut/test_player_fuel_logic.gd index c80e08205..83a346e62 100644 --- a/test/gut/test_player_fuel_logic.gd +++ b/test/gut/test_player_fuel_logic.gd @@ -94,7 +94,7 @@ 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.current_speed = 150.0 _player.player.velocity.x = 0.0 Input.action_press("move_left") diff --git a/test/gut/test_player_movement_signals.gd b/test/gut/test_player_movement_signals.gd index 7e28b7b84..b2d09591c 100644 --- a/test/gut/test_player_movement_signals.gd +++ b/test/gut/test_player_movement_signals.gd @@ -7,7 +7,7 @@ extends "res://addons/gut/test.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 _player: Variant var _original_settings: GameSettingsResource var _added_actions: Array[String] = [] @@ -24,7 +24,7 @@ func before_each() -> void: InputMap.add_action(action) _added_actions.append(action) - # NEW: Call the shared static builder + # Call the shared static builder _mock_root = GutTestHelper.build_mock_player_scene() add_child_autoqfree(_mock_root) _player = _mock_root.get_node("Player") @@ -43,29 +43,36 @@ func after_each() -> void: ## test_physics_emits_speed_changed_on_acceleration | Signal Behavior +## Verifies that dynamic speed changes correctly trigger the Observer pattern. +## When the player successfully accelerates, the `speed_changed` signal must be +## broadcast so decoupled systems (like the ParallaxBackground and UI) can react. ## :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 + _player.current_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.") + assert_gt(_player.current_speed, 100.0, "Speed logic should have increased current speed.") + ## test_physics_does_not_spam_speed_changed | Signal Efficiency +## Verifies performance optimization inside the `_set_speed` helper. +## The physics loop runs 60 times a second; to prevent UI redraw bottlenecks, +## the `speed_changed` signal must ONLY be emitted if the speed value mathematically changes. ## :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 + _player.current_speed = 250.0 # Process multiple frames without active input _player._physics_process(0.1) @@ -74,15 +81,19 @@ 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 +## Verifies the critical failure state when the player runs out of fuel. +## It ensures the player's speed is instantly hard-locked to 0.0, and verifies +## that this sudden halt is broadcast via signal so the UI and background stop scrolling. ## :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 + _player.current_speed = 300.0 - # NEW FIX: Use the private backing field `_current_fuel` to bypass the public setter. + # 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 @@ -90,10 +101,14 @@ func test_flameout_resets_speed_and_emits_signal() -> void: # 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_eq(_player.current_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 + +## test_ui_updates_on_speed_signal | UI Reactivity & Integration +## Verifies the integration between the Player and the HUD. +## Proves that manually emitting the `speed_changed` signal successfully +## forces the PlayerStatsPanel to update its internal progress bar values. ## :rtype: void func test_ui_updates_on_speed_signal() -> void: gut.p("Testing: Target UI updates instantly when speed_changed fires.") @@ -102,14 +117,18 @@ func test_ui_updates_on_speed_signal() -> void: hud_panel.setup_hud(_player) hud_panel.speed_bar.value = 0.0 - _player.speed["speed"] = 500.0 # Force local sync + _player.current_speed = 500.0 # Force local sync # 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 +## Verifies that the internal math strictly obeys the Resource configuration limits. +## Prevents logic bugs where a player holding 'accelerate' for too long exceeds +## the physical capabilities of the plane, or achieves negative speeds by decelerating. ## :rtype: void func test_speed_clamps_to_max_and_min() -> void: gut.p("Testing: Speed values obey MIN and MAX constraints.") @@ -120,21 +139,21 @@ func test_speed_clamps_to_max_and_min() -> void: var min_cap: float = Globals.settings.min_speed # --- 1. Test MAX Clamp --- - _player.speed["speed"] = max_cap - 5.0 + _player.current_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.") + assert_eq(_player.current_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 + _player.current_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.") + assert_eq(_player.current_speed, min_cap, "Speed must not fall below configured MIN_SPEED.") Input.action_release("speed_down") # Clean up