diff --git a/project.godot b/project.godot index 4b4c0754..8fee4e35 100644 --- a/project.godot +++ b/project.godot @@ -36,6 +36,7 @@ Globals="*res://sources/language_tool/globals.gd" LessonLogger="*res://sources/utils/autoloads/lesson_logger.gd" MusicManager="*res://sources/utils/autoloads/music_manager.tscn" OpeningCurtain="*res://sources/utils/autoloads/opening_curtain.tscn" +SceneLoader="*res://sources/utils/autoloads/scene_loader.gd" ServerManager="*res://sources/utils/autoloads/server_manager.tscn" UnicodeNormalizer="*res://sources/utils/autoloads/unicode_normalizer.gd" UserDataManager="*res://sources/utils/autoloads/user_data_manager.gd" diff --git a/sources/gardens/gardens.gd b/sources/gardens/gardens.gd index fa2a17cd..9f3f57dd 100644 --- a/sources/gardens/gardens.gd +++ b/sources/gardens/gardens.gd @@ -20,21 +20,25 @@ const LESSON_BUTTON_OUTLINE_RADIUS: float = 192.0 # button center, mirroring the movie icon just above it. The 384x384 button has # its center at y=192; the label (vertical-centered) then centers at y=(this+384)/2. const LESSON_BUTTON_LABEL_TOP_OFFSET: float = 104.0 -const GARDEN_SCENES: Array[PackedScene] = [ - preload("res://resources/gardens/garden_01.tscn"), - preload("res://resources/gardens/garden_02.tscn"), - preload("res://resources/gardens/garden_03.tscn"), - preload("res://resources/gardens/garden_04.tscn"), - preload("res://resources/gardens/garden_05.tscn"), - preload("res://resources/gardens/garden_06.tscn"), - preload("res://resources/gardens/garden_07.tscn"), - preload("res://resources/gardens/garden_08.tscn"), - preload("res://resources/gardens/garden_09.tscn"), - preload("res://resources/gardens/garden_10.tscn"), - preload("res://resources/gardens/garden_11.tscn"), - preload("res://resources/gardens/garden_12.tscn"), +# Loaded on demand instead of preloaded: this script never unloads (it has +# static variables), so preloaded constants would pin every garden's assets in +# memory for the whole app lifetime — including while minigames run, which +# OOM-crashes low-memory devices. +const GARDEN_SCENE_PATHS: Array[String] = [ + "res://resources/gardens/garden_01.tscn", + "res://resources/gardens/garden_02.tscn", + "res://resources/gardens/garden_03.tscn", + "res://resources/gardens/garden_04.tscn", + "res://resources/gardens/garden_05.tscn", + "res://resources/gardens/garden_06.tscn", + "res://resources/gardens/garden_07.tscn", + "res://resources/gardens/garden_08.tscn", + "res://resources/gardens/garden_09.tscn", + "res://resources/gardens/garden_10.tscn", + "res://resources/gardens/garden_11.tscn", + "res://resources/gardens/garden_12.tscn", ] -const LOOK_AND_LEARN_SCENE: PackedScene = preload("res://sources/look_and_learn/look_and_learn.tscn") +const LOOK_AND_LEARN_SCENE_PATH: String = "res://sources/look_and_learn/look_and_learn.tscn" const BOSS_BUTTON_SCENE: PackedScene = preload("res://sources/gardens/boss_button.tscn") const BOSS_MINIGAME_SCENE_PATH: String = "res://sources/minigames/boss/boss_minigame.tscn" const GARDEN_SIZE: int = 2400 @@ -71,7 +75,6 @@ static var cached_layout_lessons: int = 0 @export var minigames_body_icons: Array[Texture] = [] @export var minigames_face_icons: Array[Texture] = [] -var _minigame_scene_cache: Dictionary = {} var lessons: Dictionary = {} var _gardens_layout: GardensLayout var points: Array[Array] = [] @@ -988,20 +991,13 @@ func _lesson_button_label_color(status: StudentProgression.Status) -> Color: return lesson_button.unlocked_label_color -func _get_minigame_scene(scene_index: int) -> PackedScene: +func _get_minigame_scene_path(scene_index: int) -> String: if scene_index < 0 or scene_index >= minigame_scene_paths.size(): - return null - if _minigame_scene_cache.has(scene_index): - return _minigame_scene_cache[scene_index] as PackedScene + return "" var scene_path: String = minigame_scene_paths[scene_index] - if scene_path.is_empty(): - return null - var scene_resource: Resource = load(scene_path) - if scene_resource is PackedScene: - var packed_scene: PackedScene = scene_resource as PackedScene - _minigame_scene_cache[scene_index] = packed_scene - return packed_scene - return null + if scene_path.is_empty() or not ResourceLoader.exists(scene_path): + return "" + return scene_path func _count_completed_minigames(lesson_number: int) -> int: @@ -1073,7 +1069,8 @@ func add_gardens() -> void: for layout_index: int in range(gardens_layout.gardens.size()): Log.trace("Gardens: Preparing garden %s with layout index %s" % [str(garden_index), str(layout_index)]) var garden_layout: GardenLayout = gardens_layout.gardens[layout_index] - var garden: Garden = GARDEN_SCENES[layout_index].instantiate() + var garden_scene: PackedScene = load(GARDEN_SCENE_PATHS[layout_index]) as PackedScene + var garden: Garden = garden_scene.instantiate() garden_parent.add_child(garden) garden.garden_index = garden_index garden_index += 1 @@ -1422,7 +1419,7 @@ func _on_lesson_button_pressed() -> void: current_garden_index = current_garden.garden_index, look_and_learn_completed = false } - get_tree().change_scene_to_packed(LOOK_AND_LEARN_SCENE) + SceneLoader.change_scene(LOOK_AND_LEARN_SCENE_PATH) func _on_boss_button_pressed(lesson_number: int, garden_index: int) -> void: @@ -1438,7 +1435,7 @@ func _on_boss_button_pressed(lesson_number: int, garden_index: int) -> void: skip_minigame_layout = true, boss_gate_lesson = lesson_number } - get_tree().change_scene_to_file(BOSS_MINIGAME_SCENE_PATH) + SceneLoader.change_scene(BOSS_MINIGAME_SCENE_PATH) func _on_final_boss_button_pressed(lesson_number: int, garden_index: int) -> void: @@ -1455,14 +1452,14 @@ func _on_final_boss_button_pressed(lesson_number: int, garden_index: int) -> voi boss_gate_lesson = lesson_number, is_final_boss = true } - get_tree().change_scene_to_file(BOSS_MINIGAME_SCENE_PATH) + SceneLoader.change_scene(BOSS_MINIGAME_SCENE_PATH) func _on_minigame_button_pressed(scene_index: int, minigame_number: int) -> void: if is_locked: return - var minigame_scene: PackedScene = _get_minigame_scene(scene_index) - if not minigame_scene: + var scene_path: String = _get_minigame_scene_path(scene_index) + if scene_path.is_empty(): Log.error("Gardens: Missing minigame scene for index %d" % scene_index) return # Block a second wedge click during the curtain-close await below. @@ -1476,7 +1473,7 @@ func _on_minigame_button_pressed(scene_index: int, minigame_number: int) -> void minigame_number = minigame_number, minigame_completed = false } - get_tree().change_scene_to_packed(minigame_scene) + SceneLoader.change_scene(scene_path) func _on_scroll_container_gui_input(event: InputEvent) -> void: @@ -1515,7 +1512,7 @@ func _scroll_by_garden(p_direction: int) -> void: func _confirm_back_button_pressed() -> void: UserDataManager.logout_student() await (OpeningCurtain as OpeningCurtainClass).close() - get_tree().change_scene_to_file("res://sources/menus/login/login.tscn") + SceneLoader.change_scene("res://sources/menus/login/login.tscn") func _on_back_button_button_down() -> void: diff --git a/sources/look_and_learn/look_and_learn.gd b/sources/look_and_learn/look_and_learn.gd index b85f13b1..a84a0996 100644 --- a/sources/look_and_learn/look_and_learn.gd +++ b/sources/look_and_learn/look_and_learn.gd @@ -160,7 +160,7 @@ func _back_to_gardens() -> void: Log.info("LookAndLearn: Returning to gardens for lesson %d" % lesson_nb) await (OpeningCurtain as OpeningCurtainClass).close() Gardens.transition_data = gardens_data - get_tree().change_scene_to_file("res://sources/gardens/gardens.tscn") + SceneLoader.change_scene("res://sources/gardens/gardens.tscn") func _on_back_button_pressed() -> void: diff --git a/sources/menus/login/login.gd b/sources/menus/login/login.gd index 58e00e98..29f41354 100644 --- a/sources/menus/login/login.gd +++ b/sources/menus/login/login.gd @@ -2,7 +2,7 @@ extends Control const TEACHER_PASSWORD: String = "42" const BACK_SCENE_PATH: String = "res://sources/menus/main/main_menu.tscn" -const NEXT_SCENE: PackedScene = preload("res://sources/gardens/gardens.tscn") +const NEXT_SCENE_PATH: String = "res://sources/gardens/gardens.tscn" const TEACHER_SCENE_PATH: String = "res://sources/menus/settings/teacher_settings.tscn" const DEVELOPER_SCENE_PATH: String = "res://sources/menus/settings/developer_settings.tscn" const PACKAGE_LOADER_SCENE_PATH: String = "res://sources/menus/language_selection/package_downloader.tscn" @@ -73,8 +73,7 @@ func _on_code_keyboard_password_entered(password: String) -> void: await kalulu.play_kalulu_speech(right_password_speech) await OpeningCurtain.close() Log.trace("LoginScreen: Start loading next scene") - get_tree().change_scene_to_packed(NEXT_SCENE) - Log.trace("LoginScreen: End loading next scene") + SceneLoader.change_scene(NEXT_SCENE_PATH) else: Log.warn("LoginScreen: Unknown student code entered (length=%d)" % password.length()) kalulu_button.hide() diff --git a/sources/minigames/base/base_minigame.gd b/sources/minigames/base/base_minigame.gd index f8ecbf95..75d1b588 100644 --- a/sources/minigames/base/base_minigame.gd +++ b/sources/minigames/base/base_minigame.gd @@ -435,7 +435,7 @@ func _go_back_to_the_garden() -> void: _save_logs() Gardens.transition_data = gardens_data - get_tree().change_scene_to_file("res://sources/gardens/gardens.tscn") + SceneLoader.change_scene("res://sources/gardens/gardens.tscn") func _play_stimulus() -> void: diff --git a/sources/minigames/frog/lilypad_track.gd b/sources/minigames/frog/lilypad_track.gd index 83fc810e..04b24973 100644 --- a/sources/minigames/frog/lilypad_track.gd +++ b/sources/minigames/frog/lilypad_track.gd @@ -5,8 +5,7 @@ signal lilypad_in_center(lilypad: Lilypad) const LILYPAD_SCENE_PATH: String = "res://sources/minigames/frog/lilypad.tscn" -static var _lilypad_scene: PackedScene - +var _lilypad_scene: PackedScene var top_to_bottom: bool = false var is_stopped: bool = false var is_cleared: bool = false diff --git a/sources/utils/autoloads/scene_loader.gd b/sources/utils/autoloads/scene_loader.gd new file mode 100644 index 00000000..af414d9e --- /dev/null +++ b/sources/utils/autoloads/scene_loader.gd @@ -0,0 +1,70 @@ +extends Node +## Scene changer that keeps peak memory low on low-end devices. +## +## [method SceneTree.change_scene_to_file] loads the new scene while the old +## one is still fully resident, so transitions between heavy scenes (gardens, +## minigames) momentarily need the memory of both — enough to OOM-crash old +## tablets. This helper frees the current scene first, then streams the new +## scene from disk on a background thread so the main thread keeps rendering +## frames instead of freezing. +## +## Callers are expected to have covered the screen (OpeningCurtain closed) +## before calling [method change_scene]; the curtain stays visible while the +## tree has no current scene. + +## Scene shown if the requested scene fails to load, so the player is never +## stuck on a black screen. +const FALLBACK_SCENE_PATH: String = "res://sources/menus/main/main_menu.tscn" + +var _is_changing: bool = false + + +func change_scene(scene_path: String) -> void: + if _is_changing: + Log.warn("SceneLoader: Change to %s requested while another change is in progress; ignoring" % scene_path) + return + if not ResourceLoader.exists(scene_path): + Log.error("SceneLoader: Scene does not exist: %s" % scene_path) + return + _is_changing = true + # Defer so the calling scene is never freed while one of its methods is + # still on the stack. + _change_scene_deferred.call_deferred(scene_path) + + +func _change_scene_deferred(scene_path: String) -> void: + var tree: SceneTree = get_tree() + # Free the old scene before loading the new one: this roughly halves the + # memory peak of the transition. + if tree.current_scene: + tree.unload_current_scene() + var error: Error = ResourceLoader.load_threaded_request(scene_path) + if error != OK: + Log.error("SceneLoader: Could not start loading %s (error %s)" % [scene_path, error_string(error)]) + _finish_with_fallback(tree, scene_path) + return + var status: ResourceLoader.ThreadLoadStatus = ResourceLoader.load_threaded_get_status(scene_path) + while status == ResourceLoader.THREAD_LOAD_IN_PROGRESS: + await tree.process_frame + status = ResourceLoader.load_threaded_get_status(scene_path) + if status != ResourceLoader.THREAD_LOAD_LOADED: + Log.error("SceneLoader: Failed to load %s (status %d)" % [scene_path, status]) + _finish_with_fallback(tree, scene_path) + return + var packed_scene: PackedScene = ResourceLoader.load_threaded_get(scene_path) as PackedScene + if not packed_scene: + Log.error("SceneLoader: %s is not a PackedScene" % scene_path) + _finish_with_fallback(tree, scene_path) + return + error = tree.change_scene_to_packed(packed_scene) + if error != OK: + Log.error("SceneLoader: Could not change to %s (error %s)" % [scene_path, error_string(error)]) + _finish_with_fallback(tree, scene_path) + return + _is_changing = false + + +func _finish_with_fallback(tree: SceneTree, failed_scene_path: String) -> void: + _is_changing = false + if failed_scene_path != FALLBACK_SCENE_PATH: + tree.change_scene_to_file(FALLBACK_SCENE_PATH) diff --git a/sources/utils/autoloads/scene_loader.gd.uid b/sources/utils/autoloads/scene_loader.gd.uid new file mode 100644 index 00000000..e8553604 --- /dev/null +++ b/sources/utils/autoloads/scene_loader.gd.uid @@ -0,0 +1 @@ +uid://bm3xnmft2e4ul