first commit
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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 = {}
|
||||
Reference in New Issue
Block a user