From c9fef83b3a2c21a92abd42a44614baa95a86e6fa Mon Sep 17 00:00:00 2001 From: Tiger Jove Date: Fri, 16 Jan 2026 13:09:09 +0100 Subject: [PATCH] chore: refactored save game initialization logic feat: save game factory method create_new() --- src/dev-util/savegame.gd | 295 +++++++++++++++++----------------- src/ui/menu_main/main_menu.gd | 2 +- 2 files changed, 147 insertions(+), 150 deletions(-) diff --git a/src/dev-util/savegame.gd b/src/dev-util/savegame.gd index 97e63a5..c8b476f 100644 --- a/src/dev-util/savegame.gd +++ b/src/dev-util/savegame.gd @@ -1,115 +1,95 @@ class_name SaveGame extends Resource +## Save game data container +## This is primarily a data class - file I/O helpers are thin wrappers around ResourceSaver/Loader -var _is_initialised: bool = false +# === Computed Properties === var current_room_path: String: - get(): - return Main.room_paths[current_room] + get: + return Main.room_paths[current_room] if Main else "" -@export var filepath: String: - set(value): - filepath = value - if _is_initialised: - changed.emit() - -@export var unique_save_name: String = "frame_of_mind_%s_%s" % [Time.get_date_string_from_system(), Time.get_time_string_from_system().replace(":", "-")] + "-" + str(randi()): - set(value): - unique_save_name = value - if _is_initialised: changed.emit() - -@export var current_room: State.rooms = State.rooms.NULL: - set(value): - current_room = value - if _is_initialised: changed.emit() -@export_flags("Intro", "Childhood", "Voice Training", "Jui Jutsu") var mementos_complete: int = 0: - set(value): - mementos_complete = value - if _is_initialised: changed.emit() -@export_flags_2d_physics var sequences_enabled: int = 63: - set(value): - sequences_enabled = value - if _is_initialised: changed.emit() -@export var board_state: Dictionary = {"cards": {}, "stickies": {}, "randoms": []}: - set(value): - board_state = value - if _is_initialised: changed.emit() -@export var childhood_mementos: Dictionary = {"cards": {}, "stickies": {}, "randoms": []}: - set(value): - childhood_mementos = value - if _is_initialised: changed.emit() -@export var is_childhood_board_complete: bool = false -@export var player_position: Vector3 = Vector3.ZERO: - set(value): - player_position = value - if _is_initialised: changed.emit() -@export var player_yaw: float = 0.0: - set(value): - player_yaw = value - if _is_initialised: changed.emit() -@export var player_pitch: float = 0.0: - set(value): - player_pitch = value - if _is_initialised: changed.emit() -@export var thumbnail: Texture = preload("res://import/interface-elements/empty_save_slot.png"): - set(value): - thumbnail = value - if _is_initialised: changed.emit() -@export var last_saved: int = int(Time.get_unix_time_from_system()): - set(value): - last_saved = value - if _is_initialised: changed.emit() - -@export var is_valid: bool = false -@export var is_demo: bool = OS.has_feature("Demo") -@export var is_empty: bool = true: - get(): +var is_empty: bool: + get: return not FileAccess.file_exists(filepath) or (current_room == State.rooms.NULL) +# === Data Fields === + +@export var filepath: String = "" +@export var unique_save_name: String = "" +@export var current_room: State.rooms = State.rooms.NULL +@export_flags("Intro", "Childhood", "Voice Training", "Jui Jutsu") var mementos_complete: int = 0 +@export_flags_2d_physics var sequences_enabled: int = 63 +@export var board_state: Dictionary = {"cards": {}, "stickies": {}, "randoms": []} +@export var childhood_mementos: Dictionary = {"cards": {}, "stickies": {}, "randoms": []} +@export var is_childhood_board_complete: bool = false +@export var player_position: Vector3 = Vector3.ZERO +@export var player_yaw: float = 0.0 +@export var player_pitch: float = 0.0 +@export var thumbnail: Texture = preload("res://import/interface-elements/empty_save_slot.png") +@export var last_saved: int = 0 +@export var is_valid: bool = false +@export var is_demo: bool = false + +# === Editor Conveniences === + @export var save_manually: bool = false: set(val): - if val: save_to_file(thumbnail) + if val: + save_to_file(thumbnail) func _validate_property(property: Dictionary): - if property.name == filepath: + if property.name == "filepath": property.usage |= PROPERTY_USAGE_UPDATE_ALL_IF_MODIFIED - if property.name == "thumbnail": - property.usage |= PROPERTY_USAGE_READ_ONLY - if property.name == "is_valid": - property.usage |= PROPERTY_USAGE_READ_ONLY - if property.name == "is_empty": + if property.name in ["thumbnail", "is_valid", "is_empty"]: property.usage |= PROPERTY_USAGE_READ_ONLY -## Creates a NEW save game (for starting a new game) -func _init(filepath_or_debug: String = "") -> void: - if filepath_or_debug == "": - filepath = "%s/%s.tres" % [State.user_saves_path, unique_save_name] - elif filepath_or_debug == "DEBUG": - filepath = "DEBUG" - if OS.has_feature("debug") or OS.has_feature("demo"): - push_warning("Created DEBUG savegame. Progress will not be stored!") - else: - print_debug(get_stack()) - push_error("Created DEBUG savegame outside of demo or debug environment. This is unintentional and will lead to data loss. Please contact support and attatch the stack above.") - else: - filepath = filepath_or_debug - unique_save_name = filepath_or_debug.get_file().get_basename() +# === Static Helpers === + +## Generates a unique save filepath +static func generate_save_path() -> String: + var timestamp := "%s_%s" % [Time.get_date_string_from_system(), Time.get_time_string_from_system().replace(":", "-")] + var unique_name := "frame_of_mind_%s-%d" % [timestamp, randi()] + return "%s/%s.tres" % [State.user_saves_path, unique_name] + +## Creates a new save game with generated filepath +static func create_new() -> SaveGame: + var save := SaveGame.new() + save.filepath = generate_save_path() + save.unique_save_name = save.filepath.get_file().get_basename() + save.is_valid = true + save.is_demo = OS.has_feature("Demo") + save.last_saved = int(Time.get_unix_time_from_system()) # Ensure save directory exists - if filepath != "DEBUG" and not DirAccess.dir_exists_absolute(filepath.get_base_dir()): - DirAccess.make_dir_absolute(filepath.get_base_dir()) + if not DirAccess.dir_exists_absolute(save.filepath.get_base_dir()): + DirAccess.make_dir_absolute(save.filepath.get_base_dir()) - print_debug("Savegame: Creating new save for path: %s" % filepath) - is_valid = true - _is_initialised = true + print_debug("SaveGame: Created new save: %s" % save.filepath) + return save -## Static factory method to load an EXISTING save from disk +## Creates a DEBUG save (not persisted to disk) +static func create_debug() -> SaveGame: + var save := SaveGame.new() + save.filepath = "DEBUG" + save.unique_save_name = "DEBUG" + save.is_valid = true + save.is_demo = OS.has_feature("Demo") + + if OS.has_feature("debug") or OS.has_feature("demo"): + push_warning("Created DEBUG savegame. Progress will not be stored!") + else: + push_error("Created DEBUG savegame outside of demo/debug environment. This will lead to data loss!") + + return save + +## Loads an existing save from disk static func load_from_file(save_filepath: String) -> SaveGame: if not FileAccess.file_exists(save_filepath): push_error("SaveGame: File does not exist: %s" % save_filepath) return null - print_debug("Savegame: Loading from: %s" % save_filepath) + print_debug("SaveGame: Loading from: %s" % save_filepath) var loaded: SaveGame = ResourceLoader.load(save_filepath, "", ResourceLoader.CACHE_MODE_IGNORE) @@ -117,107 +97,124 @@ static func load_from_file(save_filepath: String) -> SaveGame: push_error("Failed to load SaveGame resource from: %s" % save_filepath) return null - # Update filepath to actual location (in case file was moved) + # Update filepath metadata loaded.filepath = save_filepath loaded.unique_save_name = save_filepath.get_file().get_basename() - # Ensure randoms array exists (backwards compatibility) + # Backwards compatibility if "randoms" not in loaded.board_state: loaded.board_state["randoms"] = [] # Load thumbnail separately (not stored in .tres) - var thumbnail_path := "%s/thumbnails/%s.png" % [save_filepath.get_base_dir(), loaded.unique_save_name] - if FileAccess.file_exists(thumbnail_path): - var tmp_img: Image = Image.load_from_file(thumbnail_path) - if tmp_img != null: - loaded.thumbnail = ImageTexture.create_from_image(tmp_img) + _load_thumbnail(loaded) - # Validate the loaded data - loaded.is_valid = loaded.current_room >= 0 \ - and loaded.current_room < State.rooms.keys().size() \ - and loaded.validate_board_state() + # Validate + loaded.is_valid = loaded._validate() if not loaded.is_valid: push_error("Validation of loaded save failed: %s" % save_filepath) return null - loaded._is_initialised = true - return loaded -## Captures current player position and camera rotation +## Helper to load thumbnail from separate PNG file +static func _load_thumbnail(save: SaveGame) -> void: + var thumbnail_path := "%s/thumbnails/%s.png" % [save.filepath.get_base_dir(), save.unique_save_name] + if FileAccess.file_exists(thumbnail_path): + var img := Image.load_from_file(thumbnail_path) + if img: + save.thumbnail = ImageTexture.create_from_image(img) + +# === Instance Methods === + +## Captures current player position/rotation from State.player func capture_player_state() -> void: - if State.player: - player_position = State.player.global_position - # Access yaw and pitch nodes - var yaw: Node3D = State.player.get_node("Yaw") - var pitch: Node3D = yaw.get_node("Pitch") - player_yaw = yaw.rotation.y - player_pitch = pitch.rotation.x - print_debug("SaveGame: Captured player state - pos: %s, yaw: %.2f, pitch: %.2f" % [player_position, player_yaw, player_pitch]) + if not State.player: + return + player_position = State.player.global_position + var yaw: Node3D = State.player.get_node("Yaw") + var pitch: Node3D = yaw.get_node("Pitch") + player_yaw = yaw.rotation.y + player_pitch = pitch.rotation.x + + print_debug("SaveGame: Captured player state - pos: %s, yaw: %.2f, pitch: %.2f" % [player_position, player_yaw, player_pitch]) + +## Saves to disk with thumbnail func save_to_file(screen_shot: Texture) -> void: - print_debug("Savegame: Saving to file: %s" % filepath) - if filepath == "DEBUG": - push_warning("Savegame: Saving DEBUG save skipped. This is intentional.") + push_warning("SaveGame: DEBUG save skipped (intentional).") return if current_room == State.rooms.NULL: - push_warning("Savegame: Not saving empty savegame.") + push_warning("SaveGame: Not saving empty savegame.") return - # Capture player state before saving - capture_player_state() + print_debug("SaveGame: Saving to file: %s" % filepath) + # Capture current state + capture_player_state() last_saved = int(Time.get_unix_time_from_system()) - # Save thumbnail separately as PNG - var thumbnail_image: Image = screen_shot.get_image() - thumbnail_image.convert(Image.Format.FORMAT_RGB8) - thumbnail_image.linear_to_srgb() - thumbnail_image.resize(384, 216, Image.INTERPOLATE_LANCZOS) # nonexistent call in ViewportTexture - thumbnail_image.crop(384, 216) + # Save thumbnail + _save_thumbnail(screen_shot) + # Save resource + var result := ResourceSaver.save(self, filepath) + if result != OK: + push_error("Failed to save resource to: %s (Error: %d)" % [filepath, result]) + else: + print_debug("Successfully saved to: %s" % filepath) + +## Processes and saves thumbnail as PNG +func _save_thumbnail(screen_shot: Texture) -> void: + var img: Image = screen_shot.get_image() + img.convert(Image.Format.FORMAT_RGB8) + img.linear_to_srgb() + img.resize(384, 216, Image.INTERPOLATE_LANCZOS) + img.crop(384, 216) + + # Ensure thumbnails directory exists var save_dir := DirAccess.open(filepath.get_base_dir()) if not save_dir.dir_exists("thumbnails"): save_dir.make_dir("thumbnails") - var thumbnail_path: String = "%s/thumbnails/%s.png" % [filepath.get_base_dir(), unique_save_name] - thumbnail_image.save_png(thumbnail_path) + var thumbnail_path := "%s/thumbnails/%s.png" % [filepath.get_base_dir(), unique_save_name] + img.save_png(thumbnail_path) - # Save the resource using Godot's native serialization - var save_result := ResourceSaver.save(self, filepath) - if save_result != OK: - push_error("Failed to save resource to: %s (Error code: %d)" % [filepath, save_result]) - else: - print_debug("Successfully saved to: %s" % filepath) +## Validates save data integrity +func _validate() -> bool: + if current_room < 0 or current_room >= State.rooms.keys().size(): + return false + return validate_board_state() +# === Helper Methods === func calculate_completed_sequences() -> int: - var i: int = mementos_complete - ((mementos_complete >> 1) & 0x55555555); - i = (i & 0x33333333) + ((i >> 2) & 0x33333333); - i = (i + (i >> 4)) & 0x0F0F0F0F; - i *= 0x01010101; - return i >> 24; + var i: int = mementos_complete - ((mementos_complete >> 1) & 0x55555555) + i = (i & 0x33333333) + ((i >> 2) & 0x33333333) + i = (i + (i >> 4)) & 0x0F0F0F0F + i *= 0x01010101 + return i >> 24 func calculate_total_connections() -> int: - var connections:= 0 - + var connections := 0 for sticky_position in board_state.stickies.values(): connections += int(sticky_position is String) - return connections func validate_board_state() -> bool: - if board_state.keys().has("cards") and board_state.keys().has("stickies"): - for card in board_state.cards.values(): - if not card is Vector2: - push_error("Save %s could not be parsed: Corrupted Cards." % unique_save_name) - return false - for sticky in board_state.stickies.values(): - if not (sticky is int or sticky is Vector2 or sticky is float or board_state.cards.keys().has(sticky)): - push_error("Save %s could not be parsed: Corrupted Sticky Notes.") - return false - return true - return false + if not board_state.has("cards") or not board_state.has("stickies"): + return false + + for card in board_state.cards.values(): + if not card is Vector2: + push_error("Save %s: Corrupted cards" % unique_save_name) + return false + + for sticky in board_state.stickies.values(): + if not (sticky is int or sticky is Vector2 or sticky is float or board_state.cards.has(sticky)): + push_error("Save %s: Corrupted sticky notes" % unique_save_name) + return false + + return true diff --git a/src/ui/menu_main/main_menu.gd b/src/ui/menu_main/main_menu.gd index ac81034..b36b9cd 100644 --- a/src/ui/menu_main/main_menu.gd +++ b/src/ui/menu_main/main_menu.gd @@ -44,7 +44,7 @@ func _ready() -> void: func _new_game() -> void: print_debug("main_menu.gd: start_new_game()") - State.save_game = SaveGame.new() + State.save_game = SaveGame.create_new() _start_game()