132646f6c3
ci / verify (push) Successful in 48s
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.
UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.
The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.
Two things found by actually looking at the screen, which is the point:
- The FIRST version of this styled nothing. A Control inherits its theme from
Control ANCESTORS only, and the chain breaks at the first plain Node or
CanvasLayer -- which is every screen here. get_window().theme set the
property, changed nothing, and read as correct. check.sh, 458 tests and a
clean smoke run all passed with the entire interface unstyled. The theme
test now instantiates every screen and asks what its buttons resolve.
- The settings screen showed Fire bound to the right mouse button, because
the test suite was writing the player's real user://settings.cfg --
rebinding calls save() and nothing had redirected the path. Settings.path
is now redirectable, the fixture points it at a scratch file, and a test
asserts the default is still the player's own.
tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.
check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
9.9 KiB
GDScript
257 lines
9.9 KiB
GDScript
extends GutTest
|
|
## Player preferences. Local, client-side, and touching nothing the server or
|
|
## the simulation cares about -- which key fires produces the same input frame
|
|
## either way.
|
|
|
|
const SCRATCH := "user://test_settings_%d.cfg"
|
|
|
|
var _saved_bindings: Dictionary[String, Dictionary] = {}
|
|
var _saved_master: float = 0.0
|
|
var _saved_sfx: float = 0.0
|
|
var _scratch: String = ""
|
|
|
|
|
|
func before_each() -> void:
|
|
# Settings is static, so a test that changed it would leak into the next
|
|
# one and into every other suite that reads the input map.
|
|
_saved_bindings = Settings.bindings.duplicate()
|
|
_saved_master = Settings.master_volume
|
|
_saved_sfx = Settings.sfx_volume
|
|
# And rebinding SAVES. Without redirecting the file, running the suite
|
|
# rewrote the player's own settings -- it really did, and it took a
|
|
# screenshot of the settings screen showing Fire on the right mouse button
|
|
# to notice.
|
|
_scratch = SCRATCH % randi()
|
|
Settings.path = _scratch
|
|
|
|
|
|
func after_each() -> void:
|
|
Settings.bindings = _saved_bindings
|
|
Settings.master_volume = _saved_master
|
|
Settings.sfx_volume = _saved_sfx
|
|
Settings.apply_input()
|
|
Settings.path = Settings.DEFAULT_PATH
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(_scratch))
|
|
|
|
|
|
func _key_event(code: Key) -> InputEventKey:
|
|
var e := InputEventKey.new()
|
|
e.device = -1
|
|
e.physical_keycode = code
|
|
return e
|
|
|
|
|
|
# --- What can be bound -------------------------------------------------------
|
|
|
|
func test_every_rebindable_action_actually_exists() -> void:
|
|
for entry in Settings.REBINDABLE:
|
|
assert_true(InputMap.has_action(String(entry[0])),
|
|
"%s is offered in settings but not in the input map" % entry[0])
|
|
assert_false(String(entry[1]).is_empty(), "%s has no label" % entry[0])
|
|
|
|
|
|
func test_every_action_starts_with_something_bound() -> void:
|
|
for entry in Settings.REBINDABLE:
|
|
assert_ne(Settings.binding_label(String(entry[0])), "unbound",
|
|
"%s shows as unbound before anything is changed" % entry[0])
|
|
|
|
|
|
## Keys and mouse buttons only. A listener that accepted any event would bind
|
|
## mouse MOTION the instant the player moved the mouse.
|
|
func test_only_keys_and_mouse_buttons_are_bindable() -> void:
|
|
assert_true(Settings.is_bindable(_key_event(KEY_J)))
|
|
var click := InputEventMouseButton.new()
|
|
click.button_index = MOUSE_BUTTON_RIGHT
|
|
assert_true(Settings.is_bindable(click))
|
|
assert_false(Settings.is_bindable(InputEventMouseMotion.new()))
|
|
assert_false(Settings.is_bindable(InputEventJoypadMotion.new()))
|
|
assert_false(Settings.is_bindable(_key_event(KEY_NONE)))
|
|
|
|
|
|
# --- Rebinding ---------------------------------------------------------------
|
|
|
|
func test_rebinding_changes_what_the_input_map_answers_to() -> void:
|
|
assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "",
|
|
"an unused key should be accepted")
|
|
assert_true(InputMap.action_has_event("fire", _key_event(KEY_J)))
|
|
assert_eq(Settings.binding_label("fire"), Settings.describe_label(
|
|
{"type": "key", "code": int(KEY_J)}))
|
|
|
|
|
|
## The old key must stop working. An action that kept its alternates would still
|
|
## answer to the key you just moved away from, which reads as the rebind having
|
|
## failed.
|
|
func test_the_previous_key_stops_working() -> void:
|
|
var before := Settings.current_binding("interact")
|
|
Settings.rebind("interact", _key_event(KEY_J))
|
|
var old := InputEventKey.new()
|
|
old.device = -1
|
|
old.physical_keycode = int(before["code"])
|
|
assert_false(InputMap.action_has_event("interact", old))
|
|
|
|
|
|
## One key doing two things is a broken control scheme, so it is refused rather
|
|
## than silently accepted and left for the player to work out.
|
|
func test_a_key_already_in_use_is_refused_and_names_the_clash() -> void:
|
|
Settings.rebind("interact", _key_event(KEY_J))
|
|
assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "interact")
|
|
assert_false(InputMap.action_has_event("fire", _key_event(KEY_J)),
|
|
"and the refusal changes nothing")
|
|
|
|
|
|
func test_rebinding_an_action_to_its_own_key_is_allowed() -> void:
|
|
Settings.rebind("fire", _key_event(KEY_J))
|
|
assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "",
|
|
"re-confirming a binding is not a clash with itself")
|
|
|
|
|
|
func test_reset_restores_what_the_project_shipped() -> void:
|
|
var original := Settings.binding_label("move_up")
|
|
Settings.rebind("move_up", _key_event(KEY_J))
|
|
assert_ne(Settings.binding_label("move_up"), original)
|
|
Settings.reset_bindings()
|
|
assert_eq(Settings.binding_label("move_up"), original)
|
|
assert_true(Settings.bindings.is_empty())
|
|
|
|
|
|
## The bindings the reset restores are the PROJECT's, captured before anything
|
|
## overrode them. Captured later they would be whatever the last session chose,
|
|
## and "reset" would restore the thing you were trying to undo.
|
|
func test_defaults_are_the_projects_own_not_the_last_sessions() -> void:
|
|
Settings.rebind("move_left", _key_event(KEY_J))
|
|
Settings.apply_input()
|
|
Settings._capture_defaults() # a second call must be a no-op
|
|
Settings.reset_bindings()
|
|
assert_ne(Settings.binding_label("move_left"),
|
|
Settings.describe_label({"type": "key", "code": int(KEY_J)}))
|
|
|
|
|
|
func test_a_mouse_button_can_be_bound() -> void:
|
|
var click := InputEventMouseButton.new()
|
|
click.device = -1
|
|
click.button_index = MOUSE_BUTTON_RIGHT
|
|
assert_eq(Settings.rebind("fire", click), "")
|
|
assert_eq(Settings.binding_label("fire"), "Mouse Right")
|
|
|
|
|
|
# --- Persistence -------------------------------------------------------------
|
|
|
|
func test_settings_survive_a_save_and_reload() -> void:
|
|
var path := SCRATCH % randi()
|
|
var cfg := ConfigFile.new()
|
|
cfg.set_value("audio", "master", 0.25)
|
|
cfg.set_value("audio", "sfx", 0.5)
|
|
cfg.set_value("input", "fire", {"type": "key", "code": int(KEY_J)})
|
|
cfg.save(path)
|
|
|
|
var loaded := ConfigFile.new()
|
|
assert_eq(loaded.load(path), OK)
|
|
assert_almost_eq(float(loaded.get_value("audio", "master")), 0.25, 0.001)
|
|
var described: Dictionary = loaded.get_value("input", "fire")
|
|
assert_eq(Settings.describe_label(described),
|
|
Settings.describe_label({"type": "key", "code": int(KEY_J)}))
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
|
|
|
|
|
## A binding this build cannot make sense of is dropped rather than guessed at.
|
|
## An unusable control is worse than the default one.
|
|
func test_a_nonsense_saved_binding_does_not_break_the_action() -> void:
|
|
assert_eq(Settings.describe_label({}), "unbound")
|
|
assert_eq(Settings.describe_label({"type": "gamepad", "code": 3}), "unbound")
|
|
assert_eq(Settings.describe_label({"type": "key", "code": 0}), "unbound")
|
|
|
|
|
|
# --- Audio -------------------------------------------------------------------
|
|
|
|
## linear_to_db(0) is -inf, which serialises badly and reads as a bug in a log.
|
|
## Silence is the mute flag; this only has to floor well below audible.
|
|
func test_silence_is_a_finite_number() -> void:
|
|
var quiet := Settings.linear_to_db_clamped(0.0)
|
|
assert_true(is_finite(quiet))
|
|
assert_lt(quiet, -60.0)
|
|
|
|
|
|
func test_full_volume_is_unattenuated() -> void:
|
|
assert_almost_eq(Settings.linear_to_db_clamped(1.0), 0.0, 0.01)
|
|
|
|
|
|
func test_volume_is_monotonic() -> void:
|
|
var last := -999.0
|
|
for step in 11:
|
|
var db := Settings.linear_to_db_clamped(float(step) / 10.0)
|
|
assert_gt(db, last, "louder input must not be quieter output")
|
|
last = db
|
|
|
|
|
|
## Effects play on their own bus so the sliders are real mixer settings rather
|
|
## than a number multiplied into every play() call.
|
|
func test_applying_audio_creates_the_effects_bus() -> void:
|
|
Settings.apply_audio()
|
|
var index := AudioServer.get_bus_index(Settings.SFX_BUS)
|
|
assert_gte(index, 0, "the SFX bus should exist after apply")
|
|
assert_eq(AudioServer.get_bus_send(index), &"Master")
|
|
|
|
|
|
func test_applying_audio_twice_does_not_add_a_second_bus() -> void:
|
|
Settings.apply_audio()
|
|
var before := AudioServer.bus_count
|
|
Settings.apply_audio()
|
|
assert_eq(AudioServer.bus_count, before)
|
|
|
|
|
|
func test_zero_volume_mutes_rather_than_merely_attenuating() -> void:
|
|
Settings.master_volume = 0.0
|
|
Settings.apply_audio()
|
|
assert_true(AudioServer.is_bus_mute(0))
|
|
Settings.master_volume = 0.8
|
|
Settings.apply_audio()
|
|
assert_false(AudioServer.is_bus_mute(0))
|
|
|
|
|
|
## A corrupt or hand-edited file can hold anything. Every one of these has to
|
|
## come out as a real number the mixer will accept.
|
|
func test_nonsense_volumes_still_produce_a_usable_number() -> void:
|
|
for value in [-5.0, -0.0001, 0.0, 1.5, 1e9]:
|
|
var db := Settings.linear_to_db_clamped(value)
|
|
assert_true(is_finite(db), "%f produced %f" % [value, db])
|
|
assert_lte(db, 0.0, "%f produced gain above unity" % value)
|
|
|
|
|
|
## A settings file from before an action existed -- or with a section missing
|
|
## entirely -- is an ordinary thing to find, not an error to log about. Reading
|
|
## it must be silent.
|
|
func test_a_settings_file_missing_keys_loads_without_complaint() -> void:
|
|
var path := SCRATCH % randi()
|
|
var cfg := ConfigFile.new()
|
|
cfg.set_value("audio", "master", 0.4) # audio only, no input section
|
|
cfg.save(path)
|
|
|
|
var loaded := ConfigFile.new()
|
|
assert_eq(loaded.load(path), OK)
|
|
for entry in Settings.REBINDABLE:
|
|
assert_false(loaded.has_section_key("input", String(entry[0])),
|
|
"setup: this file has no bindings at all")
|
|
# The read path must consult has_section_key rather than relying on a
|
|
# default, which is what made this log an engine error per missing action.
|
|
assert_false(loaded.has_section_key("input", "move_up"))
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
|
|
|
|
|
## The redirect above is a test fixture. If it were ever left in place -- or the
|
|
## default changed to something under a scratch directory -- players would
|
|
## silently stop keeping their settings.
|
|
func test_the_default_path_is_the_players_own_file() -> void:
|
|
assert_eq(Settings.DEFAULT_PATH, "user://settings.cfg")
|
|
assert_true(Settings.DEFAULT_PATH.begins_with("user://"))
|
|
|
|
|
|
## Saving has to actually reach the file the path points at, or the redirect
|
|
## above would hide a broken save rather than isolate a working one.
|
|
func test_saving_writes_to_the_configured_path() -> void:
|
|
Settings.master_volume = 0.33
|
|
Settings.save()
|
|
assert_true(FileAccess.file_exists(_scratch), "nothing was written")
|
|
var cfg := ConfigFile.new()
|
|
assert_eq(cfg.load(_scratch), OK)
|
|
assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001)
|