extends ConfirmationDialog ## Presentation only: listing never reconciles, migrates or deletes packages. ## New package names use OS crypto, independently of every simulation RNG. signal new_slot_requested(path: String, display_name: String) signal load_slot_requested(path: String) const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") const OWNED_NAME_PATTERN := "^manual_ui_[0-9a-f]{32}$" var _directory := "" var _mode := "" var _new_path := "" var _new_name := "" var _message: Label var _slots: ItemList var _label_edit: LineEdit var _validation_label: Label var _rows: Array[Dictionary] = [] func _ready() -> void: name = "ManualSaveSlotsDialog" cancel_button_text = "戻る" var column := VBoxContainer.new() column.custom_minimum_size = Vector2(460, 240) add_child(column) _message = Label.new() _message.name = "ManualSaveSlotsMessage" _message.custom_minimum_size.x = 460 _message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART column.add_child(_message) _label_edit = LineEdit.new() _label_edit.name = "ManualSaveLabelEdit" _label_edit.custom_minimum_size = Vector2(460, 32) _label_edit.placeholder_text = "セーブデータの名前(日本語入力可)" _label_edit.max_length = 60 _label_edit.text_changed.connect(_on_label_text_changed) column.add_child(_label_edit) _label_edit.hide() _validation_label = Label.new() _validation_label.name = "ManualSaveValidationLabel" _validation_label.add_theme_font_size_override("font_size", 11) _validation_label.add_theme_color_override("font_color", Color("e06c75")) column.add_child(_validation_label) _validation_label.hide() _slots = ItemList.new() _slots.name = "ManualSaveSlotsList" _slots.custom_minimum_size = Vector2(460, 150) _slots.size_flags_vertical = Control.SIZE_EXPAND_FILL _slots.item_selected.connect(_select_slot) column.add_child(_slots) confirmed.connect(_confirm_choice) canceled.connect(_clear_choice) close_requested.connect(_clear_choice) apply_layer_skin(LayerSkin.active_layer(self)) func show_new_save(current_path: String) -> void: _clear_choice() _mode = "new" _directory = current_path.get_base_dir() title = "旧保存を残して新しく保存" ok_button_text = "新しく保存" _slots.hide() # No directory is created until the player explicitly confirms. var token: PackedByteArray = Crypto.new().generate_random_bytes(16) if token.size() == 16: _new_path = _directory.path_join("manual_ui_" + token.hex_encode()) _new_name = "手動保存 " + Time.get_datetime_string_from_system(false, true) var fresh: bool = not _new_path.is_empty() and is_fresh_slot(_new_path) get_ok_button().disabled = not fresh _message.text = "今の進行は、元の保存とは別に保存します。\n元の保存は残り、あとから読込一覧で選べます。\n保存の名前(日本語入力対応):" if fresh else "新しい保存先を用意できませんでした。\n元の保存は変更していません。" if fresh: _label_edit.show() _label_edit.text = _new_name _validation_label.hide() else: _label_edit.hide() _validation_label.hide() popup_centered(Vector2i(520, 320)) if fresh: _label_edit.grab_focus() _label_edit.select_all() else: get_cancel_button().grab_focus() func show_load_slots(current_path: String) -> void: _clear_choice() _mode = "load" _directory = current_path.get_base_dir() title = "保存スロットを選んで読込" ok_button_text = "選んで読込" _message.text = "読み込む保存を選んでください。\n現在の未保存の進行は、選んだ保存で置き換わります。" _label_edit.hide() _validation_label.hide() _slots.show() _slots.clear() _rows = read_slot_rows(_directory) var selection := -1 for index: int in range(_rows.size()): var row: Dictionary = _rows[index] _slots.add_item(row[&"label"]) _slots.set_item_disabled(index, not row[&"loadable"]) if row[&"path"] == current_path and row[&"loadable"]: selection = index if selection < 0: for index: int in range(_rows.size()): if _rows[index][&"loadable"]: selection = index break get_ok_button().disabled = selection < 0 if selection >= 0: _slots.select(selection) else: _message.text = "読み込める保存が見つかりません。\n保存データは変更していません。" popup_centered(Vector2i(520, 360)) if selection >= 0: _slots.grab_focus() else: get_cancel_button().grab_focus() static func is_owned_slot_name(value: String) -> bool: if value == "manual_ui": return true var pattern := RegEx.new() return pattern.compile(OWNED_NAME_PATTERN) == OK and pattern.search(value) != null static func is_fresh_slot(path: String) -> bool: if not path.begins_with("user://") or not is_owned_slot_name(path.get_file()) or path.get_file() == "manual_ui": return false # Reject any existing package, transaction, recovery family, file or link. for suffix: String in ["", ".tmp", ".bak", SaveService.MIGRATION_BACKUP_SUFFIX, SaveService.SAVE_TRANSACTION_SUFFIX]: var absolute: String = ProjectSettings.globalize_path(path + suffix) var parent: DirAccess = DirAccess.open(absolute.get_base_dir()) if FileAccess.file_exists(absolute) or DirAccess.dir_exists_absolute(absolute) or (parent != null and parent.is_link(absolute.get_file())): return false return true static func read_slot_rows(directory: String) -> Array[Dictionary]: var rows: Array[Dictionary] = [] if not directory.begins_with("user://"): return rows var source: DirAccess = DirAccess.open(directory) if source == null: return rows var names: PackedStringArray = source.get_directories() names.sort() for slot: String in names: if not is_owned_slot_name(slot) or source.is_link(slot): continue var path: String = directory.path_join(slot) # Manifest text alone is never authority for a loadable package. var inspected: Dictionary = SaveService.inspect_snapshot(path) var loadable: bool = bool(inspected.get(&"ok", false)) var label: String = "手動保存(最初のスロット)" if slot == "manual_ui" else "手動保存" if loadable: label = String(inspected.get(&"manifest", {}).get("display_name", label)) else: label += " — 読込不可(保存は残っています)" label = "%d. %s" % [rows.size() + 1, label] rows.append({&"path": path, &"label": label, &"loadable": loadable}) return rows static func is_valid_label_text(text: String) -> Dictionary: if text.length() > 60: return {&"ok": false, &"error": "セーブ名は60文字以内で入力してください。"} for i in range(text.length()): var c := text.unicode_at(i) if c < 32 and c != 9: return {&"ok": false, &"error": "制御文字は入力できません。"} return {&"ok": true, &"error": ""} func _on_label_text_changed(new_text: String) -> void: if _mode != "new": return var fresh: bool = not _new_path.is_empty() and is_fresh_slot(_new_path) if not fresh: get_ok_button().disabled = true return var check := is_valid_label_text(new_text) if not bool(check[&"ok"]): _validation_label.text = String(check[&"error"]) _validation_label.show() get_ok_button().disabled = true else: _validation_label.hide() get_ok_button().disabled = false func _select_slot(index: int) -> void: get_ok_button().disabled = index < 0 or index >= _rows.size() or not _rows[index][&"loadable"] func _confirm_choice() -> void: if _mode == "new": if not is_fresh_slot(_new_path): _clear_choice() return var raw_name := _label_edit.text.strip_edges() if _label_edit != null else "" var check := is_valid_label_text(raw_name) if not bool(check[&"ok"]): _validation_label.text = String(check[&"error"]) _validation_label.show() get_ok_button().disabled = true return var final_name := raw_name if not raw_name.is_empty() else _new_name new_slot_requested.emit(_new_path, final_name) elif _mode == "load": var selected: PackedInt32Array = _slots.get_selected_items() if selected.size() == 1 and selected[0] >= 0 and selected[0] < _rows.size() and _rows[selected[0]][&"loadable"]: load_slot_requested.emit(_rows[selected[0]][&"path"]) _clear_choice() func _clear_choice() -> void: _mode = "" _new_path = "" _new_name = "" if _label_edit != null: _label_edit.text = "" _label_edit.hide() if _validation_label != null: _validation_label.hide() _rows.clear() func apply_layer_skin(layer: int) -> void: if _message != null: LayerSkin.register_text(_message, layer) if _validation_label != null: LayerSkin.register_status_text(_validation_label, false, layer) LayerSkin.apply_tree(self, layer)