ab81f66ef0
F2 wechselt im Spiel den Stil und meldet ihn, --style=NAME wählt ihn beim Start, und das Spiel merkt sich die Wahl in den Einstellungen. Das Bildschirmfoto-Werkzeug nimmt Bilder in einem bestimmten Stil und von der Vitrine auf und zeigt eine zweite Figur mit Namen. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
217 lines
7.6 KiB
GDScript
217 lines
7.6 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)
|
|
|
|
|
|
## The key of the style the player chose last time, or an empty text.
|
|
func style() -> String:
|
|
return _setting("display", "style")
|
|
|
|
|
|
func set_style(key: String) -> void:
|
|
_set_setting("display", "style", key)
|
|
|
|
|
|
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
|