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:
1 parent
4a98cf4b0e
commit
872922e9e2
22 files changed
+943
-7
No files matched your search
@@ -0,0 +1,213 @@
|
||||
extends CanvasLayer
|
||||
## Volumes and key bindings.
|
||||
##
|
||||
## Reachable from the main menu and from the in-game menu, and the same screen
|
||||
## both times: settings are local to this client, so there is nothing about the
|
||||
## game's state that should change what it can do.
|
||||
|
||||
signal closed
|
||||
|
||||
var _rows: Dictionary[String, Button] = {}
|
||||
var _status: Label
|
||||
var _master: HSlider
|
||||
var _sfx: HSlider
|
||||
var _master_value: Label
|
||||
var _sfx_value: Label
|
||||
## The action currently waiting for a key press, or empty.
|
||||
var _listening: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 32
|
||||
visible = false
|
||||
var root := Control.new()
|
||||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(root)
|
||||
|
||||
var scrim := ColorRect.new()
|
||||
scrim.color = Color(0.03, 0.03, 0.06, 0.94)
|
||||
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
scrim.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
root.add_child(scrim)
|
||||
|
||||
var centre := CenterContainer.new()
|
||||
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(centre)
|
||||
|
||||
var panel := VBoxContainer.new()
|
||||
panel.custom_minimum_size = Vector2(520.0, 0.0)
|
||||
panel.add_theme_constant_override("separation", 8)
|
||||
centre.add_child(panel)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "SETTINGS"
|
||||
title.add_theme_font_size_override("font_size", 26)
|
||||
panel.add_child(title)
|
||||
|
||||
panel.add_child(_heading("SOUND"))
|
||||
var master_row := _slider_row(panel, "Master")
|
||||
_master = master_row[0]
|
||||
_master_value = master_row[1]
|
||||
var sfx_row := _slider_row(panel, "Effects")
|
||||
_sfx = sfx_row[0]
|
||||
_sfx_value = sfx_row[1]
|
||||
|
||||
panel.add_child(_heading("CONTROLS"))
|
||||
_status = Label.new()
|
||||
_status.add_theme_font_size_override("font_size", 12)
|
||||
_status.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5))
|
||||
panel.add_child(_status)
|
||||
|
||||
# Scrolled: thirteen rows is more than fits on a short window, and a list
|
||||
# that runs off the bottom of the screen is a list with unreachable entries.
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.custom_minimum_size = Vector2(520.0, 300.0)
|
||||
panel.add_child(scroll)
|
||||
var list := VBoxContainer.new()
|
||||
list.custom_minimum_size = Vector2(500.0, 0.0)
|
||||
list.add_theme_constant_override("separation", 3)
|
||||
scroll.add_child(list)
|
||||
for entry in Settings.REBINDABLE:
|
||||
_rows[String(entry[0])] = _binding_row(list, String(entry[0]), String(entry[1]))
|
||||
|
||||
var buttons := HBoxContainer.new()
|
||||
buttons.add_theme_constant_override("separation", 8)
|
||||
panel.add_child(buttons)
|
||||
var reset := Button.new()
|
||||
reset.text = "Reset controls"
|
||||
reset.custom_minimum_size = Vector2(160.0, 34.0)
|
||||
reset.pressed.connect(func() -> void:
|
||||
Settings.reset_bindings()
|
||||
_status.text = "controls reset"
|
||||
refresh())
|
||||
buttons.add_child(reset)
|
||||
var close := Button.new()
|
||||
close.text = "Close"
|
||||
close.custom_minimum_size = Vector2(160.0, 34.0)
|
||||
close.pressed.connect(func() -> void: closed.emit())
|
||||
buttons.add_child(close)
|
||||
|
||||
|
||||
func _heading(text: String) -> Label:
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", 14)
|
||||
l.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
|
||||
return l
|
||||
|
||||
|
||||
func _slider_row(parent: Control, label: String) -> Array:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
parent.add_child(row)
|
||||
var name_label := Label.new()
|
||||
name_label.text = label
|
||||
name_label.custom_minimum_size = Vector2(90.0, 0.0)
|
||||
row.add_child(name_label)
|
||||
var slider := HSlider.new()
|
||||
slider.min_value = 0.0
|
||||
slider.max_value = 1.0
|
||||
slider.step = 0.05
|
||||
slider.custom_minimum_size = Vector2(340.0, 20.0)
|
||||
row.add_child(slider)
|
||||
var value := Label.new()
|
||||
value.custom_minimum_size = Vector2(50.0, 0.0)
|
||||
row.add_child(value)
|
||||
return [slider, value]
|
||||
|
||||
|
||||
func _binding_row(parent: Control, action: String, label: String) -> Button:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
parent.add_child(row)
|
||||
var name_label := Label.new()
|
||||
name_label.text = label
|
||||
name_label.custom_minimum_size = Vector2(260.0, 0.0)
|
||||
row.add_child(name_label)
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = Vector2(200.0, 30.0)
|
||||
button.pressed.connect(func() -> void: _listen_for(action))
|
||||
row.add_child(button)
|
||||
return button
|
||||
|
||||
|
||||
func open() -> void:
|
||||
visible = true
|
||||
_listening = ""
|
||||
_status.text = ""
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
_master.value = Settings.master_volume
|
||||
_sfx.value = Settings.sfx_volume
|
||||
_master_value.text = "%d%%" % roundi(Settings.master_volume * 100.0)
|
||||
_sfx_value.text = "%d%%" % roundi(Settings.sfx_volume * 100.0)
|
||||
# Connected here rather than in _ready so setting the values above cannot
|
||||
# fire the handler and write the file back on every open.
|
||||
if not _master.value_changed.is_connected(_on_master):
|
||||
_master.value_changed.connect(_on_master)
|
||||
_sfx.value_changed.connect(_on_sfx)
|
||||
for action in _rows:
|
||||
_rows[action].text = "listening..." if action == _listening \
|
||||
else Settings.binding_label(action)
|
||||
|
||||
|
||||
func _on_master(value: float) -> void:
|
||||
Settings.master_volume = value
|
||||
Settings.apply_audio()
|
||||
Settings.save()
|
||||
_master_value.text = "%d%%" % roundi(value * 100.0)
|
||||
|
||||
|
||||
func _on_sfx(value: float) -> void:
|
||||
Settings.sfx_volume = value
|
||||
Settings.apply_audio()
|
||||
Settings.save()
|
||||
_sfx_value.text = "%d%%" % roundi(value * 100.0)
|
||||
|
||||
|
||||
func _listen_for(action: String) -> void:
|
||||
_listening = action
|
||||
_status.text = "press a key or mouse button (Escape cancels)"
|
||||
refresh()
|
||||
|
||||
|
||||
## Captures the next key or mouse button while a row is listening.
|
||||
##
|
||||
## _input rather than _unhandled_input on purpose: the buttons in this screen
|
||||
## consume clicks, and a rebind that could never see a mouse button would be a
|
||||
## rebind that cannot bind Fire.
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not visible or _listening.is_empty():
|
||||
return
|
||||
if event is InputEventKey and not (event as InputEventKey).pressed:
|
||||
return
|
||||
if event is InputEventMouseButton and not (event as InputEventMouseButton).pressed:
|
||||
return
|
||||
if not Settings.is_bindable(event):
|
||||
return
|
||||
get_viewport().set_input_as_handled()
|
||||
var action := _listening
|
||||
_listening = ""
|
||||
# Escape is the way out of a listening row, so it cannot also be captured
|
||||
# by one -- binding the cancel key would leave no way to cancel.
|
||||
if event is InputEventKey \
|
||||
and (event as InputEventKey).physical_keycode == KEY_ESCAPE:
|
||||
_status.text = "cancelled"
|
||||
refresh()
|
||||
return
|
||||
var clash := Settings.rebind(action, event)
|
||||
if clash.is_empty():
|
||||
_status.text = ""
|
||||
else:
|
||||
_status.text = "%s is already %s" % [
|
||||
Settings.binding_label(clash), _label_of(clash)]
|
||||
refresh()
|
||||
|
||||
|
||||
func _label_of(action: String) -> String:
|
||||
for entry in Settings.REBINDABLE:
|
||||
if String(entry[0]) == action:
|
||||
return String(entry[1])
|
||||
return action
|
||||
Reference in new issue
Block a user