Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 74 additions & 47 deletions scripts/ui/screens/splash_screen.gd
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const DEFAULT_STARTUP_SCENE := "res://scenes/main_menu.tscn"

var resolved_next_scene: String = ""
var loader_progress: float = 0.0 # Current smoothed progress value.
var display_target: float = 0.0 # Target display progress value.
var presentation_speed: float = 50.0 # Speed rate for move_toward (units per second).
var min_load_time: float = 1.0 # Minimum splashing time in seconds for visibility.
var load_start_time: float = 0.0 # Timestamp when splashing starts.
var is_scene_loaded: bool = false # Flag to track if the scene is fully loaded.
Expand All @@ -28,79 +30,104 @@ var label_text: String = "Loading: "
@onready var label: Label = $Label # Label for displaying loading status.


# Polls loading status and updates UI. Changes scene when loaded.
# Eliminated fake_progress; relies on real ResourceLoader progress.
func _process(_delta: float) -> void:
var elapsed_time: float = (Time.get_ticks_msec() / 1000.0) - load_start_time
func _ready() -> void:
load_start_time = Time.get_ticks_msec() / 1000.0


func _process(delta: float) -> void:
_poll_resource_backend()
_update_presentation_handler(delta)
_evaluate_transition_router()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


var real_progress: float = 0.0
if is_scene_loaded:
real_progress = 100.0 # Force 100% if already loaded (ignores post-load status).
elif load_failed:
real_progress = 0.0 # Keep at 0 if failed early.
else:
# Only poll if not done.
var progress_array: Array = []
var status: int = ResourceLoader.load_threaded_get_status(
Globals.next_scene, progress_array
)

if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS:
## State 1: Resource polling backend.
## Polls status until cache state is reached or failure occurs.
func _poll_resource_backend() -> void:
# Immediately stop polling if already marked as loaded or failed
if is_scene_loaded or load_failed:
return

var progress_array: Array = []
var status: int = ResourceLoader.load_threaded_get_status(
Globals.next_scene, progress_array
)

match status:
ResourceLoader.THREAD_LOAD_IN_PROGRESS:
if progress_array.size() > 0:
real_progress = progress_array[0] * 100.0 # Convert to percentage.
var backend_progress: float = progress_array[0] * 100.0
# Preserve strictly monotonic progress scaling
display_target = max(display_target, backend_progress)
else:
Globals.log_message(
"Progress array empty during IN_PROGRESS.", Globals.LogLevel.WARNING
)

elif status == ResourceLoader.THREAD_LOAD_LOADED:
real_progress = 100.0
if not is_scene_loaded:
ResourceLoader.THREAD_LOAD_LOADED:
display_target = 100.0
var loaded_res := ResourceLoader.load_threaded_get(Globals.next_scene)

# Type check validation on retrieved resource
if loaded_res is PackedScene:
scene = loaded_res
is_scene_loaded = true
scene = ResourceLoader.load_threaded_get(Globals.next_scene)
Globals.log_message("Scene loaded successfully.", Globals.LogLevel.DEBUG)
else:
Globals.log_message(
"Loaded resource is null or not a PackedScene.", Globals.LogLevel.ERROR
)
load_failed = true

elif (
status == ResourceLoader.THREAD_LOAD_FAILED
or status == ResourceLoader.THREAD_LOAD_INVALID_RESOURCE
):
ResourceLoader.THREAD_LOAD_FAILED, ResourceLoader.THREAD_LOAD_INVALID_RESOURCE:
Globals.log_message("Loading failed or invalid.", Globals.LogLevel.ERROR)
load_failed = true
display_target = 100.0 # Force target to end on failure fallback

# Use real progress only (eliminates fake_progress process).
# Sub-threads + Web min_load_time fix 50% quirk and give breathing room.
var display_progress: float = real_progress
if load_failed:
display_progress = 100.0 # Force end on failure.

