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
+255
View File
@@ -0,0 +1,255 @@
class_name Settings
extends RefCounted
## Player preferences: key bindings and volumes.
##
## Client-side and local. Nothing here reaches the server or the simulation --
## which key you press to fire is not something the server has an opinion about,
## and the input frame it eventually produces looks identical either way.
##
## Static rather than an autoload for the same reason [GameOpts] is: a `--script`
## tool has no main loop and cannot resolve autoload names, and the settings
## file is something a headless test may well want to read.
const PATH := "user://settings.cfg"
## Actions the settings screen offers, in the order it lists them. Anything not
## here keeps whatever `tools/setup_input_map.gd` gave it and cannot be changed
## -- which is the right default for a binding nobody should be reassigning.
const REBINDABLE: Array = [
["move_up", "Move up"],
["move_down", "Move down"],
["move_left", "Move left"],
["move_right", "Move right"],
["fire", "Fire"],
["interact", "Interact / pick up"],
["emergency_escape", "Emergency escape"],
["use_slot_1", "Use slot 1"],
["use_slot_2", "Use slot 2"],
["use_slot_3", "Use slot 3"],
["use_slot_4", "Use slot 4"],
["system_menu", "Menu"],
["debug_overlay", "Hitbox overlay"],
]
## Bus every sound effect plays on. Created at runtime rather than shipped as a
## bus layout resource, so there is one fewer file to keep in step with the code
## that reads it.
const SFX_BUS := "SFX"
static var master_volume: float = 0.8
static var sfx_volume: float = 0.8
## action -> a serialisable description of its event. See _describe().
static var bindings: Dictionary[String, Dictionary] = {}
## What the project shipped, captured before anything is overridden. Without it
## "reset to defaults" would restore whatever the last session happened to save.
static var _defaults: Dictionary[String, Dictionary] = {}
static var _defaults_captured: bool = false
# --- Lifecycle ---------------------------------------------------------------
## Read the file and apply everything. Safe to call with no file present, which
## is every first run.
static func load_and_apply() -> void:
_capture_defaults()
var cfg := ConfigFile.new()
if cfg.load(PATH) == OK:
master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0)
sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0)
bindings.clear()
for action in REBINDABLE:
var key := String(action[0])
# has_section_key first: passing null as the default to get_value()
# does NOT mean "no default", it means the key is absent AND no
# default was given, and the engine logs an error for every missing
# one. A settings file written before an action existed is normal.
if not cfg.has_section_key("input", key):
continue
var stored: Variant = cfg.get_value("input", key)
# A binding this build cannot make sense of is dropped, not
# guessed at: an unusable control is worse than the default one.
if typeof(stored) == TYPE_DICTIONARY and _event_from(stored) != null:
bindings[key] = stored
apply_audio()
apply_input()
static func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("audio", "master", master_volume)
cfg.set_value("audio", "sfx", sfx_volume)
for action in bindings:
cfg.set_value("input", action, bindings[action])
var err := cfg.save(PATH)
if err != OK:
GameLog.warn("settings", "could not write %s (error %d)" % [PATH, err])
# --- Audio -------------------------------------------------------------------
## Ensure the SFX bus exists and push both volumes onto the mixer.
static func apply_audio() -> void:
var sfx := AudioServer.get_bus_index(SFX_BUS)
if sfx < 0:
AudioServer.add_bus()
sfx = AudioServer.bus_count - 1
AudioServer.set_bus_name(sfx, SFX_BUS)
AudioServer.set_bus_send(sfx, "Master")
AudioServer.set_bus_volume_db(0, linear_to_db_clamped(master_volume))
AudioServer.set_bus_mute(0, master_volume <= 0.0)
AudioServer.set_bus_volume_db(sfx, linear_to_db_clamped(sfx_volume))
AudioServer.set_bus_mute(sfx, sfx_volume <= 0.0)
## Godot's linear_to_db(0) is -inf, which serialises badly and reads as a bug
## when it turns up in a log. The clamp is what prevents that -- 0.0001 comes
## out at exactly -80dB, far below audible -- so a zero or a negative from a
## corrupt settings file lands there rather than at negative infinity. Actual
## silence is the bus mute flag, set alongside this.
static func linear_to_db_clamped(linear: float) -> float:
return linear_to_db(clampf(linear, 0.0001, 1.0))
# --- Input -------------------------------------------------------------------
## Snapshot the project's own bindings. Called before anything overrides them,
## and only once -- a second call after an override would capture the override.
static func _capture_defaults() -> void:
if _defaults_captured:
return
_defaults_captured = true
for entry in REBINDABLE:
var action := String(entry[0])
if not InputMap.has_action(action):
continue
var events := InputMap.action_get_events(action)
if not events.is_empty():
_defaults[action] = _describe(events[0])
## Rewrite the InputMap from [member bindings]. Actions with no override are
## restored to what the project shipped, so clearing one binding cannot leave a
## previous session's choice behind.
static func apply_input() -> void:
_capture_defaults()
for entry in REBINDABLE:
var action := String(entry[0])
if not InputMap.has_action(action):
continue
var described: Dictionary = bindings.get(action, _defaults.get(action, {}))
var event := _event_from(described)
if event == null:
continue
# Replacing every event rather than the first: an action that kept its
# alternates would still answer to the key the player just moved away
# from, which reads as the rebind not having worked.
InputMap.action_erase_events(action)
InputMap.action_add_event(action, event)
## Assign [param event] to [param action]. Returns the action it collided with,
## or an empty string on success -- one key doing two things is a broken
## control scheme, so it is refused rather than silently accepted.
static func rebind(action: String, event: InputEvent) -> String:
var described := _describe(event)
if described.is_empty():
return action
for entry in REBINDABLE:
var other := String(entry[0])
if other == action:
continue
if current_binding(other) == described:
return other
bindings[action] = described
apply_input()
save()
return ""
static func reset_bindings() -> void:
bindings.clear()
apply_input()
save()
## What [param action] is bound to right now: the override if there is one,
## otherwise what the project shipped.
static func current_binding(action: String) -> Dictionary:
_capture_defaults()
return bindings.get(action, _defaults.get(action, {}))
static func binding_label(action: String) -> String:
return describe_label(current_binding(action))
static func describe_label(described: Dictionary) -> String:
match String(described.get("type", "")):
"key":
var code := int(described.get("code", 0))
# 0 is not a key. _event_from refuses to build one, so a stored 0
# never reaches the input map -- but it would render as a blank
# button, which looks like a bound key with no name.
if code == 0:
return "unbound"
return OS.get_keycode_string(_layout_keycode(code))
"mouse":
match int(described.get("code", 0)):
MOUSE_BUTTON_LEFT: return "Mouse Left"
MOUSE_BUTTON_RIGHT: return "Mouse Right"
MOUSE_BUTTON_MIDDLE: return "Mouse Middle"
return "Mouse %d" % int(described["code"])
return "unbound"
## Bindings are stored as PHYSICAL keycodes, so they follow key position rather
## than layout -- the same choice tools/setup_input_map.gd makes. The label is
## translated back through the active layout so an AZERTY player reads "A" for
## the key their fingers are on, rather than the QWERTY name of that position.
##
## The translation is skipped on a display server that has no keyboard, which
## is headless: the call is not merely unsupported there, it pushes an engine
## error and hands the argument straight back, so asking is worse than not.
## There is no feature flag to test for it.
static func _layout_keycode(physical: int) -> int:
if physical == 0 or DisplayServer.get_name() == "headless":
return physical
return DisplayServer.keyboard_get_keycode_from_physical(physical)
## Only keys and mouse buttons are accepted. A rebind listener that took any
## InputEvent would happily capture mouse MOTION the instant the player moved
## the mouse, which is not a binding anyone meant to make.
static func is_bindable(event: InputEvent) -> bool:
return not _describe(event).is_empty()
static func _describe(event: InputEvent) -> Dictionary:
if event is InputEventKey:
var key := event as InputEventKey
var code := key.physical_keycode if key.physical_keycode != 0 else key.keycode
if code == 0:
return {}
return {"type": "key", "code": int(code)}
if event is InputEventMouseButton:
return {"type": "mouse", "code": int((event as InputEventMouseButton).button_index)}
return {}
static func _event_from(described: Dictionary) -> InputEvent:
match String(described.get("type", "")):
"key":
var k := InputEventKey.new()
# device -1 is the only value that matches input from a real
# device; a freshly constructed event defaults to 16, which
# silently matches nothing. See tools/setup_input_map.gd.
k.device = -1
k.physical_keycode = int(described.get("code", 0))
return k if k.physical_keycode != 0 else null
"mouse":
var m := InputEventMouseButton.new()
m.device = -1
m.button_index = int(described.get("code", 0))
return m if m.button_index != 0 else null
return null
+1
View File
@@ -0,0 +1 @@
uid://pdbmt03hxfum
+7
View File
@@ -139,6 +139,13 @@ const PARALLEL_OFFSET := 15.0
const SPLIT_ANGLE_DEG := 45.0
## How long one dose of Poison takes to deliver its damage.
const POISON_DURATION_TICKS := 600 # 10 seconds
## How near its preferred distance a CHASE boss counts as "there".
##
## Without a dead band the sign of the correction flips every tick once it
## arrives, and the boss buzzes on the spot at 60Hz. Small in world units, and
## unmistakable on screen.
const BOSS_CHASE_DEADBAND := 8.0
## How close to the hub's upgrade NPC you must stand to spend a choice.
## Enforced on the server, like the portal: standing somewhere is the only
## thing a client cannot lie about.
+5 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=9 format=3]
[gd_scene load_steps=10 format=3]
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
@@ -8,6 +8,7 @@
[ext_resource type="Script" path="res://src/view/sfx.gd" id="6"]
[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"]
[ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"]
[ext_resource type="Script" path="res://src/ui/settings_screen.gd" id="9"]
[node name="Game" type="Node2D"]
script = ExtResource("1")
@@ -33,3 +34,6 @@ script = ExtResource("7")
[node name="UpgradeScreen" type="CanvasLayer" parent="."]
script = ExtResource("8")
[node name="SettingsScreen" type="CanvasLayer" parent="."]
script = ExtResource("9")
+16
View File
@@ -10,6 +10,7 @@ const GAME_SCENE := preload("res://src/game.tscn")
const CONNECT_TIMEOUT_SEC := 8.0
var _menu: Control = null
var _settings: CanvasLayer = null
var _game: Node = null
var _ticks: int = 0
var _connecting: bool = false
@@ -23,6 +24,10 @@ func _ready() -> void:
# engine default and would silently desync server and client if that
# default ever changed.
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
# Bindings and volumes, before anything can read the input map or play a
# sound. A dedicated server has neither, so it does not pay for them.
if not GameOpts.is_server:
Settings.load_and_apply()
Net.state_changed.connect(_on_net_state)
if GameOpts.is_server:
@@ -63,6 +68,7 @@ func _show_menu() -> void:
_menu = preload("res://src/ui/main_menu.gd").new()
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
_menu.settings_requested.connect(_open_settings)
add_child(_menu)
# Unconditional, and the reason this is not an early-return when the menu
# already exists: a failed connection returns here with the buttons still
@@ -139,6 +145,16 @@ func _enter_game() -> void:
add_child(_game)
## The main menu's copy of the settings screen. The in-game menu has its own --
## they are the same screen, and neither exists while the other is on display.
func _open_settings() -> void:
if _settings == null:
_settings = preload("res://src/ui/settings_screen.gd").new()
_settings.closed.connect(func() -> void: _settings.visible = false)
add_child(_settings)
_settings.open()
func _clear_game() -> void:
if _game != null:
_game.queue_free()
+18 -1
View File
@@ -625,10 +625,27 @@ func inventory_full() -> bool:
return true
## The boss, interpolated between the last two snapshots exactly like every
## other actor.
##
## It used to be handed back raw, which meant it jumped to each new snapshot the
## moment it arrived -- a visible step three times slower than the frame rate.
## Nobody noticed while every boss stood still. The first one that moved looked
## broken.
func boss_state() -> Dictionary:
if snap_curr.is_empty() or snap_curr.get("boss") == null:
return {}
return snap_curr["boss"]
var rec: Dictionary = (snap_curr["boss"] as Dictionary).duplicate()
if snap_prev.is_empty() or snap_prev.get("boss") == null:
return rec
var old: Dictionary = snap_prev["boss"]
# Only between the same boss. Across an instance change the previous
# snapshot describes a different fight in a different room, and lerping to
# it would fling the new boss across the map for one frame.
if int(old["id"]) != int(rec["id"]):
return rec
rec["pos"] = (old["pos"] as Vector2).lerp(rec["pos"], _interp)
return rec
func _interpolated(list_key: String, id_key: String, exclude: Array) -> Array[Dictionary]:
+6 -1
View File
@@ -613,7 +613,12 @@ func _move_boss(phase: BossPhase) -> void:
# Signed, so it backs off when you close inside its preferred
# distance. A boss that ends up standing on you is a boss whose
# bullets you cannot see coming.
step = (to_player / gap) * signf(gap - phase.move_param) * phase.move_speed * dt
var error := gap - phase.move_param
# Close enough. Without this the sign flips every tick once it
# arrives and the boss buzzes on the spot at the tick rate.
if absf(error) <= SimConfig.BOSS_CHASE_DEADBAND:
return
step = (to_player / gap) * signf(error) * phase.move_speed * dt
BossPhase.Move.WAYPOINTS:
if phase.waypoints.is_empty():
return
+4
View File
@@ -10,6 +10,7 @@ signal resumed
signal return_to_hub_requested
signal disconnect_requested
signal characters_requested
signal settings_requested
var _panel: VBoxContainer
var _hub_button: Button
@@ -60,6 +61,9 @@ func _ready() -> void:
_characters_button = _button("Change character", func() -> void:
characters_requested.emit()
close())
_button("Settings", func() -> void:
settings_requested.emit()
close())
_button("Resume", func() -> void: close())
_disconnect_button = _button("Disconnect to menu", func() -> void:
disconnect_requested.emit()
+6
View File
@@ -4,6 +4,7 @@ extends Control
signal join_requested(address: String, port: int)
signal host_requested(port: int)
signal settings_requested
var _address: LineEdit
var _port: LineEdit
@@ -55,6 +56,11 @@ func _ready() -> void:
_host_button.pressed.connect(_on_host)
panel.add_child(_host_button)
var settings := Button.new()
settings.text = "Settings"
settings.pressed.connect(func() -> void: settings_requested.emit())
panel.add_child(settings)
var quit := Button.new()
quit.text = "Quit"
quit.pressed.connect(func() -> void: get_tree().quit())
+213
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://m6dfka8r5y2o
+3
View File
@@ -9,6 +9,7 @@ extends Node2D
@onready var sfx: Node = $Sfx
@onready var characters: CanvasLayer = $CharacterSelect
@onready var upgrades: CanvasLayer = $UpgradeScreen
@onready var settings: CanvasLayer = $SettingsScreen
var _bound: ClientRuntime = null
## Opened deliberately from the menu, as opposed to forced open by having no
@@ -34,6 +35,8 @@ func _ready() -> void:
characters.create_requested.connect(func(n: String) -> void: Net.create_character(n))
characters.closed.connect(func() -> void: _roster_open = false)
upgrades.closed.connect(func() -> void: _upgrades_open = false)
menu.settings_requested.connect(func() -> void: settings.open())
settings.closed.connect(func() -> void: settings.visible = false)
upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i))
+5 -1
View File
@@ -20,9 +20,13 @@ var _frame: int = 0
func _ready() -> void:
# Settings.apply_audio() creates the bus; make sure it exists before the
# players are pointed at it, or they silently fall back to Master and the
# effects slider does nothing.
Settings.apply_audio()
for i in VOICES:
var p := AudioStreamPlayer.new()
p.bus = "Master"
p.bus = Settings.SFX_BUS
add_child(p)
_voices.append(p)