class_name SaveManagerService extends Node ## Слоты сохранений: запись, чтение, удаление и метаданные для UI. ## ## Зарегистрирован автозагрузкой, доступен как SaveManager. ## ## Контракт для игровых сцен: ## 1. в _ready() вызвать begin_session(scene_file_path); ## 2. узлы, которые нужно сохранять, положить в группу "persistent" ## и дать им методы save_data() -> Dictionary и load_data(Dictionary). const SAVE_DIR := "user://saves" const SLOT_COUNT := 6 const PERSIST_GROUP := "persistent" const FORMAT_VERSION := 1 ## Слот записан или удалён — экранам пора обновить список. signal slots_changed ## Наигранное время текущей сессии, секунды. var playtime := 0.0 ## Считать ли время: включается игровой сценой, в меню стоит. var tracking := false ## Сцена, которая будет записана в слот и открыта при загрузке. var current_scene_path := "" ## Кадр игры, снятый до открытия меню паузы, — миниатюра для слота. var _thumbnail: Image = null var _pending_payload := {} func _ready() -> void: DirAccess.make_dir_recursive_absolute(SAVE_DIR) func _process(delta: float) -> void: if tracking: playtime += delta ## Вызывается игровой сценой при старте (и новой игры, и загрузки). func begin_session(scene_path: String) -> void: current_scene_path = scene_path tracking = true func end_session() -> void: tracking = false # --- Слоты ------------------------------------------------------------------ ## Метаданные всех слотов по порядку — ровно то, что рисует UI. func list_slots() -> Array[Dictionary]: var slots: Array[Dictionary] = [] for index in SLOT_COUNT: slots.append(slot_info(index)) return slots func slot_info(index: int) -> Dictionary: var info := { "index": index, "exists": false, "saved_at": 0, "playtime": 0.0, "scene_path": "", "thumbnail": null, } var data := _read_slot(index) if data.is_empty(): return info info["exists"] = true info["saved_at"] = int(data.get("saved_at", 0)) info["playtime"] = float(data.get("playtime", 0.0)) info["scene_path"] = String(data.get("scene_path", "")) info["thumbnail"] = _load_thumbnail(index) return info func has_any_save() -> bool: for index in SLOT_COUNT: if FileAccess.file_exists(_slot_path(index)): return true return false ## Индекс самого свежего слота или -1, если сохранений нет. func latest_slot() -> int: var best := -1 var best_time := -1 for index in SLOT_COUNT: var data := _read_slot(index) if data.is_empty(): continue var saved_at := int(data.get("saved_at", 0)) if saved_at > best_time: best_time = saved_at best = index return best func save_to_slot(index: int) -> bool: if current_scene_path.is_empty(): push_warning("SaveManager: нечего сохранять — сессия не начата.") return false var nodes := {} for node: Node in get_tree().get_nodes_in_group(PERSIST_GROUP): if node.has_method("save_data"): nodes[String(node.get_path())] = node.save_data() var data := { "version": FORMAT_VERSION, "saved_at": int(Time.get_unix_time_from_system()), "playtime": playtime, "scene_path": current_scene_path, "nodes": nodes, } var file := FileAccess.open(_slot_path(index), FileAccess.WRITE) if file == null: push_error("SaveManager: не удалось открыть слот %d на запись." % index) return false file.store_string(JSON.stringify(data, "\t")) file.close() _write_thumbnail(index) slots_changed.emit() return true func load_slot(index: int) -> bool: var data := _read_slot(index) if data.is_empty(): return false var scene_path := String(data.get("scene_path", "")) if not ResourceLoader.exists(scene_path): push_error("SaveManager: сцена из слота %d не найдена: %s" % [index, scene_path]) return false _pending_payload = data.get("nodes", {}) playtime = float(data.get("playtime", 0.0)) get_tree().paused = false get_tree().change_scene_to_file(scene_path) # Смена сцены отложенная: ждём, пока новое дерево встанет на место. await get_tree().process_frame await get_tree().process_frame _apply_pending_payload() return true func delete_slot(index: int) -> void: for path: String in [_slot_path(index), _thumbnail_path(index)]: if FileAccess.file_exists(path): DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) slots_changed.emit() ## Снимок последнего отрисованного кадра. Вызывать до показа меню, иначе ## в миниатюру попадёт само меню. func capture_thumbnail() -> void: var texture := get_viewport().get_texture() if texture == null: return var image := texture.get_image() if image == null: return image.resize(384, 216, Image.INTERPOLATE_LANCZOS) _thumbnail = image # --- Форматирование для UI -------------------------------------------------- static func format_timestamp(unix_time: int) -> String: var zone := Time.get_time_zone_from_system() var local := unix_time + int(zone.get("bias", 0)) * 60 var stamp := Time.get_datetime_dict_from_unix_time(local) return "%02d.%02d.%d %02d:%02d" % [ stamp["day"], stamp["month"], stamp["year"], stamp["hour"], stamp["minute"], ] static func format_playtime(seconds: float) -> String: var total := int(seconds) @warning_ignore("integer_division") var hours := total / 3600 @warning_ignore("integer_division") var minutes := (total % 3600) / 60 if hours > 0: return "%d ч %02d мин" % [hours, minutes] return "%d мин" % minutes # --- Внутреннее ------------------------------------------------------------- func _slot_path(index: int) -> String: return "%s/slot_%d.json" % [SAVE_DIR, index] func _thumbnail_path(index: int) -> String: return "%s/slot_%d.png" % [SAVE_DIR, index] func _read_slot(index: int) -> Dictionary: var path := _slot_path(index) if not FileAccess.file_exists(path): return {} var text := FileAccess.get_file_as_string(path) var parsed: Variant = JSON.parse_string(text) if parsed is Dictionary: return parsed push_warning("SaveManager: слот %d повреждён." % index) return {} func _write_thumbnail(index: int) -> void: if _thumbnail == null: return _thumbnail.save_png(_thumbnail_path(index)) func _load_thumbnail(index: int) -> Texture2D: var path := _thumbnail_path(index) if not FileAccess.file_exists(path): return null var image := Image.load_from_file(path) if image == null: return null return ImageTexture.create_from_image(image) func _apply_pending_payload() -> void: for node: Node in get_tree().get_nodes_in_group(PERSIST_GROUP): var key := String(node.get_path()) if _pending_payload.has(key) and node.has_method("load_data"): node.load_data(_pending_payload[key]) _pending_payload = {}