class_name SaveSlotsPage extends Control const PAGE_SAVES: StringName = &"saves" const PAGE_NEW: StringName = &"new" const WorldLayoutType = preload("res://world/world_layout.gd") const SaveManagerType = preload("res://save/player_save_manager.gd") const NewGameSetupPageType = preload("res://ui/new_game_setup_page.gd") const DialogControllerNavigationType = preload( "res://ui/file_dialog_controller_navigation.gd" ) signal back_requested signal play_requested(slot_id: String) signal create_requested( display_name: String, world_layout: StringName, world_seed: int, duplicate_source_slot_id: String, ) enum SeedMode { RANDOM, CUSTOM, } @onready var _main_panel: PanelContainer = %MainPanel @onready var _content_panel: PanelContainer = %ContentPanel @onready var _saves_tab: OrganizerTab = %SavesTab @onready var _new_slot_tab: OrganizerTab = %NewSlotTab @onready var _saves_page: Control = %SavesPage @onready var _new_slot_page: Control = %NewSlotPage @onready var _slot_list: VBoxContainer = %SlotList @onready var _empty_slots_label: Label = %EmptySlotsLabel @onready var _slot_name_edit: LineEdit = %SelectedSlotName @onready var _slot_summary: RichTextLabel = %SlotSummary @onready var _play_button: Button = %PlaySlotButton @onready var _rename_button: Button = %RenameSlotButton @onready var _duplicate_button: Button = %DuplicateSlotButton @onready var _export_button: Button = %ExportSlotButton @onready var _delete_button: Button = %DeleteSlotButton @onready var _import_button: Button = %ImportSlotButton @onready var _new_slot_heading: Label = %NewSlotHeading @onready var _new_slot_name: LineEdit = %NewSlotName @onready var _generated_button: Button = %GeneratedButton @onready var _starter_button: Button = %StarterButton @onready var _world_description: Label = %WorldDescription @onready var _seed_section: VBoxContainer = %SeedSection @onready var _random_seed_button: Button = %RandomSeedButton @onready var _custom_seed_button: Button = %CustomSeedButton @onready var _seed_edit: LineEdit = %SeedEdit @onready var _seed_help: Label = %SeedHelp @onready var _create_button: Button = %CreateSlotButton @onready var _status: Label = %Status @onready var _back_button: Button = %BackButton var _catalog: PlayerSaveSlotCatalog var _data_root: PlayerDataRoot var _interface_fonts: InterfaceFontController var _active_page_id: StringName = PAGE_SAVES var _selected_slot_id := "" var _duplicate_source_slot_id := "" var _world_layout: StringName = WorldLayoutType.GENERATED var _seed_mode: SeedMode = SeedMode.RANDOM var _random_seed: int = SaveManagerType.DEFAULT_WORLD_SEED var _slot_buttons: Array[Button] = [] var _import_dialog: FileDialog var _export_dialog: FileDialog var _delete_dialog: ConfirmationDialog var _busy: bool = false func _ready() -> void: _configure_style() _saves_tab.pressed.connect(_select_page.bind(PAGE_SAVES, true)) _new_slot_tab.pressed.connect(_open_fresh_slot_page) _play_button.pressed.connect(_request_play) _rename_button.pressed.connect(_rename_selected_slot) _duplicate_button.pressed.connect(_prepare_duplicate) _export_button.pressed.connect(_choose_export_path) _delete_button.pressed.connect(_confirm_delete_selected_slot) _import_button.pressed.connect(_choose_import_path) _generated_button.pressed.connect( _set_world_layout.bind(WorldLayoutType.GENERATED) ) _starter_button.pressed.connect( _set_world_layout.bind(WorldLayoutType.STARTER_ISLAND) ) _random_seed_button.pressed.connect(_choose_random_seed) _custom_seed_button.pressed.connect(_choose_custom_seed) _new_slot_name.text_changed.connect(_on_new_slot_name_changed) _seed_edit.text_changed.connect(_on_seed_text_changed) _seed_edit.text_submitted.connect(_on_seed_submitted) _new_slot_name.text_submitted.connect(_on_new_slot_name_submitted) _create_button.pressed.connect(_request_create) _back_button.pressed.connect(request_back) visibility_changed.connect(_on_visibility_changed) hide() func _configure_style() -> void: UtilityPageStyle.apply_page(self) _main_panel.add_theme_stylebox_override( "panel", UtilityPageStyle.rounded_style( UtilityPageStyle.OCEAN_PANEL_MID, 28, ), ) _content_panel.add_theme_stylebox_override( "panel", UtilityPageStyle.rounded_style( UtilityPageStyle.OCEAN_FIELD, 20, ), ) for node: Node in find_children("*", "Label", true, false): var label := node as Label label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY, ) for node: Node in find_children("*", "Button", true, false): if node is OrganizerTab: continue UtilityPageStyle.apply_ocean_button(node as BaseButton) _play_button.add_theme_stylebox_override( "normal", UtilityPageStyle.ocean_button_style( UtilityPageStyle.GREEN, ), ) _delete_button.add_theme_stylebox_override( "normal", UtilityPageStyle.ocean_button_style( UtilityPageStyle.OCEAN_DANGER, ), ) _back_button.add_theme_stylebox_override( "normal", UtilityPageStyle.ocean_button_style( UtilityPageStyle.OCEAN_PANEL_DEEP, ), ) _status.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY, ) func setup( catalog: PlayerSaveSlotCatalog, data_root: PlayerDataRoot, interface_fonts: InterfaceFontController, ) -> void: _catalog = catalog _data_root = data_root _interface_fonts = interface_fonts if not _catalog.slots_changed.is_connected(_on_slots_changed): _catalog.slots_changed.connect(_on_slots_changed) func open_page() -> void: _busy = false _status.text = "" _duplicate_source_slot_id = "" _refresh_slots() if _catalog != null and _catalog.has_slots(): _select_page(PAGE_SAVES, false) else: _prepare_fresh_slot() _select_page(PAGE_NEW, false) show() _main_panel.modulate.a = 1.0 _main_panel.scale = Vector2.ONE UtilityPageStyle.animate_in(self) call_deferred("_focus_open_page") func close_page() -> void: _release_owned_focus() hide() func request_back() -> void: if _import_dialog != null and _import_dialog.visible: _import_dialog.hide() return if _export_dialog != null and _export_dialog.visible: _export_dialog.hide() return if _delete_dialog != null and _delete_dialog.visible: _delete_dialog.hide() return if _busy: return if _active_page_id == PAGE_NEW and _catalog != null and _catalog.has_slots(): _duplicate_source_slot_id = "" _select_page(PAGE_SAVES, true) return back_requested.emit() func set_status(message: String) -> void: _busy = false _status.text = message _refresh_action_state() func finish_request_if_pending(message: String) -> void: if _busy: set_status(message) func refresh_page() -> void: if visible: _refresh_slots() func get_active_page_id() -> StringName: return _active_page_id if visible else StringName() func _select_page(page_id: StringName, focus_tab: bool) -> void: if page_id not in [PAGE_SAVES, PAGE_NEW]: return _active_page_id = page_id _saves_page.visible = page_id == PAGE_SAVES _new_slot_page.visible = page_id == PAGE_NEW _saves_tab.set_selected(page_id == PAGE_SAVES, is_inside_tree()) _new_slot_tab.set_selected(page_id == PAGE_NEW, is_inside_tree()) _configure_controller_focus() if focus_tab: (_saves_tab if page_id == PAGE_SAVES else _new_slot_tab).grab_focus() func _open_fresh_slot_page() -> void: _prepare_fresh_slot() _select_page(PAGE_NEW, true) func _prepare_fresh_slot() -> void: _duplicate_source_slot_id = "" _new_slot_heading.text = "new progression" _new_slot_name.text = _suggested_slot_name() _world_layout = WorldLayoutType.GENERATED _seed_mode = SeedMode.RANDOM _roll_random_seed() _refresh_new_slot_presentation() func _prepare_duplicate() -> void: var slot: Dictionary = _selected_slot() if slot.is_empty() or not bool(slot.get("has_save", false)): return _duplicate_source_slot_id = _selected_slot_id _new_slot_heading.text = "duplicate progression" _new_slot_name.text = PlayerSaveSlotCatalog.normalized_name( "copy of %s" % str(slot.get("display_name", "save")) ) _world_layout = WorldLayoutType.GENERATED _seed_mode = SeedMode.RANDOM _roll_random_seed() _refresh_new_slot_presentation() _select_page(PAGE_NEW, true) func _refresh_slots() -> void: for button: Button in _slot_buttons: _slot_list.remove_child(button) button.queue_free() _slot_buttons.clear() if _catalog == null: _selected_slot_id = "" _empty_slots_label.show() _refresh_selected_slot() return var slots: Array[Dictionary] = _catalog.list_slots() var known_selection: bool = false for slot: Dictionary in slots: var slot_id: String = str(slot.get("slot_id", "")) var button := Button.new() button.name = "Slot_%s" % slot_id.left(8) button.custom_minimum_size = Vector2(0.0, 56.0) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.focus_mode = Control.FOCUS_ALL button.toggle_mode = true button.text = str(slot.get("display_name", "save")) + ( " · active" if bool(slot.get("active", false)) else "" ) button.alignment = HORIZONTAL_ALIGNMENT_LEFT UtilityPageStyle.apply_ocean_button(button) button.pressed.connect(_select_slot.bind(slot_id, false)) button.focus_entered.connect(_select_slot.bind(slot_id, true)) _slot_list.add_child(button) _slot_buttons.append(button) known_selection = known_selection or slot_id == _selected_slot_id _empty_slots_label.visible = slots.is_empty() if not known_selection: _selected_slot_id = _catalog.get_active_slot_id() if _selected_slot_id.is_empty() and not slots.is_empty(): _selected_slot_id = str(slots.front().get("slot_id", "")) _refresh_selected_slot() _configure_controller_focus() func _select_slot(slot_id: String, _from_focus: bool) -> void: if _catalog == null or _catalog.get_slot(slot_id).is_empty(): return _selected_slot_id = slot_id _refresh_selected_slot() func _refresh_selected_slot() -> void: var slot: Dictionary = _selected_slot() for button: Button in _slot_buttons: button.set_pressed_no_signal( button.name == "Slot_%s" % _selected_slot_id.left(8) ) if slot.is_empty(): _slot_name_edit.text = "" _slot_name_edit.editable = false _slot_summary.text = "[center]create or import a save slot to begin.[/center]" else: _slot_name_edit.editable = true _slot_name_edit.text = str(slot.get("display_name", "save")) _slot_summary.text = _format_slot_summary(slot) _refresh_action_state() func _format_slot_summary(slot: Dictionary) -> String: if not bool(slot.get("has_save", false)): return "[center]%s[/center]" % str( slot.get("status_message", "this slot has no progression yet.") ) var last_played: String = _format_timestamp( int(slot.get("last_played_at_unix", 0)) ) return ( "[font_size=24]level %d[/font_size]\n\n" + "%d catches · %d discovered\n" + "%d fishcoins\n\n" + "%s · seed %d\n" + "last played %s" ) % [ int(slot.get("player_level", 1)), int(slot.get("catch_count", 0)), int(slot.get("discovered_species_count", 0)), int(slot.get("wallet_balance", 0)), WorldLayoutType.display_name(slot.get("world_layout", "")), int(slot.get("world_seed", 0)), last_played, ] func _refresh_action_state() -> void: var slot: Dictionary = _selected_slot() var has_slot: bool = not slot.is_empty() var has_save: bool = has_slot and bool(slot.get("has_save", false)) _play_button.disabled = _busy or not has_save _rename_button.disabled = _busy or not has_slot _duplicate_button.disabled = _busy or not has_save _export_button.disabled = _busy or not has_save _delete_button.disabled = _busy or not has_slot _import_button.disabled = _busy or _catalog == null _create_button.disabled = _busy or not _new_slot_request_is_valid() func _request_play() -> void: if _play_button.disabled or _selected_slot_id.is_empty(): return _busy = true _status.text = "loading save slot..." _refresh_action_state() play_requested.emit(_selected_slot_id) func _rename_selected_slot() -> void: if _catalog == null or _rename_button.disabled: return if not _catalog.rename_slot(_selected_slot_id, _slot_name_edit.text): _status.text = "the save slot could not be renamed." return _status.text = "save slot renamed." func _confirm_delete_selected_slot() -> void: var slot: Dictionary = _selected_slot() if slot.is_empty() or _delete_button.disabled: return if _delete_dialog == null: _delete_dialog = ConfirmationDialog.new() _delete_dialog.title = "delete save slot?" _delete_dialog.ok_button_text = "delete" _delete_dialog.confirmed.connect(_delete_selected_slot) if _interface_fonts != null: _interface_fonts.apply_utility_theme(_delete_dialog) add_child(_delete_dialog) _delete_dialog.dialog_text = ( "delete \"%s\"? this cannot be undone from the game." % str(slot.get("display_name", "save")) ) _delete_dialog.popup_centered(Vector2i(560, 260)) _configure_delete_dialog.call_deferred() func _configure_delete_dialog() -> void: if ( _delete_dialog != null and is_instance_valid(_delete_dialog) and _delete_dialog.visible ): DialogControllerNavigationType.configure_scope( _delete_dialog, _delete_dialog.get_cancel_button(), ) func _delete_selected_slot() -> void: if _catalog == null or _selected_slot_id.is_empty(): return if not _catalog.delete_slot(_selected_slot_id): _status.text = "the save slot could not be deleted." return _selected_slot_id = _catalog.get_active_slot_id() _status.text = "save slot deleted." _refresh_slots() if not _catalog.has_slots(): _prepare_fresh_slot() _select_page(PAGE_NEW, true) func _choose_import_path() -> void: if _catalog == null or _data_root == null: return if _import_dialog == null: _import_dialog = FileDialog.new() _import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE _import_dialog.access = FileDialog.ACCESS_FILESYSTEM _import_dialog.use_native_dialog = false _import_dialog.filters = PackedStringArray([ "*.nfsave ; straywild progression archive", ]) _import_dialog.file_selected.connect(_import_file_selected) if _interface_fonts != null: _interface_fonts.apply_utility_theme(_import_dialog) add_child(_import_dialog) _import_dialog.current_dir = _data_root.progression_backup_directory() if _interface_fonts != null: _interface_fonts.popup_file_dialog(_import_dialog) else: _import_dialog.popup_centered_ratio(0.85) func _import_file_selected(path: String) -> void: var suggested_name: String = path.get_file().get_basename().replace("_", " ") suggested_name = suggested_name.replace("-", " ") var result: Dictionary = _catalog.import_slot(path, suggested_name) _status.text = str(result.get("message", "progression import failed.")) if bool(result.get("ok", false)): _selected_slot_id = str(result.get("slot_id", "")) _refresh_slots() _select_page(PAGE_SAVES, false) func _choose_export_path() -> void: var slot: Dictionary = _selected_slot() if slot.is_empty() or _data_root == null: return var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-") var filename: String = "%s-%s%s" % [ _safe_filename(str(slot.get("display_name", "save"))), timestamp, PlayerSaveManager.ARCHIVE_EXTENSION, ] if _export_dialog == null: _export_dialog = FileDialog.new() _export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE _export_dialog.access = FileDialog.ACCESS_FILESYSTEM _export_dialog.use_native_dialog = false _export_dialog.filters = PackedStringArray([ "*.nfsave ; straywild progression archive", ]) _export_dialog.file_selected.connect(_export_file_selected) if _interface_fonts != null: _interface_fonts.apply_utility_theme(_export_dialog) add_child(_export_dialog) _export_dialog.current_dir = _data_root.progression_backup_directory() _export_dialog.current_file = filename if _interface_fonts != null: _interface_fonts.popup_file_dialog(_export_dialog) else: _export_dialog.popup_centered_ratio(0.85) func _export_file_selected(path: String) -> void: var destination: String = ( path if path.ends_with(PlayerSaveManager.ARCHIVE_EXTENSION) else path + PlayerSaveManager.ARCHIVE_EXTENSION ) var result: Dictionary = _catalog.export_slot( _selected_slot_id, destination, ) _status.text = str(result.get("message", "progression export failed.")) func _request_create() -> void: if _create_button.disabled: return var seed: int = _selected_world_seed() if _world_layout == WorldLayoutType.GENERATED and seed == 0: _status.text = "enter text or a whole number from 1 to %d." % SaveManagerType.MAX_WORLD_SEED _seed_edit.grab_focus() _seed_edit.select_all() return if seed == 0: seed = SaveManagerType.roll_world_seed() _busy = true _status.text = "creating save slot..." _refresh_action_state() create_requested.emit( PlayerSaveSlotCatalog.normalized_name(_new_slot_name.text), _world_layout, seed, _duplicate_source_slot_id, ) func _set_world_layout(layout: StringName) -> void: if not WorldLayoutType.is_valid(layout): return _world_layout = layout _status.text = "" _refresh_new_slot_presentation() func _choose_random_seed() -> void: _seed_mode = SeedMode.RANDOM _roll_random_seed() _status.text = "" _refresh_new_slot_presentation() func _choose_custom_seed() -> void: _seed_mode = SeedMode.CUSTOM if NewGameSetupPageType.parse_seed_text(_seed_edit.text) == _random_seed: _seed_edit.text = "" _status.text = "" _refresh_new_slot_presentation() _seed_edit.grab_focus.call_deferred() _seed_edit.select_all.call_deferred() func _roll_random_seed() -> void: _random_seed = SaveManagerType.roll_world_seed() _seed_edit.text = str(_random_seed) func _selected_world_seed() -> int: return ( _random_seed if _seed_mode == SeedMode.RANDOM else NewGameSetupPageType.parse_seed_text(_seed_edit.text) ) func _on_seed_text_changed(_text: String) -> void: if _seed_mode == SeedMode.CUSTOM: _status.text = "" _refresh_action_state() func _on_new_slot_name_changed(_text: String) -> void: _refresh_action_state() func _on_seed_submitted(_text: String) -> void: _request_create() func _on_new_slot_name_submitted(_text: String) -> void: if _world_layout == WorldLayoutType.STARTER_ISLAND: _request_create() func _refresh_new_slot_presentation() -> void: var generated: bool = _world_layout == WorldLayoutType.GENERATED _generated_button.set_pressed_no_signal(generated) _starter_button.set_pressed_no_signal(not generated) _world_description.text = ( "build a new island from terrain chunks.\n" + "the same seed always builds the same world." if generated else "play on the starter island." ) _seed_section.visible = generated _random_seed_button.set_pressed_no_signal(_seed_mode == SeedMode.RANDOM) _custom_seed_button.set_pressed_no_signal(_seed_mode == SeedMode.CUSTOM) _seed_edit.editable = _seed_mode == SeedMode.CUSTOM _seed_edit.focus_mode = ( Control.FOCUS_ALL if _seed_mode == SeedMode.CUSTOM else Control.FOCUS_NONE ) _seed_help.text = ( "press random seed again to roll another world." if _seed_mode == SeedMode.RANDOM else "enter text or a number from 1 to %d." % SaveManagerType.MAX_WORLD_SEED ) _create_button.text = ( "duplicate and play" if not _duplicate_source_slot_id.is_empty() else "create and play" ) _refresh_action_state() _configure_controller_focus() func _new_slot_request_is_valid() -> bool: return ( not PlayerSaveSlotCatalog.normalized_name(_new_slot_name.text).is_empty() and ( _world_layout != WorldLayoutType.GENERATED or _seed_mode == SeedMode.RANDOM or NewGameSetupPageType.parse_seed_text(_seed_edit.text) > 0 ) ) func _configure_controller_focus() -> void: if not is_node_ready(): return _saves_tab.focus_neighbor_right = _saves_tab.get_path_to(_new_slot_tab) _new_slot_tab.focus_neighbor_left = _new_slot_tab.get_path_to(_saves_tab) if _active_page_id == PAGE_SAVES: var first: Control = _slot_buttons.front() if not _slot_buttons.is_empty() else _import_button var last: Control = _slot_buttons.back() if not _slot_buttons.is_empty() else _import_button _saves_tab.focus_neighbor_bottom = _saves_tab.get_path_to(first) _new_slot_tab.focus_neighbor_bottom = _new_slot_tab.get_path_to(first) for index: int in _slot_buttons.size(): var button: Button = _slot_buttons[index] var top: Control = _saves_tab if index == 0 else _slot_buttons[index - 1] var bottom: Control = _import_button if index == _slot_buttons.size() - 1 else _slot_buttons[index + 1] _set_neighbors(button, button, _slot_name_edit, top, bottom) _set_neighbors(_import_button, _import_button, _slot_name_edit, last, _back_button) _set_neighbors(_slot_name_edit, first, _slot_name_edit, _saves_tab, _play_button) _set_neighbors(_play_button, _import_button, _play_button, _slot_name_edit, _rename_button) _set_neighbors(_rename_button, _import_button, _duplicate_button, _play_button, _export_button) _set_neighbors(_duplicate_button, _rename_button, _duplicate_button, _play_button, _delete_button) _set_neighbors(_export_button, _import_button, _delete_button, _rename_button, _back_button) _set_neighbors(_delete_button, _export_button, _delete_button, _duplicate_button, _back_button) _set_neighbors(_back_button, _back_button, _back_button, _import_button, _back_button) return _saves_tab.focus_neighbor_bottom = _saves_tab.get_path_to(_new_slot_name) _new_slot_tab.focus_neighbor_bottom = _new_slot_tab.get_path_to(_new_slot_name) var generated: bool = _world_layout == WorldLayoutType.GENERATED var custom: bool = generated and _seed_mode == SeedMode.CUSTOM _set_neighbors(_new_slot_name, _new_slot_name, _new_slot_name, _new_slot_tab, _generated_button) _set_neighbors(_generated_button, _generated_button, _starter_button, _new_slot_name, _random_seed_button if generated else _create_button) _set_neighbors(_starter_button, _generated_button, _starter_button, _new_slot_name, _custom_seed_button if generated else _back_button) _set_neighbors(_random_seed_button, _random_seed_button, _custom_seed_button, _generated_button, _seed_edit if custom else _create_button) _set_neighbors(_custom_seed_button, _random_seed_button, _custom_seed_button, _starter_button, _seed_edit if custom else _back_button) _set_neighbors(_seed_edit, _seed_edit, _seed_edit, _custom_seed_button, _create_button) var action_top: Control = _seed_edit if custom else _random_seed_button if generated else _generated_button _set_neighbors(_create_button, _create_button, _back_button, action_top, _create_button) _set_neighbors(_back_button, _create_button, _back_button, action_top, _back_button) func _set_neighbors( control: Control, left: Control, right: Control, top: Control, bottom: Control, ) -> void: control.focus_neighbor_left = control.get_path_to(left) control.focus_neighbor_right = control.get_path_to(right) control.focus_neighbor_top = control.get_path_to(top) control.focus_neighbor_bottom = control.get_path_to(bottom) func _focus_open_page() -> void: if not visible: return (_saves_tab if _active_page_id == PAGE_SAVES else _new_slot_tab).grab_focus() func _selected_slot() -> Dictionary: return _catalog.get_slot(_selected_slot_id) if _catalog != null else {} func _suggested_slot_name() -> String: var used: Dictionary[String, bool] = {} if _catalog != null: for slot: Dictionary in _catalog.list_slots(): used[str(slot.get("display_name", "")).to_lower()] = true var number: int = 1 while used.has("save %d" % number): number += 1 return "save %d" % number func _format_timestamp(unix_time: int) -> String: if unix_time <= 0: return "never" var value: Dictionary = Time.get_datetime_dict_from_unix_time(unix_time) return "%04d-%02d-%02d" % [ int(value.get("year", 0)), int(value.get("month", 0)), int(value.get("day", 0)), ] func _safe_filename(value: String) -> String: var result: String = value.strip_edges().to_lower().replace(" ", "-") for character: String in ["/", "\\", ":", "*", "?", "\"", "<", ">", "|"]: result = result.replace(character, "") return "straywild-save" if result.is_empty() else result func _on_slots_changed() -> void: if visible: _refresh_slots() func _on_visibility_changed() -> void: if not visible: _release_owned_focus() func _release_owned_focus() -> void: if not is_inside_tree(): return var focus_owner: Control = get_viewport().gui_get_focus_owner() if focus_owner != null and is_ancestor_of(focus_owner): focus_owner.release_focus()