diff --git a/scenes/main_scene.tscn b/scenes/main_scene.tscn index 8e83f038..69e271aa 100644 --- a/scenes/main_scene.tscn +++ b/scenes/main_scene.tscn @@ -4,7 +4,7 @@ [ext_resource type="PackedScene" uid="uid://cb4n4cqkuddqg" path="res://scenes/pause_menu.tscn" id="1_w2twt"] [ext_resource type="Texture2D" uid="uid://ce63ga8f5mua7" path="res://files/trees/tree_01.png" id="2_fm3ay"] [ext_resource type="FontFile" uid="uid://borwvgqdgawbj" path="res://files/fonts/EMPIREST.TTF" id="2_pu3yx"] -[ext_resource type="PackedScene" uid="uid://37rarq1yywmc" path="res://scenes/player.tscn" id="2_pw63i"] +[ext_resource type="PackedScene" uid="uid://37rarq1yywmc" path="res://scenes/Player.tscn" id="2_pw63i"] [ext_resource type="Script" uid="uid://csdce7ynrpivs" path="res://scripts/resource_preloader.gd" id="3_c1pb6"] [ext_resource type="Texture2D" uid="uid://bsmbcgovcph7p" path="res://files/trees/tree_02.png" id="3_sgkfd"] [ext_resource type="Texture2D" uid="uid://syw5eae1ebna" path="res://files/trees/tree_11.png" id="4_qj6t7"] diff --git a/scripts/game_settings_resource.gd b/scripts/game_settings_resource.gd index 6a86e451..8c963b4f 100644 --- a/scripts/game_settings_resource.gd +++ b/scripts/game_settings_resource.gd @@ -15,6 +15,100 @@ extends Resource ## without the UI having to explicitly call persistence or logging methods. signal setting_changed(setting_name: String, new_value: Variant) +## SIGNAL: fuel_depleted +## +## Emitted when current_fuel reaches exactly 0.0. +## External systems (like the Player or UI) can connect to this to trigger +## game-over states or low-fuel warnings without polling every frame. +signal fuel_depleted + +@export_group("Fuel System") + +## Maximum fuel capacity. +@export var max_fuel: float = 100.0: + set(value): + # NEW: Enforce a logical minimum capacity of 1.0. This prevents a misconfigured + # save file or options slider from shrinking the tank to 0.0 and + # accidentally triggering a runtime "Game Over" flameout. + var new_max: float = max(1.0, value) + + if _max_fuel == new_max: + return + _max_fuel = new_max + + # Use the backing field for comparisons, but trigger the public + # setter for current_fuel if needed + if _current_fuel > _max_fuel: + self.current_fuel = _max_fuel + + setting_changed.emit("max_fuel", _max_fuel) + get: + return _max_fuel + +## Current fuel level. Clamped between 0.0 and max_fuel. +@export var current_fuel: float = 100.0: + set(value): + # NEW: Use private backing field to safely clamp and check values without recursion + var old_value: float = _current_fuel + var new_fuel: float = clamp(value, 0.0, _max_fuel) + + if _current_fuel == new_fuel: + return + + _current_fuel = new_fuel + + if old_value > 0.0 and _current_fuel == 0.0: + fuel_depleted.emit() + + setting_changed.emit("current_fuel", _current_fuel) + get: + # NEW: Return the backing field + return _current_fuel + +## Base rate of fuel consumption per second. +@export var base_consumption_rate: float = 1.0 + +# NEW: Migrated thresholds from player.gd to centralize all fuel configuration +@export var high_fuel_threshold: float = 90.0: + set(value): + var new_val: float = max(value, _medium_fuel_threshold + 1.0) # Must be greater than medium + if _high_fuel_threshold == new_val: + return + _high_fuel_threshold = new_val + setting_changed.emit("high_fuel_threshold", _high_fuel_threshold) + get: + return _high_fuel_threshold + +@export var medium_fuel_threshold: float = 50.0: + set(value): + var new_val: float = clamp(value, _low_fuel_threshold + 1.0, _high_fuel_threshold - 1.0) + if _medium_fuel_threshold == new_val: + return + _medium_fuel_threshold = new_val + setting_changed.emit("medium_fuel_threshold", _medium_fuel_threshold) + get: + return _medium_fuel_threshold + +@export var low_fuel_threshold: float = 30.0: + set(value): + var new_val: float = clamp(value, _no_fuel_threshold + 1.0, _medium_fuel_threshold - 1.0) + if _low_fuel_threshold == new_val: + return + _low_fuel_threshold = new_val + setting_changed.emit("low_fuel_threshold", _low_fuel_threshold) + get: + return _low_fuel_threshold + +@export var no_fuel_threshold: float = 15.0: + set(value): + var new_val: float = min(value, _low_fuel_threshold - 1.0) # Must be lower than low + if _no_fuel_threshold == new_val: + return + _no_fuel_threshold = new_val + setting_changed.emit("no_fuel_threshold", _no_fuel_threshold) + get: + return _no_fuel_threshold + @export_group("Logging") # Current log level: 0=DEBUG, 1=INFO, 2=WARNING, 3=ERROR, 4=NONE @export_range(0, 4, 1) var current_log_level: int = 1: @@ -58,9 +152,20 @@ signal setting_changed(setting_name: String, new_value: Variant) @export var options_scene: PackedScene # Private member variables moved to bottom to satisfy class-definitions-order +# NEW: GDScript requires backing fields to be declared BEFORE +# the properties that reference them. +# Moved these from the bottom of the script to the top to resolve +# the "Identifier not declared" scope error. +var _max_fuel: float = 100.0 +var _current_fuel: float = _max_fuel # ← Automatically syncs to whatever max_fuel is var _current_log_level: int = 1 var _difficulty: float = 1.0 var _enable_debug_logging: bool = false +# NEW: Add backing fields +var _high_fuel_threshold: float = 90.0 +var _medium_fuel_threshold: float = 50.0 +var _low_fuel_threshold: float = 30.0 +var _no_fuel_threshold: float = 15.0 func _init() -> void: @@ -69,3 +174,13 @@ func _init() -> void: key_mapping_scene = load("res://scenes/key_mapping_menu.tscn") if not options_scene: options_scene = load("res://scenes/options_menu.tscn") + # NEW: Safely enforce the invariant that a brand new resource + # always starts with a full tank, without bypassing the validation setters. + _current_fuel = _max_fuel + + +## Helper method to increase fuel safely. +## Increases fuel level by specified amount, clamped to max_fuel. +func refuel(amount: float) -> void: + if amount > 0: + current_fuel += amount diff --git a/scripts/globals.gd b/scripts/globals.gd index 4f99abb6..e53df891 100644 --- a/scripts/globals.gd +++ b/scripts/globals.gd @@ -79,10 +79,17 @@ func _on_setting_changed(setting_name: String, new_value: Variant) -> void: var log_msg: String = "Setting '%s' updated to: %s" % [setting_name, str(new_value)] # Automatically log the change - log_message(log_msg, LogLevel.DEBUG) + # OLD: log_message(log_msg, LogLevel.DEBUG) + # NEW: Prevent log spam by filtering out high-frequency runtime changes like fuel ticks + if setting_name != "current_fuel": + log_message(log_msg, LogLevel.DEBUG) # Automatically persist to disk - _save_settings() + # OLD: _save_settings() + # NEW: Prevent disk I/O lag by stopping current_fuel from + # triggering a file save on every frame/timer tick + if setting_name != "current_fuel": + _save_settings() ## Centralized "ensure initial focus" helper. @@ -151,7 +158,8 @@ func load_key_mapping(menu_to_hide: Node) -> void: get_tree().root.add_child(km_instance) -## Loads persisted settings from config if valid types; skips invalid/missing to keep current. +## Loads persisted settings from config if valid types; +## skips invalid/missing to keep current. ## :param path: Config file path (default: Settings.CONFIG_PATH). ## :type path: String ## :rtype: void @@ -202,6 +210,16 @@ func _load_settings(path: String = Settings.CONFIG_PATH) -> void: LogLevel.DEBUG ) + # NEW: Load the fuel related settings + if config.has_section_key("Settings", "max_fuel"): + var loaded_max: Variant = config.get_value("Settings", "max_fuel") + if loaded_max is float or loaded_max is int: + settings.max_fuel = float(loaded_max) + else: + log_message( + "Invalid type for max_fuel: " + str(typeof(loaded_max)), LogLevel.WARNING + ) + # Disable the guard and log a single summary instead _is_loading_settings = false log_message("All settings loaded and synchronized.", LogLevel.DEBUG) @@ -226,6 +244,9 @@ func _save_settings(path: String = Settings.CONFIG_PATH) -> void: config.set_value("Settings", "difficulty", settings.difficulty) # NEW: Persist the debug logging flag config.set_value("Settings", "enable_debug_logging", settings.enable_debug_logging) + # NEW: Persist the fuel settings + config.set_value("Settings", "max_fuel", settings.max_fuel) + err = config.save(path) if err != OK: log_message("Failed to save settings: " + str(err), LogLevel.ERROR) @@ -321,7 +342,8 @@ func _notification(what: int) -> void: # Example: Save game state if you have a save system. # Replace with your actual save function, e.g., from a save_manager.gd. - # save_game_state() # Uncomment and implement as needed. + # NEW: Explicitly save all settings right before the game quits + _save_settings() # After cleanup, let the quit proceed (optional on desktop; auto on web). get_tree().quit() diff --git a/scripts/main_scene.gd b/scripts/main_scene.gd index 771a07ca..bc158bcc 100644 --- a/scripts/main_scene.gd +++ b/scripts/main_scene.gd @@ -186,9 +186,20 @@ func setup_decor_layer(viewport: Vector2) -> void: func _process(delta: float) -> void: - var scroll_speed: float = player.speed["speed"] * delta * Globals.settings.difficulty * 0.8 + # NEW: Safely grab the settings resource and guard against null crashes + # during scene transitions, engine shutdown, or isolated GUT tests. + var settings_res: GameSettingsResource = ( + Globals.settings if is_instance_valid(Globals) else null + ) + if not is_instance_valid(settings_res): + return + + # Use the safe local reference for difficulty + var scroll_speed: float = player.speed["speed"] * delta * settings_res.difficulty * 0.8 background.scroll_offset.y += scroll_speed - if player.fuel["fuel"] <= 0: + + # Use the safe local reference for current_fuel + if settings_res.current_fuel <= 0: background.scroll_offset = Vector2(0, 0) # 1. Critical unbound controls warning (shown ONCE per session) diff --git a/scripts/player.gd b/scripts/player.gd index f72d32f3..ebd11ebd 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -1,18 +1,11 @@ ## Copyright (C) 2025 Egor Kostan ## SPDX-License-Identifier: GPL-3.0-or-later -# player.gd +## player.gd extends Node2D ## Player controller for P-38 Lightning in SkyLockAssault. ## Manages movement, fuel, bounds, rotors (anim/sound), weapons. -# Fuel color thresholds (percentages) -const HIGH_FUEL_THRESHOLD: float = 90.0 # Starts green lerp -const MEDIUM_FUEL_THRESHOLD: float = 50.0 # Switches to yellow lerp -const MAX_FUEL: float = 100.0 # Fully Red Color -const LOW_FUEL_THRESHOLD: float = 30.0 # Switches to red lerp -const NO_FUEL_THRESHOLD: float = 15.0 # Fully Red Color - # Bounds hitbox scale (quarter texture = tight margin for top-down plane) const HITBOX_SCALE: float = 0.25 @@ -37,13 +30,9 @@ 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 current_speed: float = 250.0 @export var lateral_speed: float = 250.0 @export var acceleration: float = 200.0 @export var deceleration: float = 100.0 -# Base fuel consumption -@export var base_fuel_drain: float = 1.0 -var current_fuel: float # Regular vars for computed boundaries (no export needed if set in code) var screen_size: Vector2 @@ -60,6 +49,9 @@ var corner_radius: int = 10 var fuel: Dictionary var speed: Dictionary +# Cache the global settings to avoid singleton lookups in hot paths +var _settings: GameSettingsResource = null + # Onreadys next @onready var rotor_right: Node2D = $CharacterBody2D/RotorRight @onready var rotor_left: Node2D = $CharacterBody2D/RotorLeft @@ -81,6 +73,20 @@ var speed_label_blink_timer: Timer = $"../PlayerStatsPanel/Stats/Speed/SpeedLabe 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") @@ -139,7 +145,13 @@ func _ready() -> void: # Initialize fuel bar style fuel_bar_fill_style = StyleBoxFlat.new() set_bar_fill_style(fuel_bar, fuel_bar_fill_style) - fuel_bar.max_value = MAX_FUEL + # 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. + _settings.current_fuel = _settings.max_fuel # Initialize speed bar style and value speed_bar_fill_style = StyleBoxFlat.new() @@ -147,12 +159,19 @@ func _ready() -> void: speed_bar.max_value = MAX_SPEED # Initialize fuel bar style and value - current_fuel = MAX_FUEL 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, # Initial speed value (mph); was current_speed + "speed": 250.0, "lateral_speed": lateral_speed, "acceleration": acceleration, "deceleration": deceleration, @@ -166,12 +185,12 @@ func _ready() -> void: "blinking": false, } + # Initialize fuel dictionary + # (Fuel state now lives in _settings. This dict is ONLY for UI.) fuel = { - "fuel": current_fuel, "factor": 0.0, "timer": fuel_label_blink_timer, "label": fuel_label, - "max": MAX_FUEL, "bar": fuel_bar, "bar style": fuel_bar_fill_style, "blinking": false, @@ -210,12 +229,69 @@ func _ready() -> void: push_error("Weapon node not found! Check player.tscn scene tree for $Weapon child.") +# NEW: Defensive cleanup to prevent dangling signal connections +# when the player is removed from the scene tree or reloaded. +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. +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 + 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) + Globals.log_message( + "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 + + rotor_stop(rotor_right, rotor_right_sfx) + rotor_stop(rotor_left, rotor_left_sfx) + fuel_timer.stop() + + ## Retrieves the effective text color of a Label, considering theme overrides. ## @param label: The Label node to query. ## @return: The effective font color. func get_label_text_color(label: Label) -> Color: if label.has_theme_color_override("font_color"): - # return label.get_theme_color("font_color", "") return label.get("theme_override_colors/font_color") return label.get_theme_color("font_color", "Label") @@ -238,7 +314,6 @@ func set_bar_fill_style(bar: ProgressBar, bar_fill_style: StyleBoxFlat) -> void: func _input(event: InputEvent) -> void: # Fire weapon if event.is_action_pressed("fire"): - # Globals.log_message("Fire input pressed → calling weapon.fire()", Globals.LogLevel.DEBUG) if weapon and weapon.has_method("fire"): weapon.fire() get_viewport().set_input_as_handled() @@ -284,27 +359,42 @@ func rotor_stop(rotor: Node2D, rotor_sfx: AudioStreamPlayer2D) -> void: func update_fuel_bar() -> void: - fuel["bar"].value = fuel["fuel"] - var fuel_percent: float = (fuel["fuel"] / fuel["max"]) * 100.0 + 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 - if fuel_percent > HIGH_FUEL_THRESHOLD: - fuel["factor"] = 0.0 # Reset for consistency, though not used here + 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_FUEL_THRESHOLD: - fuel["factor"] = ( - (HIGH_FUEL_THRESHOLD - fuel_percent) / (HIGH_FUEL_THRESHOLD - MEDIUM_FUEL_THRESHOLD) - ) + + 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_FUEL_THRESHOLD: - fuel["factor"] = ( - (MEDIUM_FUEL_THRESHOLD - fuel_percent) / (MEDIUM_FUEL_THRESHOLD - LOW_FUEL_THRESHOLD) - ) + + 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_THRESHOLD: - fuel["factor"] = ( - (LOW_FUEL_THRESHOLD - fuel_percent) / (LOW_FUEL_THRESHOLD - NO_FUEL_THRESHOLD) - ) + + 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"]) @@ -355,33 +445,35 @@ func update_speed_bar() -> void: # Connect Timer's timeout signal func _on_fuel_timer_timeout() -> void: - # Scale base rate with clamped normalized speed - # to avoid excessive drain at out-of-range speeds + 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 fuel_left: float = ( - fuel["fuel"] - ((base_fuel_drain * normalized_speed) * Globals.settings.difficulty) + var consumption: float = ( + _settings.base_consumption_rate * normalized_speed * _settings.difficulty ) - # - # Clamp and update current_fuel first - fuel["fuel"] = clamp(fuel_left, 0, fuel["max"]) + _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) - if fuel["fuel"] <= 0: - speed["speed"] = 0.0 # Or game over logic - fuel_timer.stop() - rotor_stop(rotor_right, rotor_right_sfx) - rotor_stop(rotor_left, rotor_left_sfx) - # Update UI from the clamped value - update_fuel_bar() - # Check fuel level and start/stop blinking - check_fuel_warning() - Globals.log_message("Fuel left: " + str(fuel["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 -func check_fuel_warning() -> void: - if fuel["fuel"] <= LOW_FUEL_THRESHOLD and not fuel["blinking"]: + # NEW: Compare the calculated percentage against the threshold + if fuel_percent <= _settings.low_fuel_threshold and not fuel["blinking"]: start_blinking(fuel) - elif fuel["fuel"] > LOW_FUEL_THRESHOLD and fuel["blinking"]: + # NEW: Compare the calculated percentage against the threshold + elif fuel_percent > _settings.low_fuel_threshold and fuel["blinking"]: stop_blinking(fuel) @@ -434,30 +526,41 @@ func _toggle_label(param: Dictionary) -> void: func _physics_process(_delta: float) -> void: + # NEW: Guard against null references during teardown or tests + if not is_instance_valid(_settings): + return + # Speed changes allowed only if fuel > 0 - if Input.is_action_pressed("speed_up") and fuel["fuel"] > 0: + if Input.is_action_pressed("speed_up") and _settings.current_fuel > 0: speed["speed"] += speed["acceleration"] * _delta - if Input.is_action_pressed("speed_down") and fuel["fuel"] > 0: + + if Input.is_action_pressed("speed_down") and _settings.current_fuel > 0: speed["speed"] -= speed["deceleration"] * _delta + # Clamp current_speed between MIN_SPEED and MAX_SPEED - if fuel["fuel"] == 0: + 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"]) + # 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 fuel["fuel"] > 0 and speed["speed"] > 0: + 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 else: player.velocity.x = 0.0 + # Clamp player position within allowed ranged of coords 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 f3eea6f0..3cda3734 100644 --- a/test/gdunit4/test_difficulty.gd +++ b/test/gdunit4/test_difficulty.gd @@ -8,12 +8,23 @@ extends GdUnitTestSuite const TestHelpers = preload("res://test/gdunit4/test_helpers.gd") var original_difficulty: float # Snapshot holder +# NEW: Added snapshot holders for global fuel state to prevent test leakage +var original_current_fuel: float +var original_max_fuel: float + func before_test() -> void: original_difficulty = Globals.settings.difficulty # Snapshot before each test + # NEW: Snapshot fuel state + original_current_fuel = Globals.settings.current_fuel + original_max_fuel = Globals.settings.max_fuel + func after_test() -> void: Globals.settings.difficulty = original_difficulty # Restore after each test + # NEW: Restore fuel state so other tests start clean + Globals.settings.max_fuel = original_max_fuel + Globals.settings.current_fuel = original_current_fuel ## Tests fuel depletion scaling with difficulty levels. @@ -28,25 +39,54 @@ func test_fuel_depletion_with_difficulty() -> void: # Save original difficulty for reset var original_difficulty: float = Globals.settings.difficulty + # NEW: Derive the starting baseline dynamically to avoid clamping issues if max_fuel changed + var start_fuel: float = Globals.settings.max_fuel # Reset fuel before each sim for independent tests - player_inst.fuel["fuel"] = 100.0 + # OLD: player_inst.fuel["fuel"] = 100.0 + # 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 - var dep_1: float = player_inst.base_fuel_drain * normalized_speed * Globals.settings.difficulty + + # 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 + var dep_1: float = Globals.settings.base_consumption_rate * normalized_speed * Globals.settings.difficulty + player_inst._on_fuel_timer_timeout() - assert_float(player_inst.fuel["fuel"]).is_equal_approx(100.0 - dep_1, 0.01) # Larger delta for precision - player_inst.fuel["fuel"] = 100.0 + # OLD: assert_float(player_inst.fuel["fuel"]).is_equal_approx(100.0 - dep_1, 0.01) # Larger delta for precision + # NEW: Assert against the global fuel resource and dynamic baseline + assert_float(Globals.settings.current_fuel).is_equal_approx(start_fuel - dep_1, 0.01) + + # OLD: player_inst.fuel["fuel"] = 100.0 + # 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 - var dep_2: float = player_inst.base_fuel_drain * normalized_speed * Globals.settings.difficulty + + # 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 + var dep_2: float = Globals.settings.base_consumption_rate * normalized_speed * Globals.settings.difficulty + player_inst._on_fuel_timer_timeout() - assert_float(player_inst.fuel["fuel"]).is_equal_approx(100.0 - dep_2, 0.01) - player_inst.fuel["fuel"] = 100.0 + # OLD: assert_float(player_inst.fuel["fuel"]).is_equal_approx(100.0 - dep_2, 0.01) + # NEW: Assert against the global fuel resource and dynamic baseline + assert_float(Globals.settings.current_fuel).is_equal_approx(start_fuel - dep_2, 0.01) + + # OLD: player_inst.fuel["fuel"] = 100.0 + # 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 - var dep_05: float = player_inst.base_fuel_drain * normalized_speed * Globals.settings.difficulty + + # 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 + var dep_05: float = Globals.settings.base_consumption_rate * normalized_speed * Globals.settings.difficulty + player_inst._on_fuel_timer_timeout() - assert_float(player_inst.fuel["fuel"]).is_equal_approx(100.0 - dep_05, 0.01) + + # OLD: assert_float(player_inst.fuel["fuel"]).is_equal_approx(100.0 - dep_05, 0.01) + # NEW: Assert against the global fuel resource and dynamic baseline + assert_float(Globals.settings.current_fuel).is_equal_approx(start_fuel - dep_05, 0.01) diff --git a/test/gdunit4/test_difficulty_integration.gd b/test/gdunit4/test_difficulty_integration.gd index bca19fe3..2e276de7 100644 --- a/test/gdunit4/test_difficulty_integration.gd +++ b/test/gdunit4/test_difficulty_integration.gd @@ -8,12 +8,24 @@ extends GdUnitTestSuite const TestHelpers = preload("res://test/gdunit4/test_helpers.gd") var original_difficulty: float # Snapshot holder +# NEW: Added snapshot holders for global fuel state to prevent test leakage +var original_current_fuel: float +var original_max_fuel: float + func before_test() -> void: original_difficulty = Globals.settings.difficulty # Snapshot before each test + # NEW: Snapshot fuel state + original_current_fuel = Globals.settings.current_fuel + original_max_fuel = Globals.settings.max_fuel + func after_test() -> void: Globals.settings.difficulty = original_difficulty # Restore after each test + # NEW: Restore fuel state so other tests start clean + Globals.settings.max_fuel = original_max_fuel + Globals.settings.current_fuel = original_current_fuel + func test_difficulty_scales_fuel_and_weapon() -> void: # Setup: Load main_scene for full context (PlayerStatsPanel for fuel_bar path) @@ -31,13 +43,25 @@ func test_difficulty_scales_fuel_and_weapon() -> void: var original_difficulty: float = Globals.settings.difficulty Globals.settings.difficulty = 2.0 + # NEW: Derive the starting baseline dynamically to avoid clamping issues if max_fuel changed + var start_fuel: float = Globals.settings.max_fuel + # TEST 1: Fuel depletion scales (derive from constants) - player.fuel["fuel"] = 100.0 + # 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 - var expected_depletion: float = player.base_fuel_drain * normalized_speed * Globals.settings.difficulty + + # 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 + var expected_depletion: float = Globals.settings.base_consumption_rate * normalized_speed * Globals.settings.difficulty + player._on_fuel_timer_timeout() - var expected_fuel: float = 100.0 - expected_depletion - assert_float(player.fuel["fuel"]).is_equal_approx(expected_fuel, 0.01) # Larger delta for precision + var expected_fuel: float = start_fuel - expected_depletion + + # OLD: assert_float(player.fuel["fuel"]).is_equal_approx(expected_fuel, 0.01) # Larger delta for precision + # NEW: Verify the depletion amount against the global resource current_fuel + assert_float(Globals.settings.current_fuel).is_equal_approx(expected_fuel, 0.01) # Larger delta for precision # TEST 2: Weapon cooldown scales (fire_rate 0.15 * 2.0 = 0.30) weapon.fire() # FIXED: fire() not _fire(); delegates → BulletFirer.fire() → timer.start(0.30) diff --git a/test/gdunit4/test_helpers.gd b/test/gdunit4/test_helpers.gd index ac091c24..b2069957 100644 --- a/test/gdunit4/test_helpers.gd +++ b/test/gdunit4/test_helpers.gd @@ -10,5 +10,8 @@ extends RefCounted ## @param player: The player node instance. ## @param difficulty: The difficulty level to use. ## @return: The expected depletion amount. -static func calculate_expected_depletion(player: Node, difficulty: float) -> float: - return player.base_fuel_drain * (player.speed["speed"] / player.MAX_SPEED) * difficulty +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 + return Globals.settings.base_consumption_rate * normalized_speed * difficulty diff --git a/test/gdunit4/test_player.gd b/test/gdunit4/test_player.gd index 66ccf4e0..f006a2b8 100644 --- a/test/gdunit4/test_player.gd +++ b/test/gdunit4/test_player.gd @@ -4,7 +4,6 @@ # Unit tests for player.gd using GdUnit4 in Godot 4.4 # All tests now use manual scene instantiation (no GdUnitSceneRunner) # This avoids version-specific API issues and gives full control. - extends GdUnitTestSuite const TestHelpers = preload("res://test/gdunit4/test_helpers.gd") @@ -31,7 +30,8 @@ func test_shared_depletion_helper() -> void: var player_root: Node = main_scene.get_node("Player") Globals.settings.difficulty = 2.0 - var expected: float = player_root.base_fuel_drain * (player_root.speed["speed"] / player_root.MAX_SPEED) * Globals.settings.difficulty + # 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 assert_float(TestHelpers.calculate_expected_depletion(player_root, Globals.settings.difficulty)).is_equal_approx(expected, 0.001) @@ -46,6 +46,7 @@ func test_player_present() -> void: assert_bool(player_root.visible).is_true() 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 @@ -81,16 +82,18 @@ func test_fuel_colors() -> void: var fuel_bar : ProgressBar = player_root.fuel["bar"] # High fuel → Green - player_root.fuel["fuel"] = 95.0 + # 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() assert_that(style_1.bg_color).is_equal(Color.GREEN) # Low fuel → Dark Red (consistent with gradual depletion) - player_root.fuel["fuel"] = 10.0 + # NEW: Calculate 10% relative to the dynamic max_fuel + 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() - assert_that(style_2.bg_color).is_equal(Color(0.5, 0, 0, 1.0)) # Or Color(0.5, 0.0, 0.0) if using floats + assert_that(style_2.bg_color).is_equal(Color(0.5, 0, 0, 1.0)) # Test: Smooth color lerp between thresholds @@ -103,16 +106,19 @@ func test_fuel_colors_fixed() -> void: var fuel_bar : ProgressBar = player_root.fuel["bar"] # Still full → Green - player_root.fuel["fuel"] = 95.0 + # 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() assert_that(style.bg_color).is_equal(Color.GREEN) # Between 90% and 50% → Lerp green → yellow - player_root.fuel["fuel"] = 70.0 + # 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() - var expected := Color.GREEN.lerp(Color.YELLOW, (90.0 - 70.0) / (90.0 - 50.0)) + # Update the math here to rely on percentages rather than absolute 100-tank units + 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() @@ -125,19 +131,19 @@ func test_fuel_gradual_depletion_colors() -> void: var player_root: Node = main_scene.get_node("Player") # Start at 30% (should be red) - player_root.fuel["fuel"] = 30.0 + 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() assert_that(style.bg_color).is_equal(Color.RED) # Drop to 15% (dark red) - player_root.fuel["fuel"] = 15.0 + 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() assert_that(style.bg_color).is_equal(Color(0.5, 0, 0)) # Drop to 10% (still dark red) - player_root.fuel["fuel"] = 10.0 + 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() assert_that(style.bg_color).is_equal(Color(0.5, 0, 0)) @@ -166,7 +172,6 @@ func test_rotor_null_sfx() -> void: assert_bool(player_root.rotor_right.get_node("AnimatedSprite2D").is_playing()).is_false() -# Test: Independent blinking for fuel and speed labels # Test: Independent blinking for fuel and speed labels func test_independent_blinking() -> void: var main_scene: Node = auto_free(load("res://scenes/main_scene.tscn").instantiate()) @@ -176,7 +181,8 @@ func test_independent_blinking() -> void: var player_root: Node = main_scene.get_node("Player") # Force low fuel and high speed to trigger both - player_root.fuel["fuel"] = 10.0 + # 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() @@ -222,7 +228,6 @@ func test_get_label_text_color_override() -> void: assert_that(player_root.get_label_text_color(fuel_label)).is_equal(initial_color) -# Test: rotor_start/stop logs warning on missing AnimatedSprite2D # Test: rotor_start/stop logs warning on missing AnimatedSprite2D func test_rotor_missing_anim_sprite() -> void: var main_scene: Node = auto_free(load("res://scenes/main_scene.tscn").instantiate()) @@ -393,18 +398,25 @@ func test_fuel_depletion() -> void: var player_root: Node = main_scene.get_node("Player") # Initial state - assert_float(player_root.fuel["fuel"]).is_equal(player_root.fuel["max"]) - assert_float(player_root.fuel["bar"].value).is_equal(100.0) + 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 - var expected_depletion: float = player_root.base_fuel_drain * normalized_speed * Globals.settings.difficulty + + # Use the global base_consumption_rate since the local drain variable was removed. + var expected_depletion: float = Globals.settings.base_consumption_rate * normalized_speed * Globals.settings.difficulty + player_root._on_fuel_timer_timeout() - assert_float(player_root.fuel["fuel"]).is_equal_approx(player_root.fuel["max"] - expected_depletion, 0.1) # Larger delta for float precision - assert_float(player_root.fuel["bar"].value).is_equal_approx(100.0 - expected_depletion, 0.1) # Normalized to percent + + 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) # Force zero fuel - player_root.fuel["fuel"] = 0.0 + Globals.settings.current_fuel = 0.0 + player_root._on_fuel_timer_timeout() assert_float(player_root.speed["speed"]).is_equal(0.0) assert_bool(player_root.fuel_timer.is_stopped()).is_true() diff --git a/test/gut/test_fuel_additional_edge_cases.gd b/test/gut/test_fuel_additional_edge_cases.gd new file mode 100644 index 00000000..16030b5a --- /dev/null +++ b/test/gut/test_fuel_additional_edge_cases.gd @@ -0,0 +1,70 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_fuel_additional_edge_cases.gd +## GUT unit tests covering fuel consumption scaling and negative refuel edge cases. + +extends "res://addons/gut/test.gd" + +var main_scene: Node +var player_root: Node2D +# NEW: Declare the member variable to prevent "Identifier not found" script errors +var _previous_settings: GameSettingsResource + +## Per-test setup: Instantiate a fresh environment and resource. +## :rtype: void +func before_each() -> void: + _previous_settings = Globals.settings + # NEW: Reset global settings to a fresh instance to prevent state leakage between tests. + Globals.settings = GameSettingsResource.new() + +func after_each() -> void: + Globals.settings = _previous_settings + + +## test_fuel_consumption_with_scaling | Integration | Verify speed increases fuel consumption +## :rtype: void +func test_fuel_consumption_with_scaling() -> void: + gut.p("Testing: Fuel consumption scales up when moving at a higher speed.") + + # NEW: Instantiate the main scene locally and use GUT's add_child_autoqfree(). + # This ensures the scene and all its dynamically generated Sprite2D children are safely queued for deletion. + main_scene = load("res://scenes/main_scene.tscn").instantiate() + add_child_autoqfree(main_scene) + player_root = main_scene.get_node("Player") + player_root.fuel_timer.stop() + + # NEW: Establish a clean baseline for fuel and difficulty. + 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 + 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 + player_root._on_fuel_timer_timeout() + var high_speed_depletion: float = 100.0 - Globals.settings.current_fuel + + # NEW: Assert that the high-speed state drained strictly more fuel than the base-speed state. + assert_gt(high_speed_depletion, base_depletion, "High speed state must consume more fuel than base speed.") + + +## test_refuel_negative_input | Resource | Validate handling of invalid refuel input +## :rtype: void +func test_refuel_negative_input() -> void: + gut.p("Testing: Refueling with a negative value should be ignored.") + + # NEW: Set an initial baseline fuel level. + var initial_fuel: float = 50.0 + Globals.settings.current_fuel = initial_fuel + + # NEW: Attempt to apply a negative refuel amount (-10.0) which should be caught by the refuel logic. + Globals.settings.refuel(-10.0) + + # NEW: Assert the fuel level remained completely unchanged and did not accidentally subtract fuel. + assert_eq(Globals.settings.current_fuel, initial_fuel, "Negative refuel inputs must not drain the current fuel.") diff --git a/test/gut/test_fuel_additional_edge_cases.gd.uid b/test/gut/test_fuel_additional_edge_cases.gd.uid new file mode 100644 index 00000000..d870b84b --- /dev/null +++ b/test/gut/test_fuel_additional_edge_cases.gd.uid @@ -0,0 +1 @@ +uid://cpiqr1on8hg3a diff --git a/test/gut/test_fuel_edge_cases.gd b/test/gut/test_fuel_edge_cases.gd new file mode 100644 index 00000000..e03eb15d --- /dev/null +++ b/test/gut/test_fuel_edge_cases.gd @@ -0,0 +1,61 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_fuel_edge_cases.gd +## GUT unit tests for the Fuel System edge cases. +## +## Covers signal emission constraints. + +extends GutTest + +const TEST_CONFIG_PATH: String = "user://test_fuel_edge_cases.cfg" + +var original_settings: GameSettingsResource + +## Per-test setup: Isolate the filesystem and ensure a clean memory state. +## :rtype: void +func before_each() -> void: + original_settings = Globals.settings + if FileAccess.file_exists(TEST_CONFIG_PATH): + DirAccess.remove_absolute(TEST_CONFIG_PATH) + + # Reset global settings to a fresh instance to prevent state leakage + Globals.settings = GameSettingsResource.new() + + # NEW: Silence logs the correct way (without stubbing a real Singleton). + # This prevents the "Instance of a Double was expected" error. + Globals.settings.current_log_level = Globals.LogLevel.NONE + + +## Per-test cleanup: Remove temporary configuration files. +## :rtype: void +func after_each() -> void: + Globals.settings = original_settings + if FileAccess.file_exists(TEST_CONFIG_PATH): + DirAccess.remove_absolute(TEST_CONFIG_PATH) + + +## test_fuel_depleted_signal_fires_once | Signal Constraints | Verify fuel_depleted does not spam | Single emission +## :rtype: void +func test_fuel_depleted_signal_fires_once() -> void: + gut.p("Testing: 'fuel_depleted' signal must emit exactly once when reaching zero.") + + watch_signals(Globals.settings) + + # 1. Start with a positive amount + Globals.settings.current_fuel = 10.0 + + # 2. Drain to exactly zero (this should trigger the first and only emission) + Globals.settings.current_fuel = 0.0 + + assert_signal_emitted(Globals.settings, "fuel_depleted", "Signal should fire when transitioning to zero.") + + # 3. Simulate continued consumption attempts while already empty + # The setter should clamp this and prevent further signal emissions + Globals.settings.current_fuel -= 5.0 + Globals.settings.current_fuel -= 5.0 + + assert_signal_emit_count(Globals.settings, "fuel_depleted", 1, "Signal must not spam when fuel is already depleted.") + +# NOTE: The test_fuel_persistence test was removed from this file. +# current_fuel is volatile and no longer saved to disk. +# Proper persistence testing for max_fuel is handled in test_fuel_persistence_integration.gd. diff --git a/test/gut/test_fuel_edge_cases.gd.uid b/test/gut/test_fuel_edge_cases.gd.uid new file mode 100644 index 00000000..bcbb469a --- /dev/null +++ b/test/gut/test_fuel_edge_cases.gd.uid @@ -0,0 +1 @@ +uid://cw5usn01etq5t diff --git a/test/gut/test_fuel_integration.gd b/test/gut/test_fuel_integration.gd new file mode 100644 index 00000000..8b0df78d --- /dev/null +++ b/test/gut/test_fuel_integration.gd @@ -0,0 +1,55 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_fuel_integration_gut.gd +## GUT integration tests for Fuel System signals and persistence. +extends "res://addons/gut/test.gd" + +const TEST_CONFIG_PATH: String = "user://test_fuel_persistence.cfg" + + +## Per-test setup: Isolate filesystem and stub logging. +## :rtype: void +func before_each() -> void: + if FileAccess.file_exists(TEST_CONFIG_PATH): + DirAccess.remove_absolute(TEST_CONFIG_PATH) + stub(Globals, 'log_message').to_do_nothing() + Globals.settings = GameSettingsResource.new() + + +## Per-test cleanup: Remove temp config. +## :rtype: void +func after_each() -> void: + if FileAccess.file_exists(TEST_CONFIG_PATH): + DirAccess.remove_absolute(TEST_CONFIG_PATH) + +# --- 1. Signal Tests (Observer Pattern) --- + +## test_fuel_depletion_signal_emitted_once | Ensure signal fires exactly once +## :rtype: void +func test_fuel_depletion_signal_emitted_once() -> void: + gut.p("Testing: Depletion signal fires once upon hitting 0.0.") + watch_signals(Globals.settings) + + Globals.settings.current_fuel = 0.1 + Globals.settings.current_fuel = 0.0 # Deplete + + assert_signal_emitted(Globals.settings, "fuel_depleted", "Signal should fire at zero") + + # Simulate continued consumption attempt to check for signal spam + Globals.settings.current_fuel = 0.0 + assert_signal_emit_count(Globals.settings, "fuel_depleted", 1, "Signal should not fire twice") + +# --- 2. Persistence Tests --- + +## test_persistence_invalid_types_fallback | Ensure robustness against invalid data +## :rtype: void +func test_persistence_invalid_types_fallback() -> void: + gut.p("Testing: System ignores non-float data in config.") + var default_max: float = Globals.settings.max_fuel + + var config: ConfigFile = ConfigFile.new() + config.set_value("Settings", "max_fuel", "corrupt_string_value") + config.save(TEST_CONFIG_PATH) + + Globals._load_settings(TEST_CONFIG_PATH) + assert_eq(Globals.settings.max_fuel, default_max, "System failed to fallback on invalid type") diff --git a/test/gut/test_fuel_integration.gd.uid b/test/gut/test_fuel_integration.gd.uid new file mode 100644 index 00000000..2159a4b4 --- /dev/null +++ b/test/gut/test_fuel_integration.gd.uid @@ -0,0 +1 @@ +uid://ds0cr6rmj018e diff --git a/test/gut/test_fuel_persistence_integration.gd b/test/gut/test_fuel_persistence_integration.gd new file mode 100644 index 00000000..d5df948b --- /dev/null +++ b/test/gut/test_fuel_persistence_integration.gd @@ -0,0 +1,121 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_fuel_persistence_integration.gd +## GUT unit tests covering fuel system persistence, fallback behaviors, and UI reactivity. + +extends "res://addons/gut/test.gd" +const TEST_CONFIG_PATH: String = "user://test_fuel_integration_settings.cfg" +var _previous_settings: GameSettingsResource + +## Per-test setup: Isolate the filesystem and ensure a clean memory state. +## :rtype: void +func before_each() -> void: + _previous_settings = Globals.settings + # Ensure a clean slate by deleting any leftover test config files + if FileAccess.file_exists(TEST_CONFIG_PATH): + DirAccess.remove_absolute(TEST_CONFIG_PATH) + + # Reset global settings to a fresh instance to prevent state leakage between tests + Globals.settings = GameSettingsResource.new() + +## Per-test cleanup: Remove temporary configuration files. +## :rtype: void +func after_each() -> void: + if FileAccess.file_exists(TEST_CONFIG_PATH): + DirAccess.remove_absolute(TEST_CONFIG_PATH) + Globals.settings = _previous_settings + +## test_fuel_persistence | Config Save/Load | Verify valid max_fuel persists correctly +## :rtype: void +func test_fuel_persistence() -> void: + gut.p("Testing: Saving and loading valid fuel configuration values.") + + # 1. Set specific valid values and explicitly save to the isolated test path + Globals.settings.max_fuel = 150.0 + # NEW: We no longer test current_fuel persistence because it is intentionally volatile + Globals._save_settings(TEST_CONFIG_PATH) + + assert_true(FileAccess.file_exists(TEST_CONFIG_PATH), "Config file must be created on save.") + + # 2. Alter the memory state to guarantee we are actually reading from the disk + Globals.settings.max_fuel = 100.0 + + # 3. Load the settings back from the test file + Globals._load_settings(TEST_CONFIG_PATH) + + # 4. Assert the values were successfully restored + assert_eq(Globals.settings.max_fuel, 150.0, "max_fuel should restore correctly from the config file.") + +## test_persistence_invalid_types_fallback | Config Save/Load | Verify corrupted types fall back safely +## :rtype: void +func test_persistence_invalid_types_fallback() -> void: + gut.p("Testing: Loading invalid data types falls back to defaults safely without crashing.") + + # 1. Manually create a corrupted config file with invalid data types + var config: ConfigFile = ConfigFile.new() + config.set_value("Settings", "max_fuel", "invalid_string_data") # Should be float/int + config.save(TEST_CONFIG_PATH) + + # 2. Establish known safe baseline defaults in memory + Globals.settings.max_fuel = 100.0 + + # 3. Attempt to load the corrupted file + Globals._load_settings(TEST_CONFIG_PATH) + + # 4. Assert that the invalid types were rejected and memory remained intact + assert_eq(Globals.settings.max_fuel, 100.0, "max_fuel must reject string values and retain the safe memory default.") + +## test_persistence_missing_keys_fallback | Config Save/Load | Verify missing keys do not overwrite memory +## :rtype: void +func test_persistence_missing_keys_fallback() -> void: + gut.p("Testing: Missing config keys fall back to resource defaults.") + + # 1. Create a valid config file that completely omits the fuel settings + var config: ConfigFile = ConfigFile.new() + config.set_value("Settings", "difficulty", 2.0) # Include a valid unrelated key + config.save(TEST_CONFIG_PATH) + + # 2. Establish a known memory state for the fuel system + Globals.settings.max_fuel = 120.0 + Globals.settings.difficulty = 1.0 + + # 3. Load the incomplete config file + Globals._load_settings(TEST_CONFIG_PATH) + + # 4. Assert that missing keys did not wipe out the current memory state, but present keys loaded + assert_eq(Globals.settings.max_fuel, 120.0, "max_fuel should retain memory default if missing in config file.") + assert_eq(Globals.settings.difficulty, 2.0, "Present keys (difficulty) should still load successfully.") + +## test_ui_updates_on_fuel_change_signal | Integration | Verify UI elements react to global signals +## :rtype: void +func test_ui_updates_on_fuel_change_signal() -> void: + gut.p("Testing: UI ProgressBar updates reactively via setting_changed signal.") + + # 1. Create a mock UI ProgressBar and safely queue it for deletion to prevent orphans + var progress_bar: ProgressBar = ProgressBar.new() + add_child_autoqfree(progress_bar) + + # 2. Define a local lambda to act as the Observer pattern UI handler + var _on_setting_changed := func(setting_name: String, new_value: Variant) -> void: + # Add a safety check to ensure the lambda doesn't try to access a freed node + if is_instance_valid(progress_bar): + if setting_name == "current_fuel": + progress_bar.value = new_value + elif setting_name == "max_fuel": + progress_bar.max_value = new_value + + # 3. Connect the signal + Globals.settings.setting_changed.connect(_on_setting_changed) + + # 4. Mutate the global resource, which should implicitly fire the signals + Globals.settings.max_fuel = 200.0 + Globals.settings.current_fuel = 150.0 + + # 5. Assert the UI element automatically synchronized with the backend data + assert_eq(progress_bar.max_value, 200.0, "ProgressBar max_value should react automatically to the max_fuel signal.") + assert_eq(progress_bar.value, 150.0, "ProgressBar value should react automatically to the current_fuel signal.") + + # Explicitly disconnect the signal at the end of the test. + # This prevents the lambda from becoming a "ghost" listener that crashes subsequent tests. + if Globals.settings.setting_changed.is_connected(_on_setting_changed): + Globals.settings.setting_changed.disconnect(_on_setting_changed) diff --git a/test/gut/test_fuel_persistence_integration.gd.uid b/test/gut/test_fuel_persistence_integration.gd.uid new file mode 100644 index 00000000..cd627ea7 --- /dev/null +++ b/test/gut/test_fuel_persistence_integration.gd.uid @@ -0,0 +1 @@ +uid://dqa4dwn3p0tby diff --git a/test/gut/test_fuel_resource.gd b/test/gut/test_fuel_resource.gd new file mode 100644 index 00000000..7288777b --- /dev/null +++ b/test/gut/test_fuel_resource.gd @@ -0,0 +1,76 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_fuel_resource.gd +## GUT unit tests for Fuel System core logic in GameSettingsResource. +## +## Covers initialization, deterministic consumption, and clamping. +extends "res://addons/gut/test.gd" + +var fuel_res: GameSettingsResource +const TOLERANCE: float = 0.0001 + + +## Per-test setup: Instantiate a fresh resource. +## :rtype: void +func before_each() -> void: + fuel_res = GameSettingsResource.new() + + # OLD: + # fuel_res.max_fuel = 100.0 + # fuel_res.current_fuel = 100.0 + # fuel_res.base_consumption_rate = 1.0 + + # NEW: Removed the manual property assignments. This ensures test_fuel_initialization() + # genuinely verifies the default values hardcoded within the GameSettingsResource script. + +# --- 1. Initialization Tests --- + +## test_fuel_initialization | Verify fuel initializes to max capacity +## :rtype: void +func test_fuel_initialization() -> void: + gut.p("Testing: Fuel should default to max_capacity on init.") + assert_eq(fuel_res.current_fuel, fuel_res.max_fuel, "Initial fuel must match max capacity") + +# --- 2. Consumption & Clamping Tests --- + +## test_fuel_consumption_static_exact | Validate deterministic fuel consumption +## :rtype: void +func test_fuel_consumption_static_exact() -> void: + gut.p("Testing: Deterministic fuel drain over fixed delta steps.") + var start_fuel: float = fuel_res.current_fuel + var delta: float = 0.1 + var steps: int = 10 + var rate: float = fuel_res.base_consumption_rate + + for i in range(steps): + # Manual subtraction to simulate the physics/timer logic + fuel_res.current_fuel -= rate * delta + + var expected: float = start_fuel - (rate * delta * steps) + assert_almost_eq(fuel_res.current_fuel, expected, TOLERANCE, "Fuel drain calculation mismatch") + +## test_fuel_not_negative | Ensure fuel is clamped at zero +## :rtype: void +func test_fuel_not_negative() -> void: + gut.p("Testing: Fuel setter must clamp values to 0.0.") + fuel_res.current_fuel = 0.5 + fuel_res.current_fuel -= 10.0 # Force negative via subtraction + assert_eq(fuel_res.current_fuel, 0.0, "Fuel should never be negative") + +# --- 3. Refuel Tests --- + +## test_refuel_basic | Verify refuel increases fuel correctly +## :rtype: void +func test_refuel_basic() -> void: + gut.p("Testing: Refuel logic adds to current stock.") + fuel_res.current_fuel = 50.0 + fuel_res.refuel(20.0) + assert_eq(fuel_res.current_fuel, 70.0, "Refuel amount not correctly added") + +## test_refuel_clamped_to_max | Ensure refuel does not exceed capacity +## :rtype: void +func test_refuel_clamped_to_max() -> void: + gut.p("Testing: Refuel logic clamps at max_fuel.") + fuel_res.current_fuel = 95.0 + fuel_res.refuel(20.0) + assert_eq(fuel_res.current_fuel, 100.0, "Fuel exceeded max_capacity after refuel") diff --git a/test/gut/test_fuel_resource.gd.uid b/test/gut/test_fuel_resource.gd.uid new file mode 100644 index 00000000..cbf1e3d5 --- /dev/null +++ b/test/gut/test_fuel_resource.gd.uid @@ -0,0 +1 @@ +uid://bjmdhwkq0aarl diff --git a/test/gut/test_fuel_ui.gd b/test/gut/test_fuel_ui.gd new file mode 100644 index 00000000..2c1aaa88 --- /dev/null +++ b/test/gut/test_fuel_ui.gd @@ -0,0 +1,29 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_fuel_ui_gut.gd +## Unit tests for UI reactivity to fuel updates. +extends "res://addons/gut/test.gd" + +## test_ui_updates_on_fuel_change_signal | Validate UI reacts to fuel updates +## :rtype: void +var original_fuel: float + +func before_each() -> void: + original_fuel = Globals.settings.current_fuel + +func after_each() -> void: + Globals.settings.current_fuel = original_fuel + +func test_ui_updates_on_fuel_change_signal() -> void: + gut.p("Testing: UI Progressbar reflects resource value via signal.") + var fuel_bar: ProgressBar = ProgressBar.new() + add_child_autofree(fuel_bar) + + # Connect UI to the settings observer + Globals.settings.setting_changed.connect(func(name: String, val: Variant) -> void: + if name == "current_fuel": + fuel_bar.value = float(val) + ) + + Globals.settings.current_fuel = 45.5 + assert_eq(fuel_bar.value, 45.5, "UI failed to update from fuel signal") diff --git a/test/gut/test_fuel_ui.gd.uid b/test/gut/test_fuel_ui.gd.uid new file mode 100644 index 00000000..13dcecc3 --- /dev/null +++ b/test/gut/test_fuel_ui.gd.uid @@ -0,0 +1 @@ +uid://c7e4p4hk75tq0 diff --git a/test/gut/test_player_lifecycle.gd b/test/gut/test_player_lifecycle.gd new file mode 100644 index 00000000..4310a84b --- /dev/null +++ b/test/gut/test_player_lifecycle.gd @@ -0,0 +1,74 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_player_lifecycle.gd +## GUT unit tests for player node lifecycle, specifically _exit_tree cleanup. + +extends "res://addons/gut/test.gd" + +var main_scene: Node +var player_root: Node2D +var original_settings: GameSettingsResource + +## Per-test setup: Isolate the environment. +## :rtype: void +func before_each() -> void: + original_settings = Globals.settings + Globals.settings = GameSettingsResource.new() + +## Per-test cleanup: Restore global state. +## :rtype: void +func after_each() -> void: + Globals.settings = original_settings + + # NEW: Reverted to a hard free(). + # queue_free() delays deletion, causing GUT to falsely report the entire scene as orphans. + # A hard free() executes instantly, ensuring 0 lingering scene nodes when GUT checks memory. + if is_instance_valid(main_scene): + main_scene.free() + +## test_exit_tree_disconnects_signals | Lifecycle | Verify clean signal severing +## :rtype: void +func test_exit_tree_disconnects_signals() -> void: + gut.p("Testing: Player _exit_tree properly disconnects global signals.") + + # 1. Instantiate and add to tree to trigger _ready() and the signal connections + main_scene = load("res://scenes/main_scene.tscn").instantiate() + add_child(main_scene) + player_root = main_scene.get_node("Player") + + assert_true( + Globals.settings.setting_changed.is_connected(player_root._on_setting_changed), + "setting_changed must be connected after player enters the tree." + ) + assert_true( + Globals.settings.fuel_depleted.is_connected(player_root._on_player_out_of_fuel), + "fuel_depleted must be connected after player enters the tree." + ) + + # NEW: 2. Instead of remove_child() (which breaks the tree), manually call the lifecycle function + player_root._exit_tree() + + # 3. Assert the signals were cleanly severed + assert_false( + Globals.settings.setting_changed.is_connected(player_root._on_setting_changed), + "setting_changed must be completely disconnected after player leaves the tree." + ) + assert_false( + Globals.settings.fuel_depleted.is_connected(player_root._on_player_out_of_fuel), + "fuel_depleted must be completely disconnected after player leaves the tree." + ) + +## test_exit_tree_safe_without_globals | Safety | Verify no crashes on early exit +## :rtype: void +func test_exit_tree_safe_without_globals() -> void: + gut.p("Testing: Player _exit_tree does not crash if Globals.settings is null.") + + Globals.settings = null + + # Instantiate manually without adding to the tree (bypasses _ready) + main_scene = load("res://scenes/main_scene.tscn").instantiate() + player_root = main_scene.get_node("Player") + + player_root._exit_tree() + + assert_true(true, "_exit_tree handled a null Globals.settings state gracefully.") diff --git a/test/gut/test_player_lifecycle.gd.uid b/test/gut/test_player_lifecycle.gd.uid new file mode 100644 index 00000000..f6bc95cf --- /dev/null +++ b/test/gut/test_player_lifecycle.gd.uid @@ -0,0 +1 @@ +uid://ch6bm03vu74ne