diff --git a/systems/config/settings.gd b/systems/config/settings.gd index abe8aad..0fa75e7 100644 --- a/systems/config/settings.gd +++ b/systems/config/settings.gd @@ -12,6 +12,9 @@ const SECTION := "options" var screen_shake: bool = true ## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed). var gore: bool = true +## Controller rumble toggle — player-facing; Controls.vibrate() also respects the DP +## "gamepad_vibration" debug flag, so either can silence it. +var vibration: bool = true ## Audio volumes stored as linear 0–1. Applied to AudioServer buses on load/change. var volume_master: float = 1.0 @@ -62,6 +65,14 @@ func set_gore(value: bool) -> void: save() +func set_vibration(value: bool) -> void: + if vibration == value: + return + vibration = value + changed.emit("vibration", value) + save() + + func set_volume_master(value: float) -> void: volume_master = clampf(value, 0.0, 1.0) AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), _vol_db(volume_master)) @@ -96,6 +107,7 @@ func save() -> void: var cfg := ConfigFile.new() cfg.set_value(SECTION, "screen_shake", screen_shake) cfg.set_value(SECTION, "gore", gore) + cfg.set_value(SECTION, "vibration", vibration) cfg.set_value(SECTION, "volume_master", volume_master) cfg.set_value(SECTION, "volume_ambient", volume_ambient) cfg.set_value(SECTION, "volume_effects", volume_effects) @@ -110,6 +122,7 @@ func load_saved() -> void: return screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool gore = cfg.get_value(SECTION, "gore", true) as bool + vibration = cfg.get_value(SECTION, "vibration", true) as bool volume_master = cfg.get_value(SECTION, "volume_master", 1.0) as float volume_ambient = cfg.get_value(SECTION, "volume_ambient", 1.0) as float volume_effects = cfg.get_value(SECTION, "volume_effects", 1.0) as float diff --git a/systems/input/controls_manager.gd b/systems/input/controls_manager.gd index 296cc1d..c76df46 100644 --- a/systems/input/controls_manager.gd +++ b/systems/input/controls_manager.gd @@ -7,6 +7,7 @@ extends Node const SAVE_PATH := "user://controls.cfg" const SECTION := "keybinds" +const JOY_SECTION := "joybinds" ## Actions the options menu lets the player rebind (keyboard only). const REBINDABLE_ACTIONS: PackedStringArray = [ @@ -20,6 +21,15 @@ const REBINDABLE_ACTIONS: PackedStringArray = [ &"ability_roll", ] +## Actions the options menu also lets the player rebind on a gamepad. Movement stays on the +## D-pad/stick (standard, not individually remapped); only the face-button abilities are. +const JOY_REBINDABLE_ACTIONS: PackedStringArray = [ + &"ability_kick", + &"ability_dash", + &"ability_slam", + &"ability_roll", +] + func _ready() -> void: _ensure_gamepad_confirm_binding() @@ -35,15 +45,20 @@ func _ready() -> void: # gamepad glyphs. A small deadzone on joypad axes avoids stick drift/noise doing the same. const _JOY_AXIS_DEADZONE := 0.5 var _last_input_is_gamepad: bool = false +## Device id of whichever gamepad was pressed most recently — which controller's name we +## check to pick Xbox vs PlayStation glyphs when more than one is connected. +var _last_joy_device: int = 0 func _input(event: InputEvent) -> void: if event is InputEventJoypadButton: if event.pressed: _last_input_is_gamepad = true + _last_joy_device = event.device elif event is InputEventJoypadMotion: if absf(event.axis_value) > _JOY_AXIS_DEADZONE: _last_input_is_gamepad = true + _last_joy_device = event.device elif event is InputEventKey: if event.pressed: _last_input_is_gamepad = false @@ -59,23 +74,88 @@ func using_gamepad() -> bool: # Godot's joypad button indices are already Xbox-position-normalized — by SDL on native, by -# the browser's Standard Gamepad Mapping on web — so this reads correctly for any controller -# brand (a Nintendo pad's physically-labelled "A" is button 1 here, same as "B" on Xbox). +# the browser's Standard Gamepad Mapping on web — so a position-based glyph table reads +# correctly for any controller brand with no per-device special-casing (a Nintendo pad's +# physically-labelled "A" is button 1 here, same as "B" on Xbox; PlayStation's ✕/○/□/△ sit +# in the exact same four positions as Xbox's A/B/X/Y, so the same button-index keys apply). const _XBOX_BUTTON_GLYPHS := { JOY_BUTTON_A: "A", JOY_BUTTON_B: "B", JOY_BUTTON_X: "X", JOY_BUTTON_Y: "Y", } +const _PLAYSTATION_BUTTON_GLYPHS := { + JOY_BUTTON_A: "✕", + JOY_BUTTON_B: "○", + JOY_BUTTON_X: "□", + JOY_BUTTON_Y: "△", +} +## Substrings (checked lower-case) that identify a controller as a PlayStation pad from +## Input.get_joy_name() — which varies a lot by OS/browser/driver, so this is necessarily a +## heuristic. "wireless controller" is what macOS/some browsers call a DualShock 4 with no +## further detail — but a Bluetooth-connected Xbox pad reports as "Xbox Wireless Controller", +## so _is_playstation_pad checks for "xbox" first and bails out before these hints, or that +## exact string would misfire as PlayStation. Anything that matches neither reads as +## Xbox-style — the safe default, since unrecognised/generic pads and the browser's Standard +## Gamepad Mapping are Xbox-shaped. +const _PLAYSTATION_NAME_HINTS := [ + "sony", "playstation", "dualshock", "dualsense", "ps3", "ps4", "ps5", "wireless controller", +] -## Xbox-style face-button glyph ("A"/"B"/"X"/"Y") for the action's bound joypad button, or -## "—" if it has none. -func get_joy_button_label(action: StringName) -> String: +## True if the given gamepad's reported name looks like a PlayStation pad (DualShock/ +## DualSense), false (Xbox-style) for everything else, including unknown controllers. +func _is_playstation_pad(device: int) -> bool: + var name := Input.get_joy_name(device).to_lower() + if name.contains("xbox"): + return false + for hint: String in _PLAYSTATION_NAME_HINTS: + if name.contains(hint): + return true + return false + + +## Returns the InputEventJoypadButton bound to `action`, or null. +func _get_joy_button_event(action: StringName) -> InputEventJoypadButton: for event: InputEvent in InputMap.action_get_events(action): if event is InputEventJoypadButton: - return _XBOX_BUTTON_GLYPHS.get(event.button_index, "?") - return "—" + return event + return null + + +## Face-button glyph for the action's bound joypad button — Xbox letters (A/B/X/Y) or +## PlayStation shapes (✕/○/□/△) depending on whichever pad was pressed most recently — or +## "—" if the action has no joypad binding. +func get_joy_button_label(action: StringName) -> String: + var event := _get_joy_button_event(action) + if event == null: + return "—" + var glyphs := _PLAYSTATION_BUTTON_GLYPHS if _is_playstation_pad(_last_joy_device) \ + else _XBOX_BUTTON_GLYPHS + return glyphs.get(event.button_index, "Btn %d" % event.button_index) + + +## Replaces the joypad button bound to `action`, keeping every other event (keyboard, +## alternates) in place. +func rebind_joy(action: StringName, event: InputEventJoypadButton) -> void: + _rebind_joy_no_save(action, event) + save() + + +## Rebuilds the action's event list, swapping the first joypad-button event for `event` in +## place so ordering is preserved, same as _rebind_no_save() does for keyboard. +func _rebind_joy_no_save(action: StringName, event: InputEventJoypadButton) -> void: + var events := InputMap.action_get_events(action) + InputMap.action_erase_events(action) + var replaced := false + for existing: InputEvent in events: + if not replaced and existing is InputEventJoypadButton: + InputMap.action_add_event(action, event) + replaced = true + else: + InputMap.action_add_event(action, existing) + if not replaced: + InputMap.action_add_event(action, event) ## Ability-bar keycap label for `action`: the bound Xbox-style face button while a gamepad @@ -293,13 +373,14 @@ func rigid_skin_enabled() -> bool: return DP.b("use_rigid_skin") -## Rumble a connected gamepad, gated on the DP "gamepad_vibration" flag. Off web this is a thin -## wrapper over Input.start_joy_vibration(); on web that call is a documented engine no-op +## Rumble a connected gamepad, gated on the player-facing Settings.vibration toggle and the +## DP "gamepad_vibration" debug flag (either can silence it). Off web this is a thin wrapper +## over Input.start_joy_vibration(); on web that call is a documented engine no-op ## (godotengine/godot#96985 — never wired to the browser side), so there we drive the Gamepad ## Haptic Actuator API ourselves. Safe no-op wherever it's unsupported (Safari, most Firefox ## versions, or a controller with no rumble motors) — desktop Chromium/Edge is the target. func vibrate(device: int, weak_magnitude: float, strong_magnitude: float, duration: float) -> void: - if not DP.b("gamepad_vibration"): + if not Settings.vibration or not DP.b("gamepad_vibration"): return if not OS.has_feature("web"): Input.start_joy_vibration(device, weak_magnitude, strong_magnitude, duration) @@ -344,7 +425,7 @@ func vibrate_for(device: int, weak_magnitude: float, strong_magnitude: float, ## Returns the first keyboard event bound to an action, or null. -func get_key_event(action: StringName) -> InputEventKey: +func _get_key_event(action: StringName) -> InputEventKey: for event: InputEvent in InputMap.action_get_events(action): if event is InputEventKey: return event @@ -353,7 +434,7 @@ func get_key_event(action: StringName) -> InputEventKey: ## Human-readable label for the action's current key (e.g. "W", "Space"). func get_key_label(action: StringName) -> String: - var event := get_key_event(action) + var event := _get_key_event(action) if event == null: return "—" return event.as_text_physical_keycode() @@ -362,16 +443,20 @@ func get_key_label(action: StringName) -> String: ## Replaces the primary keyboard event of `action` with `event`, keeping ## every other event (e.g. arrow-key alternates, joypad) in its place. func rebind(action: StringName, event: InputEventKey) -> void: - rebind_no_save(action, event) + _rebind_no_save(action, event) save() func save() -> void: var cfg := ConfigFile.new() for action: StringName in REBINDABLE_ACTIONS: - var event := get_key_event(action) + var event := _get_key_event(action) if event != null: cfg.set_value(SECTION, String(action), event.physical_keycode) + for action: StringName in JOY_REBINDABLE_ACTIONS: + var joy_event := _get_joy_button_event(action) + if joy_event != null: + cfg.set_value(JOY_SECTION, String(action), joy_event.button_index) cfg.save(SAVE_PATH) @@ -386,12 +471,20 @@ func load_saved() -> void: var keycode: int = cfg.get_value(SECTION, key) var event := InputEventKey.new() event.physical_keycode = keycode as Key - rebind_no_save(action, event) + _rebind_no_save(action, event) + for action: StringName in JOY_REBINDABLE_ACTIONS: + var joy_key := String(action) + if not cfg.has_section_key(JOY_SECTION, joy_key): + continue + var button_index: int = cfg.get_value(JOY_SECTION, joy_key) + var joy_event := InputEventJoypadButton.new() + joy_event.button_index = button_index + _rebind_joy_no_save(action, joy_event) ## Rebuilds the action's event list, swapping the first keyboard event for ## `event` in place so ordering (and thus the "primary" key) is preserved. -func rebind_no_save(action: StringName, event: InputEventKey) -> void: +func _rebind_no_save(action: StringName, event: InputEventKey) -> void: var events := InputMap.action_get_events(action) InputMap.action_erase_events(action) var replaced := false diff --git a/ui/main_menu.gd b/ui/main_menu.gd index f932e6e..f27810f 100644 --- a/ui/main_menu.gd +++ b/ui/main_menu.gd @@ -80,6 +80,8 @@ const ACTION_LABELS: Dictionary = { var _listening_action: StringName = &"" var _listening_button: Button = null +var _listening_joy_action: StringName = &"" +var _listening_joy_button: Button = null func _ready() -> void: @@ -207,6 +209,7 @@ func _build_options_toggles() -> void: _add_section_label("── Gameplay ──", idx); idx += 1 _add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, idx); idx += 1 _add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, idx); idx += 1 + _add_toggle_row("Controller Vibration", Settings.vibration, Settings.set_vibration, idx); idx += 1 _add_section_label("── Controls ──", idx) @@ -316,6 +319,16 @@ func _build_rebind_rows() -> void: key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button)) row.add_child(key_button) + # Movement stays on the D-pad/stick (standard, not individually remapped) — only the + # face-button abilities get a gamepad rebind button, alongside the keyboard one. + if Controls.JOY_REBINDABLE_ACTIONS.has(String(action)): + var joy_button := Button.new() + joy_button.custom_minimum_size = Vector2(72, 0) + joy_button.text = Controls.get_joy_button_label(action) + _style_button(joy_button, false) + joy_button.pressed.connect(_on_rebind_joy_pressed.bind(action, joy_button)) + row.add_child(joy_button) + rebind_list.add_child(row) @@ -369,17 +382,26 @@ func _on_rebind_pressed(action: StringName, button: Button) -> void: button.text = "Press a key…" +func _on_rebind_joy_pressed(action: StringName, button: Button) -> void: + _cancel_listening() + _listening_joy_action = action + _listening_joy_button = button + button.text = "Press…" + + func _cancel_listening() -> void: if _listening_button != null: _listening_button.text = Controls.get_key_label(_listening_action) + if _listening_joy_button != null: + _listening_joy_button.text = Controls.get_joy_button_label(_listening_joy_action) _listening_action = &"" _listening_button = null + _listening_joy_action = &"" + _listening_joy_button = null func _unhandled_input(event: InputEvent) -> void: - if _listening_action == &"": - return - if event is InputEventKey and event.pressed and not event.echo: + if _listening_action != &"" and event is InputEventKey and event.pressed and not event.echo: if event.physical_keycode == KEY_ESCAPE: _cancel_listening() else: @@ -390,3 +412,18 @@ func _unhandled_input(event: InputEvent) -> void: _listening_action = &"" _listening_button = null get_viewport().set_input_as_handled() + return + + if _listening_joy_action != &"": + if event is InputEventKey and event.pressed and not event.echo \ + and event.physical_keycode == KEY_ESCAPE: + _cancel_listening() + get_viewport().set_input_as_handled() + elif event is InputEventJoypadButton and event.pressed: + var new_joy_event := InputEventJoypadButton.new() + new_joy_event.button_index = event.button_index + Controls.rebind_joy(_listening_joy_action, new_joy_event) + _listening_joy_button.text = Controls.get_joy_button_label(_listening_joy_action) + _listening_joy_action = &"" + _listening_joy_button = null + get_viewport().set_input_as_handled()