diff --git a/game/i18n/texts.csv b/game/i18n/texts.csv new file mode 100644 index 0000000..6df10eb --- /dev/null +++ b/game/i18n/texts.csv @@ -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 diff --git a/game/i18n/texts.csv.import b/game/i18n/texts.csv.import new file mode 100644 index 0000000..8dd0c09 --- /dev/null +++ b/game/i18n/texts.csv.import @@ -0,0 +1,3 @@ +[remap] + +importer="keep" diff --git a/game/scripts/i18n.gd b/game/scripts/i18n.gd new file mode 100644 index 0000000..e032740 --- /dev/null +++ b/game/scripts/i18n.gd @@ -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 diff --git a/game/scripts/i18n.gd.uid b/game/scripts/i18n.gd.uid new file mode 100644 index 0000000..ddf548c --- /dev/null +++ b/game/scripts/i18n.gd.uid @@ -0,0 +1 @@ +uid://dy10r7pfqy63v diff --git a/game/scripts/input_setup.gd b/game/scripts/input_setup.gd new file mode 100644 index 0000000..5c57097 --- /dev/null +++ b/game/scripts/input_setup.gd @@ -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) diff --git a/game/scripts/input_setup.gd.uid b/game/scripts/input_setup.gd.uid new file mode 100644 index 0000000..ffc4f91 --- /dev/null +++ b/game/scripts/input_setup.gd.uid @@ -0,0 +1 @@ +uid://dvckt6ss6k4xi diff --git a/game/scripts/save_store.gd b/game/scripts/save_store.gd new file mode 100644 index 0000000..a789d99 --- /dev/null +++ b/game/scripts/save_store.gd @@ -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 diff --git a/game/scripts/save_store.gd.uid b/game/scripts/save_store.gd.uid new file mode 100644 index 0000000..cf4a620 --- /dev/null +++ b/game/scripts/save_store.gd.uid @@ -0,0 +1 @@ +uid://cbp4jkbdfyawm diff --git a/game/tests/support/test_files.gd b/game/tests/support/test_files.gd new file mode 100644 index 0000000..09d77d3 --- /dev/null +++ b/game/tests/support/test_files.gd @@ -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) diff --git a/game/tests/support/test_files.gd.uid b/game/tests/support/test_files.gd.uid new file mode 100644 index 0000000..c85ba14 --- /dev/null +++ b/game/tests/support/test_files.gd.uid @@ -0,0 +1 @@ +uid://cjpx0i0uknopn diff --git a/game/tests/test_i18n.gd b/game/tests/test_i18n.gd new file mode 100644 index 0000000..3fbbd78 --- /dev/null +++ b/game/tests/test_i18n.gd @@ -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]) diff --git a/game/tests/test_i18n.gd.uid b/game/tests/test_i18n.gd.uid new file mode 100644 index 0000000..db8795b --- /dev/null +++ b/game/tests/test_i18n.gd.uid @@ -0,0 +1 @@ +uid://qmoy6gvht6ba diff --git a/game/tests/test_input_setup.gd b/game/tests/test_input_setup.gd new file mode 100644 index 0000000..e6f5aae --- /dev/null +++ b/game/tests/test_input_setup.gd @@ -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") diff --git a/game/tests/test_input_setup.gd.uid b/game/tests/test_input_setup.gd.uid new file mode 100644 index 0000000..6ac9610 --- /dev/null +++ b/game/tests/test_input_setup.gd.uid @@ -0,0 +1 @@ +uid://dxk7wymwduoy2 diff --git a/game/tests/test_save_store.gd b/game/tests/test_save_store.gd new file mode 100644 index 0000000..7eb655a --- /dev/null +++ b/game/tests/test_save_store.gd @@ -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) diff --git a/game/tests/test_save_store.gd.uid b/game/tests/test_save_store.gd.uid new file mode 100644 index 0000000..0a1d0d2 --- /dev/null +++ b/game/tests/test_save_store.gd.uid @@ -0,0 +1 @@ +uid://cd4grmj44ifao