132646f6c3
ci / verify (push) Successful in 48s
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>
263 lines
9.9 KiB
GDScript
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
|