Files
transcience/src/core/settings.gd
T
claude 132646f6c3
ci / verify (push) Successful in 48s
Rework the UI on Crusenho's pack; credit it in the game, not just the repo
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.

UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.

The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.

Two things found by actually looking at the screen, which is the point:

  - The FIRST version of this styled nothing. A Control inherits its theme from
    Control ANCESTORS only, and the chain breaks at the first plain Node or
    CanvasLayer -- which is every screen here. get_window().theme set the
    property, changed nothing, and read as correct. check.sh, 458 tests and a
    clean smoke run all passed with the entire interface unstyled. The theme
    test now instantiates every screen and asks what its buttons resolve.
  - The settings screen showed Fire bound to the right mouse button, because
    the test suite was writing the player's real user://settings.cfg --
    rebinding calls save() and nothing had redirected the path. Settings.path
    is now redirectable, the fixture points it at a scratch file, and a test
    asserts the default is still the player's own.

tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.

check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:12:13 +02:00

263 lines
9.9 KiB
GDScript

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 DEFAULT_PATH := "user://settings.cfg"
## Where preferences are read from and written to. A variable rather than a
## constant so tests can point it at a scratch file: rebinding calls save(),
## and a suite run was otherwise rewriting the player's real settings -- which
## it did, silently, until a screenshot showed Fire bound to the right mouse
## button.
static var path: String = DEFAULT_PATH
## 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