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
+49
View File
@@ -429,3 +429,52 @@ longest-lived bullet in the game.
The other seven frames stay in the atlas because that is what the source art is.
Nothing draws them, and the renderer now slices four textures at startup instead
of thirty-two.
---
## Settings
**Preferences are local to the client and nothing else knows about them.**
Which key fires produces the same [InputFrame] either way, so the server has no
opinion and never hears about it. `Settings` is static for the same reason
`GameOpts` is: a `--script` tool has no main loop and cannot resolve autoloads.
**Bindings are stored as physical keycodes and labelled through the active
layout.** Physical is what `tools/setup_input_map.gd` already uses, so bindings
follow key *position*; translating the label back means an AZERTY player reads
the letter printed on the key their fingers are on. The translation is skipped
on a display server with no keyboard — headless does not merely lack the call,
it logs an engine error and hands the argument back, and there is no feature
flag to test for.
**A rebind replaces every event on the action, not just the first.** Keeping the
alternates would mean the action still answered to the key you just moved away
from, which reads as the rebind not having worked. The cost is that rebinding
movement loses the arrow keys; untouched actions keep all their defaults.
**One key doing two things is refused, and the clash is named.** Silently
accepting it produces a control scheme that is broken in a way the player has to
diagnose themselves.
**"Reset" restores what the PROJECT shipped**, captured once before anything
overrides it. Captured later it would restore the last session's choice — that
is, the thing the player was trying to undo.
**Effects play on an SFX bus created at runtime.** A bus layout resource would
be one more file to keep in step with the code that reads it, and the sliders
being real mixer settings beats multiplying a number into every `play()` call.
---
## Drawing a boss that moves
**The boss is interpolated between snapshots like every other actor.** It was
not — `boss_state()` handed back the newest snapshot raw, so the boss stepped at
the 20 Hz snapshot rate rather than the frame rate. Invisible for as long as
every boss stood still, and the first one that moved looked broken. Not
interpolated across an instance change, though: the previous snapshot describes
a different fight in a different room.
**A CHASE boss has a dead band around its preferred distance.** The correction
is signed, so without one the sign flips every tick at the standoff and the boss
vibrates on the spot at the tick rate — a couple of pixels, and unmistakable.