220 lines
8.4 KiB
GDScript
220 lines
8.4 KiB
GDScript
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)
|
|
|