Fix boss jitter; add a settings screen for controls and volume
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>
This commit is contained in:
2026-09-07 10:56:44 +02:00
parent 4a98cf4b0e
commit 872922e9e2
22 changed files with 943 additions and 7 deletions
+25
View File
@@ -284,3 +284,28 @@ func test_the_cantor_survives_its_own_fight() -> void:
assert_eq(phases_seen.size(), b.def.phases.size(), "every phase ran")
assert_gt(telegraphs, 0, "and it warned before striking at least once")
assert_gt(inst.world.pool.live_count, 0, "and it is actually shooting")
# --- Not looking jittery ----------------------------------------------------
## Once it has arrived it stays arrived. The correction is signed, so without a
## dead band the sign flips every tick at the standoff distance and the boss
## vibrates on the spot -- tiny in world units, unmistakable on screen.
func test_a_chasing_boss_settles_instead_of_buzzing() -> void:
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
var p := world.add_player(1, "bait")
p.pos = Vector2(100.0, 0.0)
_step(300)
var settled := b.pos
var worst := 0.0
for _i in 120:
world.step()
worst = maxf(worst, b.pos.distance_to(settled))
assert_lt(worst, 0.001, "it moved %.2fpx after settling" % worst)
func test_the_dead_band_is_smaller_than_the_distance_it_guards() -> void:
# A band as wide as the standoff would mean the boss stops anywhere.
for phase in Content.cantor().phases:
if phase.move == BossPhase.Move.CHASE:
assert_lt(SimConfig.BOSS_CHASE_DEADBAND, phase.move_param * 0.25)
+228
View File
@@ -0,0 +1,228 @@
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))
+1
View File
@@ -0,0 +1 @@
uid://cyicgu8318br2
+47
View File
@@ -217,3 +217,50 @@ func test_arriving_somewhere_clears_the_old_warnings() -> void:
client.on_enter_instance(7, Protocol.InstanceKind.LOBBY, 0, "", Vector2.ZERO,
20, 20, PackedByteArray(), "", Vector2.ZERO)
assert_eq(client.telegraphs.size(), 0)
# --- Boss interpolation -----------------------------------------------------
func _client_with_snapshots(prev_pos: Vector2, curr_pos: Vector2,
prev_id: int = 1, curr_id: int = 1) -> ClientRuntime:
var client: ClientRuntime = autofree(ClientRuntime.new())
client.snap_prev = {"boss": {"id": prev_id, "pos": prev_pos, "hp": 10, "phase": 0}}
client.snap_curr = {"boss": {"id": curr_id, "pos": curr_pos, "hp": 10, "phase": 0}}
return client
## The boss is drawn between the last two snapshots like every other actor. It
## was not, which is invisible while bosses stand still and looks like a
## stuttering mess the moment one moves.
func test_the_boss_is_interpolated_between_snapshots() -> void:
var client := _client_with_snapshots(Vector2.ZERO, Vector2(100.0, 0.0))
client._interp = 0.0
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 0.0, 0.01)
client._interp = 0.5
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 50.0, 0.01)
client._interp = 1.0
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 100.0, 0.01)
## Across an instance change the previous snapshot describes a different fight
## in a different room; lerping to it would fling the new boss across the map.
func test_two_different_bosses_are_never_interpolated_together() -> void:
var client := _client_with_snapshots(Vector2.ZERO, Vector2(900.0, 0.0), 1, 2)
client._interp = 0.5
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 900.0, 0.01)
func test_the_first_snapshot_of_a_fight_is_used_as_is() -> void:
var client: ClientRuntime = autofree(ClientRuntime.new())
client.snap_curr = {"boss": {"id": 1, "pos": Vector2(40.0, 0.0), "hp": 5, "phase": 0}}
client._interp = 0.5
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 40.0, 0.01)
## Reading it must not rewrite the snapshot -- the next frame interpolates from
## the same pair again, and a mutated one would creep.
func test_reading_the_boss_does_not_modify_the_snapshot() -> void:
var client := _client_with_snapshots(Vector2.ZERO, Vector2(100.0, 0.0))
client._interp = 0.5
client.boss_state()
assert_almost_eq((client.snap_curr["boss"]["pos"] as Vector2).x, 100.0, 0.01)