244 lines
7.2 KiB
GDScript
244 lines
7.2 KiB
GDScript
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 = {}
|