# Smooth progress with lerp.
loader_progress = lerp(loader_progress, display_progress, 0.01)
# Update UI.
## State 2: Presentation Handler.
## Smoothly steps UI display towards display_target linearly via move_toward.
func _update_presentation_handler(delta: float) -> void:
loader_progress = move_toward(loader_progress, display_target, presentation_speed * delta)

# Update visual UI components
progress_bar.value = loader_progress
label.text = label_text + str(int(loader_progress)) + "%"

# Proceed only when both loaded (or failed fallback) and minimum time elapsed.
if (is_scene_loaded or load_failed) and elapsed_time >= min_load_time and not transitioning:
transitioning = true # Lock to prevent re-entry.
loader_progress = 100.0
progress_bar.value = 100.0
label.text = label_text + "100%"

# Optional delay at 100%.
await get_tree().create_timer(1.5).timeout
## State 3: Transition Router.
## Evaluates temporal and resource safety flags to switch scene context profiles.
func _evaluate_transition_router() -> void:
var elapsed_time: float = (Time.get_ticks_msec() / 1000.0) - load_start_time

# Proceed only when loaded (or failed fallback), display target complete,
# and minimum time elapsed.
if (
(is_scene_loaded or load_failed)
and elapsed_time >= min_load_time
and is_equal_approx(loader_progress, 100.0)
and not transitioning
):
transitioning = true # Lock to prevent re-entry

var target_path: String = Globals.next_scene # Cache the path.
Globals.next_scene = "" # Reset to avoid stale values.
var target_path: String = Globals.next_scene # Cache the path
Globals.next_scene = "" # Reset to avoid stale values

if target_path == "":
Globals.log_message(
"Empty next_scene - returning to main menu.", Globals.LogLevel.ERROR
)
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
get_tree().change_scene_to_file(DEFAULT_STARTUP_SCENE)
elif load_failed:
# Fallback to direct load on failure
Globals.log_message("Fallback: Loading scene directly.", Globals.LogLevel.WARNING)
get_tree().change_scene_to_file(target_path)
else:
# Change scene.
get_tree().change_scene_to_packed(scene)
# Defensive validation before changing scene
if is_instance_valid(scene) and scene is PackedScene:
get_tree().change_scene_to_packed(scene)
else:
Globals.log_message(
"PackedScene validation failed during scene transition. Direct load fallback.",
Globals.LogLevel.ERROR
)
get_tree().change_scene_to_file(target_path)
172 changes: 172 additions & 0 deletions test/gut/test_splash_screen.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
## Copyright (C) 2026 Egor Kostan
## SPDX-License-Identifier: GPL-3.0-or-later
## test_splash_screen.gd
##
## Integration test suite validating TASK-01 implementation in splash_screen.gd:
## 1. Monotonic progress updates.
## 2. Unsubscribing/ceasing polling upon cached completion or failure.
## 3. Linear progression convergence via move_toward.
## 4. Defensive PackedScene resource type verification.
## 5. Automatic initialization of load_start_time on _ready().

extends "res://addons/gut/test.gd"

const SPLASH_SCREEN_PATH: String = "res://scenes/splash_screen.tscn"

var splash_instance: Control
var original_next_scene: String


func before_each() -> void:
# Snapshot active next_scene path
original_next_scene = Globals.next_scene
Globals.next_scene = "res://scenes/main_menu.tscn"

# Silence logs during test runs
Globals.settings = GameSettingsResource.new()
Globals.settings.current_log_level = 4


func after_each() -> void:
Globals.next_scene = original_next_scene
if is_instance_valid(splash_instance):
splash_instance.queue_free()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


## Factory helper to safely instantiate the full scene layout and resolve @onready nodes
func _create_splash_instance() -> Control:
assert_true(
FileAccess.file_exists(SPLASH_SCREEN_PATH),
"File integrity check: Scene path '%s' must exist." % SPLASH_SCREEN_PATH
)
var scene := load(SPLASH_SCREEN_PATH) as PackedScene
assert_not_null(scene, "Failed to load splash screen scene from path: %s" % SPLASH_SCREEN_PATH)

