Files
Steven Wroblewski ab81f66ef0 Stil wählen, merken und fotografieren
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>
2026-10-02 12:49:40 +02:00

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