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 # === Computed Properties === var current_room_path: String: get: return Main.room_paths[current_room] if Main else "" 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 last_saved: int = 0 @export var is_valid: bool = false @export var is_demo: bool = false var thumbnail: Texture = preload("res://import/interface-elements/empty_save_slot.png") # === Editor Conveniences === @export var save_manually: bool = false: set(val): if val: save_to_file(thumbnail) func _validate_property(property: Dictionary): if property.name == "filepath": property.usage |= PROPERTY_USAGE_UPDATE_ALL_IF_MODIFIED if property.name in ["thumbnail", "is_valid", "is_empty"]: property.usage |= PROPERTY_USAGE_READ_ONLY # === 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 not DirAccess.dir_exists_absolute(save.filepath.get_base_dir()): DirAccess.make_dir_absolute(save.filepath.get_base_dir()) print_debug("SaveGame: Created new save: %s" % save.filepath) return save ## 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) var loaded: SaveGame = ResourceLoader.load(save_filepath, "", ResourceLoader.CACHE_MODE_IGNORE) if not loaded: push_error("Failed to load SaveGame resource from: %s" % save_filepath) return null # Update filepath metadata loaded.filepath = save_filepath loaded.unique_save_name = save_filepath.get_file().get_basename() # Backwards compatibility if "randoms" not in loaded.board_state: loaded.board_state["randoms"] = [] # Load thumbnail separately (not stored in .tres) _load_thumbnail(loaded) # Validate loaded.is_valid = loaded._validate() if not loaded.is_valid: push_error("Validation of loaded save failed: %s" % save_filepath) return null return loaded ## 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 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: if filepath == "DEBUG": push_warning("SaveGame: DEBUG save skipped (intentional).") return if current_room == State.rooms.NULL: push_warning("SaveGame: Not saving empty savegame.") return 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 _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 := "%s/thumbnails/%s.png" % [filepath.get_base_dir(), unique_save_name] img.save_png(thumbnail_path) ## 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 func calculate_total_connections() -> int: 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 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