ci / verify (push) Successful in 53s
Prompts named the default keys whatever was bound. Every action is rebindable, but the HUD said "WASD move", "LMB fire", "hold F" and "1-4 use", the quartermaster said "E talk" and the upgrade screen "Close (E)". All of them now go through Settings.short_label(), and a test rebinds fire, escape, interact and a slot key and checks each prompt follows. The HUD's top-left was a developer's status line -- name, instance id, health as text, frame rate -- with the boss bar drawn over its right end and the level-up notice sharing a row with the escape bar. It is now name and level, where you are, health with its numbers, experience, escape and level-ups, stacked by a running cursor so nothing has a fixed row to collide on. Instance and fps moved behind F1, which is who they were for. Arrival protection is a centred banner rather than a note beside the health bar, because it is the one moment the rules change. A hit used to tint the whole screen red, which in a bullet hell dims the bullets at exactly the moment you are among them. It is an edge now, and the same edge pulses below a third of your health. New: a message feed above the inventory for what the server just confirmed -- items taken, used and dropped, levels gained -- and party health top-right in a dungeon, where the hub shows who is online. The pickup prompt now also names the dungeon under your feet when you stand on its ring, in the same order the server resolves that key: loot first, then the entrance. The boss bar used to announce the boss to anyone within the actor interest radius -- which passes through walls -- so a player in the entrance room was told the Warden was near. It waits until the fog has actually lit the boss once, then stays for the fight. Permadeath was announced only by the roster screen appearing. The client records its own death from the server's event and the roster says who fell, at what level, and where. The connect screen lost a name field that did nothing for a person -- it only named the characters bots create -- gained a proper panel, remembers the last server, and shows the protocol number a mismatch refusal will quote. And screenshot.tscn ran as the player's real account against their real character store, walking their character into dungeons for pictures -- with health now carried between runs, a stray hit would have stuck. It has its own store and account now, like every diagnostic, and a seeded character. The real store was checked: nothing it carries changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
11 KiB
GDScript
297 lines
11 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
|
|
## The server last joined, so the connect screen does not ask for the same
|
|
## address every launch. Empty until the first join. Client-local like
|
|
## everything else here -- it is where you play, not who you are.
|
|
static var last_address: String = ""
|
|
static var last_port: int = 0
|
|
## 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)
|
|
last_address = String(cfg.get_value("connection", "address", ""))
|
|
last_port = clampi(int(cfg.get_value("connection", "port", 0)), 0, 65535)
|
|
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)
|
|
if not last_address.is_empty():
|
|
cfg.set_value("connection", "address", last_address)
|
|
cfg.set_value("connection", "port", last_port)
|
|
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])
|
|
|
|
|
|
## Remember where the player just tried to connect.
|
|
static func remember_server(address: String, port: int) -> void:
|
|
var clean := address.strip_edges()
|
|
if clean.is_empty() or port <= 0 or port > 65535:
|
|
return
|
|
last_address = clean
|
|
last_port = port
|
|
save()
|
|
|
|
|
|
# --- 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))
|
|
|
|
|
|
## The binding as short as it can be said, for prompts on screen: "LMB" rather
|
|
## than "Mouse Left", "Esc" rather than "Escape". Every key the game names to the
|
|
## player goes through this, because every one of them is rebindable and a
|
|
## prompt that names the default is wrong for anyone who changed it.
|
|
static func short_label(action: String) -> String:
|
|
var label := binding_label(action)
|
|
match label:
|
|
"Mouse Left": return "LMB"
|
|
"Mouse Right": return "RMB"
|
|
"Mouse Middle": return "MMB"
|
|
"Escape": return "Esc"
|
|
return label
|
|
|
|
|
|
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
|