M1b: Brücke und Hülle #6

Merged
steven merged 21 commits from m1b-bruecke-und-huelle into main 2026-10-01 11:46:02 +00:00
16 changed files with 313 additions and 0 deletions
Showing only changes of commit ccf9813c80 - Show all commits
+10
View File
@@ -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
1 keys de en
2 STONE_COUNT Stein: %d Stone: %d
3 SAVED Gespeichert Saved
4 SAVE_FAILED Speichern fehlgeschlagen Saving failed
5 LOADED Spielstand geladen Save loaded
6 LOAD_FAILED Der Spielstand ließ sich nicht laden The save could not be loaded
7 INVENTORY_FULL Die Tasche ist voll Your bag is full
8 PAUSED Pause Paused
9 SIMULATION_STOPPED Die Simulation wurde angehalten The simulation stopped
10 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
+3
View File
@@ -0,0 +1,3 @@
[remap]
importer="keep"
+41
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dy10r7pfqy63v
+35
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dvckt6ss6k4xi
+93
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cbp4jkbdfyawm
+18
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cjpx0i0uknopn
+35
View File
@@ -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])
+1
View File
@@ -0,0 +1 @@
uid://qmoy6gvht6ba
+15
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://dxk7wymwduoy2
+56
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cd4grmj44ifao