872922e9e2
ci / verify (push) Successful in 48s
The jitter had two causes, and the larger one is embarrassing: boss_state() handed back the newest snapshot raw while players and enemies both went through the interpolator. The boss therefore stepped at the 20Hz snapshot rate instead of the frame rate. Invisible for as long as every boss stood still, and the first one that moved looked broken. It is interpolated now -- but not across an instance change, where the previous snapshot describes a different fight in a different room and lerping to it would fling the new boss across the map for a frame. The smaller cause was server-side: a CHASE boss corrects by the SIGN of its distance error, so at the standoff the sign flipped every tick and the boss vibrated a couple of pixels at 60Hz. It has a dead band now. The settings screen covers rebindable controls and volume, reachable from both the main menu and the in-game menu. Bindings are stored as physical keycodes -- following key position, the choice setup_input_map.gd already made -- and labelled back through the active layout so an AZERTY player reads the letter on the key their fingers are on. A rebind replaces every event on the action rather than the first, because an action that kept its alternates would still answer to the key you just moved away from. A key already in use is refused and the clash is named. Reset restores what the PROJECT shipped, captured once before anything overrides it -- captured later it would restore the last session's choice, which is the thing being undone. Effects play on an SFX bus created at runtime, so both sliders are real mixer settings rather than a number multiplied into every play() call. Worth recording: the test suite AND the smoke test both passed while a client logged twelve engine errors on every startup. ConfigFile.get_value(s, k, null) does not mean "no default" -- it means the key is absent and no default was given, and the engine logs an error per action. Nothing caught it because the smoke refutations matched SCRIPT ERROR and friends, and a plain ERROR: is none of those. It surfaced from running the client and reading the output. smoke.sh now asserts no plain engine errors either, excluding by name the one line Godot prints on every clean exit, and reintroducing the bug makes it fail. One mutation caught nothing and should not have: the early return in linear_to_db_clamped was dead code, since the clamp beneath it already prevents negative infinity. Removed rather than left looking tested. check.sh clean, 439 tests, SMOKE PASS (23 assertions), all four diagnostics green, and a real client boots with zero engine errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
8.7 KiB
GDScript
229 lines
8.7 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
|
|
|
|
|
|
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
|
|
|
|
|
|
func after_each() -> void:
|
|
Settings.bindings = _saved_bindings
|
|
Settings.master_volume = _saved_master
|
|
Settings.sfx_volume = _saved_sfx
|
|
Settings.apply_input()
|
|
|
|
|
|
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))
|