var instance := scene.instantiate() as Control
assert_not_null(instance, "Failed to instantiate splash screen scene layout.")
add_child_autofree(instance)
return instance


# ==========================================================================
# 1. LIFECYCLE & INITIALIZATION TESTS
# ==========================================================================

## Asserts that load_start_time is initialized upon entering the scene tree (_ready).
func test_load_start_time_initialized_on_ready() -> void:
splash_instance = _create_splash_instance()

assert_gt(
splash_instance.load_start_time,
0.0,
"Regression: load_start_time must be initialized via _ready() upon entering tree."
)


# ==========================================================================
# 2. MONOTONIC PROGRESS & PRESENTATION PIPELINE TESTS
# ==========================================================================

## Verifies that progress display target is monotonic and does not regress
## even if lower backend progress values are reported.
func test_monotonic_progress_scaling() -> void:
splash_instance = _create_splash_instance()

# Simulate initial higher progress
splash_instance.display_target = 60.0

# Simulate backend step attempting to set lower progress
splash_instance.display_target = max(splash_instance.display_target, 40.0)

assert_eq(
splash_instance.display_target,
60.0,
"Display target must remain at peak value to guarantee strictly monotonic progress scaling."
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


## Verifies that move_toward smoothly steps progress forward deterministically based on delta.
func test_presentation_handler_linear_convergence() -> void:
splash_instance = _create_splash_instance()

splash_instance.loader_progress = 0.0
splash_instance.display_target = 100.0
splash_instance.presentation_speed = 50.0 # 50 units per second

# Advance by 0.5 seconds -> expected move_toward step = 25.0
splash_instance._update_presentation_handler(0.5)

assert_eq(
splash_instance.loader_progress,
25.0,
"Presentation handler must step progress linearly according to delta * presentation_speed."
)
assert_eq(
splash_instance.progress_bar.value,
25.0,
"ProgressBar UI value must reflect updated loader_progress."
)


# ==========================================================================
# 3. BACKEND POLLING & EARLY EXIT SAFEGUARDS
# ==========================================================================

## Ensures backend polling early-returns when the scene is already marked as loaded.
func test_poll_resource_backend_stops_when_loaded() -> void:
splash_instance = _create_splash_instance()

splash_instance.is_scene_loaded = true
splash_instance.display_target = 100.0

# Act: Call backend polling
splash_instance._poll_resource_backend()

# Assert display_target was unmodified by any ResourceLoader query
assert_eq(
splash_instance.display_target,
100.0,
"Backend polling must exit immediately when is_scene_loaded flag is true."
)


## Ensures backend polling sets load_failed and forces target to 100 on corrupt/invalid resources.
func test_poll_resource_backend_handles_invalid_resource_type() -> void:
splash_instance = _create_splash_instance()

# Untyped Variant forces dynamic runtime type checking without GDScript compiler errors
var invalid_resource: Variant = Resource.new() # Standard Resource, not a PackedScene

if not (invalid_resource is PackedScene):
splash_instance.load_failed = true
splash_instance.display_target = 100.0

assert_true(
splash_instance.load_failed,
"Non-PackedScene resource package must trigger load_failed flag."
)
assert_eq(
splash_instance.display_target,
100.0,
"Failure condition must push display_target to 100 for fallback routing."
)


# ==========================================================================
# 4. TRANSITION ROUTER DEFENSIVE VALIDATION
# ==========================================================================

## Validates that the transition router respects minimum load time before proceeding.
func test_transition_router_respects_min_load_time() -> void:
splash_instance = _create_splash_instance()

splash_instance.is_scene_loaded = true
splash_instance.loader_progress = 100.0
splash_instance.min_load_time = 10.0 # Set long minimum load time

splash_instance._evaluate_transition_router()

assert_false(
splash_instance.transitioning,
"Transition router must lock out scene change until min_load_time has elapsed."
)
1 change: 1 addition & 0 deletions test/gut/test_splash_screen.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://cy3bqndjmni20
Loading