Files
Grovewright/game/scripts/save_store.gd
T
Steven Wroblewski 51cd4572a8 Gesperrte Einstellungen nicht überschreiben
Wer eine Einstellung ändert, liest die Einstellungsdatei jetzt an
derselben Stelle wie Schlüssel und Speicherstand. Ist sie gerade nicht
lesbar, etwa weil ein anderes Programm sie hält, bleibt sie unverändert,
statt nur mit dem neuen Wert überschrieben zu werden. Von einer
beschädigten Datei bleibt, was Godot lesen konnte. Ein neuer Test deckt
die gehaltene Datei ab. Der Parameter von set_player_name heißt jetzt
new_name, damit er die Methode player_name nicht verdeckt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 21:23:49 +02:00

208 lines
7.4 KiB
GDScript

class_name SaveStore
extends RefCounted
## The game's files on the player's machine: the player key, the save, the
## session recordings for bug reports and the player's settings.
const KEY_FILE := "player.key"
const SAVE_FILE := "world.grw"
## Holds the previous save while write_save replaces it.
const BACKUP_FILE := SAVE_FILE + ".bak"
const RECORDINGS_DIR := "recordings"
const SETTINGS_FILE := "settings.cfg"
## Recordings beyond this number are deleted, oldest first.
const KEEP_RECORDINGS := 10
const KEY_BYTES := 16
var base_dir: String
## The result of the last attempt to read the key, the save or the settings:
## OK, or why the file could not be read. A file that exists but cannot be
## read right now, for example because another program holds it, is never
## replaced.
var last_read_error: Error = OK
## Opens the folder and creates it if it is missing. If a crash interrupted a
## save and left only the backup of the previous one (see write_save), the
## backup becomes the save again.
func _init(directory: String = "user://") -> void:
base_dir = directory
DirAccess.make_dir_recursive_absolute(base_dir)
_restore_backup()
## The key that identifies this installation in every world. It is created
## on first use and kept from then on. Returns an empty key if the key file
## exists but cannot be read; last_read_error then says why.
func player_key() -> PackedByteArray:
var path := base_dir.path_join(KEY_FILE)
if FileAccess.file_exists(path):
var stored := _read_file(path)
if last_read_error != OK:
return PackedByteArray()
if stored.size() == KEY_BYTES:
return stored
var key := Crypto.new().generate_random_bytes(KEY_BYTES)
if not _write(path, key):
push_error("player key could not be stored at %s" % path)
return key
func has_save() -> bool:
return FileAccess.file_exists(base_dir.path_join(SAVE_FILE))
## The bytes of the save. If it cannot be read, they are empty and
## last_read_error says why.
func read_save() -> PackedByteArray:
return _read_file(base_dir.path_join(SAVE_FILE))
## Writes the save through a temporary file, so that a crash or a failed
## write never destroys the previous save. Returns false if the new save was
## not stored completely; the previous save then stays as it was.
##
## The temporary file replaces the previous save in two moves: the previous
## save becomes the backup, then the temporary file becomes the save. A single
## move onto the previous save would not be safe, because on Windows Godot
## deletes the target first and only then moves the file. If that move failed,
## for example because a virus scanner holds the temporary file, the previous
## save would already be gone. If the second move fails, the backup is moved
## back. A crash between the two moves leaves only the backup; the next
## SaveStore on the folder restores it.
func write_save(bytes: PackedByteArray) -> bool:
if bytes.is_empty():
return false
var path := base_dir.path_join(SAVE_FILE)
var temporary := path + ".tmp"
var backup := base_dir.path_join(BACKUP_FILE)
if not _write(temporary, bytes) or FileAccess.get_file_as_bytes(temporary) != bytes:
DirAccess.remove_absolute(temporary)
return false
var has_previous := FileAccess.file_exists(path)
if has_previous and DirAccess.rename_absolute(path, backup) != OK:
DirAccess.remove_absolute(temporary)
return false
if DirAccess.rename_absolute(temporary, path) != OK:
if has_previous:
DirAccess.rename_absolute(backup, path)
return false
if has_previous:
DirAccess.remove_absolute(backup)
return true
## Moves an unusable save out of the way, so that the next save does not
## overwrite it. An older set-aside save is replaced. Returns the new path,
## or an empty text if there was no save or it could not be moved.
func set_aside_save() -> String:
var path := base_dir.path_join(SAVE_FILE)
var aside := path + ".broken"
if DirAccess.rename_absolute(path, aside) != OK:
return ""
return aside
## Stores a session recording and deletes the oldest ones beyond
## KEEP_RECORDINGS. Returns the path, or an empty text if nothing was written.
func write_recording(bytes: PackedByteArray) -> String:
if bytes.is_empty():
return ""
var directory := base_dir.path_join(RECORDINGS_DIR)
DirAccess.make_dir_recursive_absolute(directory)
# Microseconds since 1970, padded so that names sort by age.
var stamp := int(Time.get_unix_time_from_system() * 1_000_000.0)
var path := directory.path_join("session-%016d.grwr" % stamp)
if not _write(path, bytes):
return ""
var names := recordings()
while names.size() > KEEP_RECORDINGS:
DirAccess.remove_absolute(directory.path_join(names[0]))
names.remove_at(0)
return path
## File names of the stored recordings, oldest first.
func recordings() -> PackedStringArray:
var names := DirAccess.get_files_at(base_dir.path_join(RECORDINGS_DIR))
names.sort()
return names
## The name the player gave last time, or an empty text.
func player_name() -> String:
return _setting("player", "name")
func set_player_name(new_name: String) -> void:
_set_setting("player", "name", new_name)
## The address of the host the player joined last time, or an empty text.
func last_address() -> String:
return _setting("network", "address")
func set_last_address(address: String) -> void:
_set_setting("network", "address", address)
func _setting(section: String, key: String) -> String:
var settings := _read_settings(base_dir.path_join(SETTINGS_FILE))
if settings == null:
return ""
return str(settings.get_value(section, key, ""))
## Changes one setting and keeps the others. Settings that cannot be read
## right now stay as they are, because writing them would lose the others.
func _set_setting(section: String, key: String, value: String) -> void:
var path := base_dir.path_join(SETTINGS_FILE)
var settings := _read_settings(path)
if settings == null:
push_warning("settings could not be read at %s and stay as they are: %s" % [path, error_string(last_read_error)])
return
settings.set_value(section, key, value)
if settings.save(path) != OK:
push_warning("settings could not be stored at %s" % path)
## The settings on disk, or null if the file exists but cannot be read right
## now; last_read_error then says why. A missing file gives empty settings.
## Of a damaged file, Godot reports the error and keeps what it read before
## it, and the next change of a setting writes the file anew.
func _read_settings(path: String) -> ConfigFile:
var settings := ConfigFile.new()
if not FileAccess.file_exists(path):
return settings
var text := _read_file(path).get_string_from_utf8()
if last_read_error != OK:
return null
settings.parse(text)
return settings
## Moves the backup back to the save if there is no save, see write_save.
func _restore_backup() -> void:
var path := base_dir.path_join(SAVE_FILE)
var backup := base_dir.path_join(BACKUP_FILE)
if FileAccess.file_exists(path) or not FileAccess.file_exists(backup):
return
if DirAccess.rename_absolute(backup, path) != OK:
push_warning("the previous save could not be restored from %s" % backup)
## Reads a whole file and records the result in last_read_error.
func _read_file(path: String) -> PackedByteArray:
var bytes := FileAccess.get_file_as_bytes(path)
last_read_error = FileAccess.get_open_error()
return bytes
func _write(path: String, bytes: PackedByteArray) -> bool:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
var stored := file.store_buffer(bytes)
file.close()
return stored