Texte, Tasten und Dateien
Alle Texte stehen in einer Tabelle mit Deutsch und Englisch, die das Spiel zur Laufzeit liest. Die Tasten werden in Code angelegt, nach physischer Position. SaveStore verwaltet den Spielerschlüssel dieser Installation, schreibt den Speicherstand über eine temporäre Datei, legt unbrauchbare Stände beiseite und behält die zehn neuesten Aufzeichnungen. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
keys,de,en
|
||||
STONE_COUNT,Stein: %d,Stone: %d
|
||||
SAVED,Gespeichert,Saved
|
||||
SAVE_FAILED,Speichern fehlgeschlagen,Saving failed
|
||||
LOADED,Spielstand geladen,Save loaded
|
||||
LOAD_FAILED,Der Spielstand ließ sich nicht laden,The save could not be loaded
|
||||
INVENTORY_FULL,Die Tasche ist voll,Your bag is full
|
||||
PAUSED,Pause,Paused
|
||||
SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped
|
||||
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause
|
||||
|
@@ -0,0 +1,3 @@
|
||||
[remap]
|
||||
|
||||
importer="keep"
|
||||
@@ -0,0 +1,41 @@
|
||||
class_name I18n
|
||||
extends RefCounted
|
||||
## Loads the game's texts from a CSV file: the first column holds the key,
|
||||
## every further column one language, named in the first row.
|
||||
##
|
||||
## The file is read at run time. Its import setting is "keep", so Godot
|
||||
## generates no translation files from it.
|
||||
|
||||
const TEXTS_FILE := "res://i18n/texts.csv"
|
||||
|
||||
static var _installed := false
|
||||
|
||||
|
||||
## Adds the translations of TEXTS_FILE to the TranslationServer, once.
|
||||
static func install() -> void:
|
||||
if _installed:
|
||||
return
|
||||
for translation in load_translations(TEXTS_FILE):
|
||||
TranslationServer.add_translation(translation)
|
||||
_installed = true
|
||||
|
||||
|
||||
## One Translation per language column of the CSV file at `path`.
|
||||
static func load_translations(path: String) -> Array[Translation]:
|
||||
var translations: Array[Translation] = []
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("texts not found: %s" % path)
|
||||
return translations
|
||||
var header := file.get_csv_line()
|
||||
for column in range(1, header.size()):
|
||||
var translation := Translation.new()
|
||||
translation.locale = header[column]
|
||||
translations.append(translation)
|
||||
while not file.eof_reached():
|
||||
var row := file.get_csv_line()
|
||||
if row.size() < header.size() or row[0].is_empty():
|
||||
continue
|
||||
for column in range(1, header.size()):
|
||||
translations[column - 1].add_message(row[0], row[column])
|
||||
return translations
|
||||
@@ -0,0 +1 @@
|
||||
uid://dy10r7pfqy63v
|
||||
@@ -0,0 +1,35 @@
|
||||
class_name InputSetup
|
||||
extends RefCounted
|
||||
## Registers the game's input actions in code, so that project.godot holds no
|
||||
## serialized input events. Keys are physical positions, so WASD stays in
|
||||
## place on German keyboards too.
|
||||
|
||||
|
||||
static func install() -> void:
|
||||
_add_keys("move_up", [KEY_W, KEY_UP])
|
||||
_add_keys("move_down", [KEY_S, KEY_DOWN])
|
||||
_add_keys("move_left", [KEY_A, KEY_LEFT])
|
||||
_add_keys("move_right", [KEY_D, KEY_RIGHT])
|
||||
_add_keys("save_game", [KEY_F5])
|
||||
_add_keys("load_game", [KEY_F9])
|
||||
_add_keys("toggle_pause", [KEY_ESCAPE])
|
||||
_add_mouse_button("dig", MOUSE_BUTTON_LEFT)
|
||||
|
||||
|
||||
static func _add_keys(action: StringName, keys: Array[Key]) -> void:
|
||||
if InputMap.has_action(action):
|
||||
return
|
||||
InputMap.add_action(action)
|
||||
for key in keys:
|
||||
var event := InputEventKey.new()
|
||||
event.physical_keycode = key
|
||||
InputMap.action_add_event(action, event)
|
||||
|
||||
|
||||
static func _add_mouse_button(action: StringName, button: MouseButton) -> void:
|
||||
if InputMap.has_action(action):
|
||||
return
|
||||
InputMap.add_action(action)
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = button
|
||||
InputMap.action_add_event(action, event)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvckt6ss6k4xi
|
||||
@@ -0,0 +1,93 @@
|
||||
class_name SaveStore
|
||||
extends RefCounted
|
||||
## The game's files on the player's machine: the player key, the save and
|
||||
## the session recordings for bug reports.
|
||||
|
||||
const KEY_FILE := "player.key"
|
||||
const SAVE_FILE := "world.grw"
|
||||
const RECORDINGS_DIR := "recordings"
|
||||
## Recordings beyond this number are deleted, oldest first.
|
||||
const KEEP_RECORDINGS := 10
|
||||
const KEY_BYTES := 16
|
||||
|
||||
var base_dir: String
|
||||
|
||||
|
||||
func _init(directory: String = "user://") -> void:
|
||||
base_dir = directory
|
||||
DirAccess.make_dir_recursive_absolute(base_dir)
|
||||
|
||||
|
||||
## The key that identifies this installation in every world. It is created
|
||||
## on first use and kept from then on.
|
||||
func player_key() -> PackedByteArray:
|
||||
var path := base_dir.path_join(KEY_FILE)
|
||||
var key := FileAccess.get_file_as_bytes(path)
|
||||
if key.size() != KEY_BYTES:
|
||||
key = Crypto.new().generate_random_bytes(KEY_BYTES)
|
||||
_write(path, key)
|
||||
return key
|
||||
|
||||
|
||||
func has_save() -> bool:
|
||||
return FileAccess.file_exists(base_dir.path_join(SAVE_FILE))
|
||||
|
||||
|
||||
func read_save() -> PackedByteArray:
|
||||
return FileAccess.get_file_as_bytes(base_dir.path_join(SAVE_FILE))
|
||||
|
||||
|
||||
## Writes the save through a temporary file, so that a crash while writing
|
||||
## never destroys the previous save. Returns false if nothing was written.
|
||||
func write_save(bytes: PackedByteArray) -> bool:
|
||||
if bytes.is_empty():
|
||||
return false
|
||||
var path := base_dir.path_join(SAVE_FILE)
|
||||
var temporary := path + ".tmp"
|
||||
if not _write(temporary, bytes):
|
||||
return false
|
||||
return DirAccess.rename_absolute(temporary, path) == OK
|
||||
|
||||
|
||||
## 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.
|
||||
func set_aside_save() -> String:
|
||||
var path := base_dir.path_join(SAVE_FILE)
|
||||
var aside := path + ".broken"
|
||||
DirAccess.rename_absolute(path, aside)
|
||||
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
|
||||
|
||||
|
||||
func _write(path: String, bytes: PackedByteArray) -> bool:
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return false
|
||||
file.store_buffer(bytes)
|
||||
file.close()
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbp4jkbdfyawm
|
||||
@@ -0,0 +1,18 @@
|
||||
class_name TestFiles
|
||||
extends RefCounted
|
||||
## Temporary folders below user://tests for tests that write files, so that
|
||||
## tests never touch the player's real key, save or recordings.
|
||||
|
||||
|
||||
static func temporary_dir(label: String) -> String:
|
||||
var path := "user://tests".path_join("%s-%d" % [label, Time.get_ticks_usec()])
|
||||
DirAccess.make_dir_recursive_absolute(path)
|
||||
return path
|
||||
|
||||
|
||||
static func remove_dir(path: String) -> void:
|
||||
for file in DirAccess.get_files_at(path):
|
||||
DirAccess.remove_absolute(path.path_join(file))
|
||||
for directory in DirAccess.get_directories_at(path):
|
||||
remove_dir(path.path_join(directory))
|
||||
DirAccess.remove_absolute(path)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjpx0i0uknopn
|
||||
@@ -0,0 +1,35 @@
|
||||
extends TestCase
|
||||
|
||||
|
||||
func _by_locale() -> Dictionary:
|
||||
var result := {}
|
||||
for translation in I18n.load_translations(I18n.TEXTS_FILE):
|
||||
result[translation.locale] = translation
|
||||
return result
|
||||
|
||||
|
||||
func test_german_and_english_exist() -> void:
|
||||
var translations := _by_locale()
|
||||
check(translations.has("de"), "German")
|
||||
check(translations.has("en"), "English")
|
||||
|
||||
|
||||
func test_every_text_exists_in_every_language() -> void:
|
||||
var translations := _by_locale()
|
||||
var german: Translation = translations["de"]
|
||||
var english: Translation = translations["en"]
|
||||
var keys := german.get_message_list()
|
||||
keys.sort()
|
||||
var english_keys := english.get_message_list()
|
||||
english_keys.sort()
|
||||
check_eq(english_keys, keys, "the same keys")
|
||||
for key in keys:
|
||||
check(not String(german.get_message(key)).is_empty(), "German text for %s" % key)
|
||||
check(not String(english.get_message(key)).is_empty(), "English text for %s" % key)
|
||||
|
||||
|
||||
func test_the_stone_count_has_a_slot_for_the_number() -> void:
|
||||
var translations := _by_locale()
|
||||
for locale: String in translations:
|
||||
var text := String(translations[locale].get_message("STONE_COUNT"))
|
||||
check(text.contains("%d"), "%s: %s" % [locale, text])
|
||||
@@ -0,0 +1 @@
|
||||
uid://qmoy6gvht6ba
|
||||
@@ -0,0 +1,15 @@
|
||||
extends TestCase
|
||||
|
||||
|
||||
func test_every_action_of_the_game_exists() -> void:
|
||||
InputSetup.install()
|
||||
for action in ["move_up", "move_down", "move_left", "move_right", "dig", "save_game", "load_game", "toggle_pause"]:
|
||||
check(InputMap.has_action(action), "action %s" % action)
|
||||
|
||||
|
||||
func test_installing_twice_adds_no_second_set_of_keys() -> void:
|
||||
InputSetup.install()
|
||||
var events := InputMap.action_get_events("move_up").size()
|
||||
InputSetup.install()
|
||||
check_eq(InputMap.action_get_events("move_up").size(), events, "same number of keys")
|
||||
check_eq(events, 2, "W and the arrow key")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxk7wymwduoy2
|
||||
@@ -0,0 +1,56 @@
|
||||
extends TestCase
|
||||
|
||||
|
||||
func test_the_player_key_is_created_once_and_kept() -> void:
|
||||
var directory := TestFiles.temporary_dir("key")
|
||||
var first := SaveStore.new(directory).player_key()
|
||||
var second := SaveStore.new(directory).player_key()
|
||||
check_eq(first.size(), SaveStore.KEY_BYTES, "sixteen bytes")
|
||||
check_eq(second, first, "the same key on the next start")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_a_save_is_written_and_read_back() -> void:
|
||||
var directory := TestFiles.temporary_dir("save")
|
||||
var store := SaveStore.new(directory)
|
||||
check(not store.has_save(), "no save at first")
|
||||
check(store.write_save(PackedByteArray([1, 2, 3])), "written")
|
||||
check(store.write_save(PackedByteArray([4, 5])), "overwritten")
|
||||
check(store.has_save(), "a save exists")
|
||||
check_eq(store.read_save(), PackedByteArray([4, 5]), "the newest save is read")
|
||||
check(not FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".tmp")), "no temporary file is left")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_an_empty_save_is_not_written() -> void:
|
||||
var directory := TestFiles.temporary_dir("empty")
|
||||
var store := SaveStore.new(directory)
|
||||
check(not store.write_save(PackedByteArray()), "refused")
|
||||
check(not store.has_save(), "no file")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_an_unusable_save_can_be_set_aside() -> void:
|
||||
var directory := TestFiles.temporary_dir("aside")
|
||||
var store := SaveStore.new(directory)
|
||||
store.write_save(PackedByteArray([9, 9]))
|
||||
var aside := store.set_aside_save()
|
||||
check(not store.has_save(), "the save is out of the way")
|
||||
check_eq(FileAccess.get_file_as_bytes(aside), PackedByteArray([9, 9]), "its content is kept")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_only_the_newest_recordings_are_kept() -> void:
|
||||
var directory := TestFiles.temporary_dir("recordings")
|
||||
var store := SaveStore.new(directory)
|
||||
var paths: PackedStringArray = []
|
||||
for number in SaveStore.KEEP_RECORDINGS + 2:
|
||||
paths.append(store.write_recording(PackedByteArray([number])))
|
||||
OS.delay_usec(10)
|
||||
var names := store.recordings()
|
||||
check_eq(names.size(), SaveStore.KEEP_RECORDINGS, "only the newest are kept")
|
||||
check(not FileAccess.file_exists(paths[0]), "the oldest is gone")
|
||||
check(not FileAccess.file_exists(paths[1]), "the second oldest is gone")
|
||||
check(FileAccess.file_exists(paths[paths.size() - 1]), "the newest is there")
|
||||
check_eq(store.write_recording(PackedByteArray()), "", "an empty recording is not written")
|
||||
TestFiles.remove_dir(directory)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cd4grmj44ifao
|
||||
Reference in New Issue
Block a user