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