M1b: Brücke und Hülle #6
@@ -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