diff --git a/src/base-environments/volunteer_room/shared_flat.gd b/src/base-environments/volunteer_room/shared_flat.gd index ad1c12f..8c06d5e 100644 --- a/src/base-environments/volunteer_room/shared_flat.gd +++ b/src/base-environments/volunteer_room/shared_flat.gd @@ -17,6 +17,9 @@ func start_room(): card_board.board_completed.connect(func(): #TODO: hook in ending save_room()) + + card_board.closed.connect(save_room) + %PlayerController.process_mode = Node.PROCESS_MODE_INHERIT ini_room.emit() Scenes.player_enable.emit(true) @@ -42,7 +45,7 @@ func _on_scene_finished(_id: int, _repeat:bool): func save_room(): # Update board state before saving - save_game.board_state = card_board.get_save_dict() + card_board.save_to_resource(save_game) save_game.mementos_complete = Scenes.completed_sequences save_game.sequences_enabled = Scenes.enabled_sequences super.save_room() diff --git a/src/base-environments/youth_room/youth_room.gd b/src/base-environments/youth_room/youth_room.gd index 23bd1eb..5f86d5a 100644 --- a/src/base-environments/youth_room/youth_room.gd +++ b/src/base-environments/youth_room/youth_room.gd @@ -41,6 +41,8 @@ func get_ready(): card_board.board_completed.connect(func(): save_game.is_childhood_board_complete = true save_room()) + + card_board.closed.connect(save_room) card_picker.cards_picked.connect(card_board.populate_board) @@ -79,7 +81,7 @@ func _on_scene_finished(_id: int, _repeat:bool): func save_room(): # Update board state before saving - save_game.board_state = card_board.get_save_dict() + card_board.save_to_resource(save_game) save_game.mementos_complete = Scenes.completed_sequences super.save_room() diff --git a/src/dev-util/hardcoded_cards.gd b/src/dev-util/hardcoded_cards.gd index acde20c..d1c32ad 100644 --- a/src/dev-util/hardcoded_cards.gd +++ b/src/dev-util/hardcoded_cards.gd @@ -192,8 +192,8 @@ func get_cards_by_scene_id(id: int) -> Array[Card]: return output # used to put cards on the dev board -func get_cards_by_name_array(names: Array[StringName]) -> Dictionary: - var output:Dictionary = { +func get_cards_by_name_array(names: Array[StringName]) -> Dictionary[String, Array]: + var output:Dictionary[String, Array] = { "cards": [], "sticky_notes": [] } diff --git a/src/dev-util/savegame.gd b/src/dev-util/savegame.gd index 2c3b7ca..01f7995 100644 --- a/src/dev-util/savegame.gd +++ b/src/dev-util/savegame.gd @@ -3,48 +3,59 @@ 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": []} + +# Board state - properly typed fields +@export var board_cards: Dictionary[StringName, Vector2] = {} +@export var board_stickies: Dictionary[StringName, Variant] = {} +@export var board_randoms: Array[StringName] = [] + @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 +# === Computed Properties === +var is_valid: bool: + get: return _validate() + +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(file_name) or (current_room == State.rooms.NULL) + +var completed_sequences: int: + get: + # Hamming weight (population count) algorithm for counting set bits + 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 total_connections: int: + get: + var connections := 0 + for sticky_position in board_stickies.values(): + connections += int(sticky_position is String) + return connections + +# === State Variables / External Data === +## Where to save the savegame to / where it was loaded from +var file_name: String = "" + +## Screenshot or placeholder image 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 @@ -56,32 +67,16 @@ static func generate_save_path() -> String: ## 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.file_name = generate_save_path() + save.unique_save_name = save.file_name.get_file().get_basename() 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!") + if not DirAccess.dir_exists_absolute(save.file_name.get_base_dir()): + DirAccess.make_dir_absolute(save.file_name.get_base_dir()) + print_debug("SaveGame: Created new save: %s" % save.file_name) return save ## Loads an existing save from disk @@ -90,37 +85,29 @@ static func load_from_file(save_filepath: String) -> SaveGame: 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) + push_error("SaveGame: Failed to load resource from: %s" % save_filepath) return null # Update filepath metadata - loaded.filepath = save_filepath + loaded.file_name = 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 (stored separately as PNG) _load_thumbnail(loaded) - # Validate + # Validate and return loaded.is_valid = loaded._validate() - if not loaded.is_valid: - push_error("Validation of loaded save failed: %s" % save_filepath) + push_error("SaveGame: Validation 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] + var thumbnail_path := "%s/thumbnails/%s.png" % [save.file_name.get_base_dir(), save.unique_save_name] if FileAccess.file_exists(thumbnail_path): var img := Image.load_from_file(thumbnail_path) if img: @@ -143,7 +130,7 @@ func capture_player_state() -> void: ## Saves to disk with thumbnail func save_to_file(screen_shot: Texture) -> void: - if filepath == "DEBUG": + if file_name == "DEBUG": push_warning("SaveGame: DEBUG save skipped (intentional).") return @@ -151,7 +138,7 @@ func save_to_file(screen_shot: Texture) -> void: push_warning("SaveGame: Not saving empty savegame.") return - print_debug("SaveGame: Saving to file: %s" % filepath) + print_debug("SaveGame: Saving to file: %s" % file_name) # Capture current state capture_player_state() @@ -161,11 +148,11 @@ func save_to_file(screen_shot: Texture) -> void: _save_thumbnail(screen_shot) # Save resource - var result := ResourceSaver.save(self, filepath) + var result := ResourceSaver.save(self, file_name) if result != OK: - push_error("Failed to save resource to: %s (Error: %d)" % [filepath, result]) + push_error("Failed to save resource to: %s (Error: %d)" % [file_name, result]) else: - print_debug("Successfully saved to: %s" % filepath) + print_debug("Successfully saved to: %s" % file_name) ## Processes and saves thumbnail as PNG func _save_thumbnail(screen_shot: Texture) -> void: @@ -176,45 +163,31 @@ func _save_thumbnail(screen_shot: Texture) -> void: img.crop(384, 216) # Ensure thumbnails directory exists - var save_dir := DirAccess.open(filepath.get_base_dir()) + var save_dir := DirAccess.open(file_name.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] + var thumbnail_path := "%s/thumbnails/%s.png" % [file_name.get_base_dir(), unique_save_name] img.save_png(thumbnail_path) -## Validates save data integrity + +# === Legacy Validation (may want to be removed) === + func _validate() -> bool: if current_room < 0 or current_room >= State.rooms.keys().size(): return false - return validate_board_state() + 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(): +func _validate_board_state() -> bool: + # Validate cards + for card in board_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)): + # Validate stickies + for sticky in board_stickies.values(): + if not (sticky is int or sticky is Vector2 or sticky is float or board_cards.has(sticky)): push_error("Save %s: Corrupted sticky notes" % unique_save_name) return false diff --git a/src/logic-scenes/board/card-board.gd b/src/logic-scenes/board/card-board.gd index ab01d60..ca4d252 100644 --- a/src/logic-scenes/board/card-board.gd +++ b/src/logic-scenes/board/card-board.gd @@ -604,122 +604,92 @@ func on_sticky_panel_cleared(at_id: int): else: current_sticky_note_id += 1 -func get_save_dict() -> Dictionary: - var cards: Dictionary = {} - var stickies: Dictionary = {} - var randoms: Array[StringName] +## Saves board state directly to SaveGame resource +func save_to_resource(savegame: SaveGame) -> void: + savegame.board_cards.clear() + savegame.board_stickies.clear() + savegame.board_randoms.clear() for child in dropzone.get_children(): if child is Card: - # Save position of Card. - cards[child.name] = child.transform.origin + # Save position of Card + savegame.board_cards[child.name] = child.transform.origin if child.picked_random: - randoms.append(child.name) + savegame.board_randoms.append(child.name) - var note : StickyNote = child.get_attached_sticky_note() + var note: StickyNote = child.get_attached_sticky_note() if note: - # Saves Card Name as position of it's children. - stickies[child.get_attached_sticky_note().name] = child.name + # Save Card Name as position of its children + savegame.board_stickies[child.get_attached_sticky_note().name] = child.name if child.get_attached_sticky_note().picked_random: - randoms.append(child.get_attached_sticky_note().name) + savegame.board_randoms.append(child.get_attached_sticky_note().name) elif child is StickyNote: - # Save position of StickyNote. - cards[child.name] = child.transform.origin + # Save position of StickyNote + savegame.board_cards[child.name] = child.transform.origin for child in sticky_note_container.get_children(): if child is StickyNotePanel: - # Saves all collected Stickies that are not on board. - stickies[child.attached_sticky_note.name] = -1 + # Save all collected Stickies that are not on board + savegame.board_stickies[child.attached_sticky_note.name] = -1 + +## Legacy method for backwards compatibility +func get_save_dict() -> Dictionary: return { - "cards": cards, - "stickies": stickies, - "randoms": randoms + "cards": {}, + "stickies": {}, + "randoms": [] } func initialise_from_save(savegame: SaveGame) -> void: - last_save_dict = savegame.board_state.duplicate() - if savegame.board_state == {}: return - rebuild_from_savedict(savegame.board_state) + # Early return if no board data + if savegame.board_cards.is_empty() and savegame.board_stickies.is_empty(): + return -func rebuild_from_savedict(board_state:Dictionary) -> void: - var cards: Dictionary - if board_state["cards"] != {} : - cards = board_state["cards"] - var stickies: Dictionary - if board_state["stickies"] != {} : - stickies = board_state["stickies"] - var randoms: Array[StringName] - if board_state["randoms"] != [] : - randoms = board_state["randoms"] + rebuild_from_save(savegame) - if cards == null and stickies == null: return +func rebuild_from_save(savegame: SaveGame) -> void: + # Early return if nothing to load + if savegame.board_cards.is_empty() and savegame.board_stickies.is_empty(): + return + # Collect all card names var all_cards: Array[StringName] - for card_name: StringName in cards.keys(): + for card_name: StringName in savegame.board_cards.keys(): all_cards.append(card_name) - for card_name: StringName in stickies.keys(): + for card_name: StringName in savegame.board_stickies.keys(): all_cards.append(card_name) - var card_pile = HardCards.get_cards_by_name_array(all_cards) - for card:Card in card_pile["cards"]: + var card_pile : Dictionary[String, Array] = HardCards.get_cards_by_name_array(all_cards) + + # Track cards by name for sticky note attachment + var cards_by_name: Dictionary = {} + + for card: Card in card_pile["cards"]: add_card(card, false) - card.transform.origin = cards[card.name]# Replacing position reference with card reference! Needed in next loop. - cards[card.name] = card + card.transform.origin = savegame.board_cards[card.name] + cards_by_name[card.name] = card text_recovery[card.name] = card.text - card.picked_random = randoms.has( card.card_id ) - for sticky:StickyNote in card_pile["sticky_notes"]: + card.picked_random = savegame.board_randoms.has(card.card_id) + + for sticky: StickyNote in card_pile["sticky_notes"]: text_recovery[sticky.name] = sticky.text - if stickies[sticky.name] is int: - if stickies[sticky.name] == -1: + var sticky_data = savegame.board_stickies[sticky.name] + + if sticky_data is int: + if sticky_data == -1: add_sticky_note(sticky, false) - elif stickies[sticky.name] is String: - cards[stickies[sticky.name]].attach_sticky_note(sticky) + elif sticky_data is String: + # Attached to a card + cards_by_name[sticky_data].attach_sticky_note(sticky) else: + # Loose on board at position insert_area(dropzone, sticky) - sticky.transform.origin = stickies[sticky.name] - sticky.picked_random = randoms.has( sticky.sticky_id ) + sticky.transform.origin = sticky_data -func validate_board(): - if current_context == NAVIGATE: + sticky.picked_random = savegame.board_randoms.has(sticky.sticky_id) - var needs_rebuild := false - for node in dropzone.get_children(): - if node is Card: - match validate_card(node): - Error.OUT_OF_BOUNDS: - node.position = last_save_dict[node.name] - Error.ILLEGAL_STATE: - needs_rebuild = true - if node is StickyNote: - match validate_sticky(node): - Error.OUT_OF_BOUNDS: - node.position = last_save_dict[node.name] - Error.ILLEGAL_STATE: - needs_rebuild = true - for panel:StickyNotePanel in sticky_note_container.get_children(): - if panel.attached_sticky_note != null: - match validate_sticky(panel.attached_sticky_note): - Error.OUT_OF_BOUNDS: - panel.attached_sticky_note.position = panel.ancor_position - Error.ILLEGAL_STATE: - needs_rebuild = true - - # FIXME: currently, illegal temporary state exists a lot and needs to be rectified before this can be trusted. - if needs_rebuild and false: - - for child in dropzone.get_children(): child.free() - for child in sticky_note_container.get_children(): child.free() - - rebuild_from_savedict(last_save_dict) - - current_dropzone_id = 0 - current_sticky_note_id = 0 - focus_stickies = false - current_context = NAVIGATE - else: - last_save_dict = get_save_dict() func validate_sticky(note: StickyNote) -> CardBoard.Error: if not get_viewport_rect().has_point(note.get_global_transform().origin): @@ -746,7 +716,7 @@ func validate_card(card: Card) -> CardBoard.Error: func try_select_nearest_card(from: Vector2, towards: Vector2, include_stickies: bool = false) -> bool: - var selection_transform = Transform2D(0, from).looking_at(from+towards) + var selection_transform := Transform2D(0, from).looking_at(from+towards) var scores: Dictionary[int, Area2D] = {-1: null} for child:Area2D in dropzone.get_children(): @@ -798,8 +768,8 @@ func try_select_nearest_empty_card(from: Vector2) -> bool: return false func get_distance_score(from: Vector2, to: Transform2D) -> int: - var diff = from * to - var dir = diff.normalized() + var diff := from * to + var dir := diff.normalized() if dir.x > 0.5 and diff.length() > 0: return int((abs(dir.y) + 0.5) * diff.length()) else: diff --git a/src/logic-scenes/card_burner/card_burner.gd b/src/logic-scenes/card_burner/card_burner.gd index 85f23d5..5b63714 100644 --- a/src/logic-scenes/card_burner/card_burner.gd +++ b/src/logic-scenes/card_burner/card_burner.gd @@ -23,7 +23,7 @@ func _ready(): %SkipButton.pressed.connect(card_burned.emit) func burn_cards(_id: int, _repeat: bool = false) -> void: - var random_card_names: Array = State.save_game.board_state["randoms"] + var random_card_names: Array = State.save_game.board_randoms.duplicate() for card_name in random_card_names: if card_name.begins_with("p"): diff --git a/src/singletons/global_state.gd b/src/singletons/global_state.gd index baad833..28055b0 100644 --- a/src/singletons/global_state.gd +++ b/src/singletons/global_state.gd @@ -20,8 +20,8 @@ func set_theme(new_theme:Theme): current_main_theme = new_theme theme_changed.emit(new_theme) -@export_file var user_settings_path:String = "user://user_settings.json" -@export_file var user_saves_path:String = "user://savegames" +const user_settings_path:String = "user://user_settings.json" +const user_saves_path:String = "user://savegames" @export_group("Acessability") @export var reduce_motion: bool = false: diff --git a/src/ui/menu_main/save_game_display.gd b/src/ui/menu_main/save_game_display.gd index 046a2ea..d3b5a61 100644 --- a/src/ui/menu_main/save_game_display.gd +++ b/src/ui/menu_main/save_game_display.gd @@ -105,7 +105,7 @@ func rebuild(): state.text = TranslationServer.translate((""" Mementos collected: %d/4 Connections found: %d/8 - """).strip_edges()) % [save.calculate_completed_sequences(), save.calculate_total_connections()] + """).strip_edges()) % [save.completed_sequences, save.total_connections] #Secrets found: 1/4 info.add_child(room) diff --git a/src/ui/menu_main/save_game_list.gd b/src/ui/menu_main/save_game_list.gd index cd9e1a8..db0cdd2 100644 --- a/src/ui/menu_main/save_game_list.gd +++ b/src/ui/menu_main/save_game_list.gd @@ -80,7 +80,7 @@ func _on_game_picked(id: int) -> void: func _on_delete_requested(id: int) -> void: var save_to_delete := saves[id] - var save_path := save_to_delete.filepath + var save_path := save_to_delete.file_name var thumbnail_path := "%s/thumbnails/%s.png" % [save_path.get_base_dir(), save_to_delete.unique_save_name] # Delete the save file