diff --git a/scenes/main_scene.tscn b/scenes/main_scene.tscn index 69e271aa..6fb7610f 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 8c963b4f..994fa763 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -22,6 +22,92 @@ 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 + + # 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 + setting_changed.emit("min_speed", _min_speed) + get: + return _min_speed + +@export var lateral_speed: float = 250.0: + set(value): + var new_val: float = max(0.0, value) + if _lateral_speed == new_val: + return + _lateral_speed = new_val + setting_changed.emit("lateral_speed", _lateral_speed) + get: + return _lateral_speed + +@export var acceleration: float = 200.0: + set(value): + var new_val: float = max(0.0, value) + if _acceleration == new_val: + return + _acceleration = new_val + setting_changed.emit("acceleration", _acceleration) + get: + return _acceleration + +@export var deceleration: float = 100.0: + set(value): + var new_val: float = max(0.0, value) + if _deceleration == new_val: + return + _deceleration = new_val + 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 + + # 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): + # 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 + setting_changed.emit("low_yellow_fraction", _low_yellow_fraction) + get: + return _low_yellow_fraction + @export_group("Fuel System") ## Maximum fuel capacity. @@ -167,6 +253,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/globals.gd b/scripts/globals.gd index e53df891..518009a7 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 new file mode 100644 index 00000000..585ea976 --- /dev/null +++ b/scripts/hud.gd @@ -0,0 +1,474 @@ +## 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 --- +# Fraction constants that are strictly visual can remain local. +const HIGH_RED_FRACTION: float = 0.90 +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 +var _connected_player: Node2D = null # NEW: Track the player for clean disconnects + +# --- 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): + # 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 + + # 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() + 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 + # 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_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 + # 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() + 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 + + # 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) + + _connected_player = player_node + + # Connection guard for external wiring + 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) + + +## 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: + # 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) + 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 + + # --- Handle Fuel Updates --- + 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() + + # --- 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. +## @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: + 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 + 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_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 + ) + _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 + ) + _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 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_thresh or _current_speed > high_yellow_thresh) + and not _speed_state["blinking"] + ): + start_blinking(_speed_state) + elif ( + (low_yellow_thresh <= _current_speed and _current_speed <= high_yellow_thresh) + 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) + + +# ========================================== +# 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/scripts/hud.gd.uid b/scripts/hud.gd.uid new file mode 100644 index 00000000..c1323326 --- /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 bc158bcc..10275848 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 @@ -29,6 +30,17 @@ func _ready() -> void: 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 ebd11ebd..a8a2fe69 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -1,255 +1,127 @@ -## 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) +## 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 -# 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 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) - - # 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.") + push_error("Weapon node not found! Check player.tscn scene tree.") -# 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): _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) -# 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. +## @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 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) @@ -257,69 +129,62 @@ 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() - - -# NEW: Handler for engine failure triggered by the global fuel_depleted signal from the resource. -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 - speed["speed"] = 0.0 +## 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 - rotor_stop(rotor_right, rotor_right_sfx) - rotor_stop(rotor_left, rotor_left_sfx) - fuel_timer.stop() + var old_speed: float = speed["speed"] + # 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) -## 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") + # 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) -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) +## 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) -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) + rotor_stop(rotor_right, rotor_right_sfx) + rotor_stop(rotor_left, rotor_left_sfx) + fuel_timer.stop() +## 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) @@ -334,10 +199,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() @@ -350,217 +211,55 @@ 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() -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). +## Timer callback triggered every tick of the fuel timer. +## Calculates dynamic fuel consumption based on current speed and game difficulty. ## @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 - - -# Connect Timer's timeout signal 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) - -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. +## 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 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) - - -func start_blinking(param: Dictionary) -> void: - if param["label"] and param["timer"]: - param["blinking"] = true - param["timer"].start() - _toggle_label(param) # Immediate first toggle - - -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"]) - - -func _on_fuel_blink_timer_timeout() -> void: - if fuel["blinking"] and fuel["label"]: - _toggle_label(fuel) - - -func _on_speed_blink_timer_timeout() -> void: - if speed["blinking"] and speed["label"]: - _toggle_label(speed) - - -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"]) - - func _physics_process(_delta: float) -> void: - # NEW: Guard against null references during teardown or tests if not is_instance_valid(_settings): return + 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"] += speed["acceleration"] * _delta + target_speed += _settings.acceleration * _delta if Input.is_action_pressed("speed_down") and _settings.current_fuel > 0: - speed["speed"] -= speed["deceleration"] * _delta + target_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"]) - else: - speed["speed"] = clamp(speed["speed"], speed["min"], speed["max"]) + # 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") - # 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) - # Update UI - update_speed_bar() - check_speed_warning() - - # Perform player movement player.move_and_slide() diff --git a/test/gdunit4/test_difficulty.gd b/test/gdunit4/test_difficulty.gd index 3cda3734..1e6dc720 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 2e276de7..603cd01b 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_helpers.gd b/test/gdunit4/test_helpers.gd index b2069957..67120c37 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 diff --git a/test/gdunit4/test_player.gd b/test/gdunit4/test_player.gd index f006a2b8..ffecfdac 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,49 @@ 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") + + # 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 - 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 + high_yellow_thresh) / 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 = low_yellow_thresh - 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 = (low_yellow_thresh + high_yellow_thresh) / 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 = high_yellow_thresh + 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 = high_red_thresh + 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 = (low_yellow_thresh + high_yellow_thresh) / 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 +335,55 @@ 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() + # 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 = (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) - 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 = 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() 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 + (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(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 = 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() 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 = min_s + 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 +393,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 diff --git a/test/gut/gut_test_helper.gd b/test/gut/gut_test_helper.gd new file mode 100644 index 00000000..954d0014 --- /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 00000000..db3fc535 --- /dev/null +++ b/test/gut/gut_test_helper.gd.uid @@ -0,0 +1 @@ +uid://chddl1bcuy682 diff --git a/test/gut/test_fuel_additional_edge_cases.gd b/test/gut/test_fuel_additional_edge_cases.gd index 16030b5a..29b040ef 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_hud.gd b/test/gut/test_hud.gd new file mode 100644 index 00000000..a237ff0a --- /dev/null +++ b/test/gut/test_hud.gd @@ -0,0 +1,186 @@ +## 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 +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.get_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 +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 + 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 + var expected_yellow_lerp: Color = Color.GREEN.lerp(Color.YELLOW, 0.5) + 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 + var expected_red_lerp: Color = Color.YELLOW.lerp(Color.RED, 0.5) + 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 + var expected_dark_lerp: Color = Color.RED.lerp(_hud.DARK_RED, 0.5) + assert_true(_hud.get_fuel_bar_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 +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) --- + 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) --- + 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.get_speed_bar_color().is_equal_approx(expected_yellow), "High speed must lerp towards Yellow.") + + # --- 3. Overspeed Critical (Yellow to Dark Red Lerp) --- + 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.get_speed_bar_color().is_equal_approx(expected_dark), "Overspeed must lerp towards Dark Red.") + + # --- 4. Stall Critical (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.") + + +# ========================================== +# WARNING & BLINKER LOGIC TESTS +# ========================================== + +## test_warning_blinkers_activate_and_deactivate | State Management +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 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 + _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 via Resource update + Globals.settings.current_fuel = (Globals.settings.low_fuel_threshold - 5.0) / 100.0 * Globals.settings.max_fuel + 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 + assert_false(_hud.is_fuel_warning_active(), "Fuel blinker must deactivate when refueled.") + + +# ========================================== +# OBSERVER INTEGRATION TESTS +# ========================================== + +## test_hud_reacts_to_player_signals | Observer Integration +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 natively + _player.speed_changed.emit(400.0, 800.0) + + 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 +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 + _player.speed_changed.emit(300.0, Globals.settings.max_speed) + + # Broadcast flameout globally + Globals.settings.current_fuel = 0.0 + + 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.") diff --git a/test/gut/test_hud.gd.uid b/test/gut/test_hud.gd.uid new file mode 100644 index 00000000..4ecfdbb7 --- /dev/null +++ b/test/gut/test_hud.gd.uid @@ -0,0 +1 @@ +uid://c6m7c6jsioerr diff --git a/test/gut/test_player_fuel_logic.gd b/test/gut/test_player_fuel_logic.gd new file mode 100644 index 00000000..c80e0820 --- /dev/null +++ b/test/gut/test_player_fuel_logic.gd @@ -0,0 +1,103 @@ +## 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 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. +## :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) + + # 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: + Globals.settings = _original_settings + + # 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() + +## 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 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 + 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.") 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 00000000..3863b8e6 --- /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 00000000..7e28b7b8 --- /dev/null +++ b/test/gut/test_player_movement_signals.gd @@ -0,0 +1,140 @@ +## 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" + +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: + _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) + + # 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: + # 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() + + +## 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 + + # 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() + + 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.") + + 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 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: + gut.p("Testing: Speed values obey MIN and MAX constraints.") + + Globals.settings.current_fuel = 100.0 + + 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 + + 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.") + 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 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 00000000..7a0d3c6f --- /dev/null +++ b/test/gut/test_player_movement_signals.gd.uid @@ -0,0 +1 @@ +uid://cpmr3k4ocy567