Added gameplay_input manager.
Refactored menus, player, and escape_capsule to utilize new system. Actions defined in Godot project file with default bindings. Player/Vehicles now react to actions and GameplayInput states instead of polling Input directly. They respond to commands via `_handle_gameplay_command` and should not implement _unhandled_input directly. Kept some debugging input handling code in `player.gd` for things like F3/F5/F4/PageUp/PageDown. We may want to move much of this out of `player.gd` into something that handles the gamestate more generally.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
extends Node
|
||||
## Device-independent gameplay input. Bindings remain in Godot's InputMap.
|
||||
|
||||
signal command_requested(action: StringName, command_context: int)
|
||||
signal transient_input_cleared
|
||||
|
||||
## Gameplay input context enumeration.
|
||||
## ON_FOOT: Player is on foot and can perform standard movement actions.
|
||||
## VEHICLE: Player is controlling a vehicle and movement actions are mapped accordingly.
|
||||
enum Context { ON_FOOT, VEHICLE }
|
||||
|
||||
## Current gameplay input context.
|
||||
var context: Context = Context.ON_FOOT
|
||||
## Whether sprint is currently enabled.
|
||||
var sprint_enabled: bool = false
|
||||
## Controller turn speed for look input.
|
||||
var controller_turn_speed: Vector2 = Vector2(2.5, 2.0)
|
||||
## Whether the Y-axis of the controller look input is inverted.
|
||||
var invert_controller_y: bool = false
|
||||
## Deadzone for movement stick input.
|
||||
var movement_deadzone: float = 0.2
|
||||
## Deadzone for look stick input.
|
||||
var look_deadzone: float = 0.2
|
||||
## List of nodes that currently own the menu, blocking gameplay input.
|
||||
var _menu_owners: Array[Node] = []
|
||||
## Accumulated mouse displacement since the last frame.
|
||||
var _mouse_displacement: Vector2 = Vector2.ZERO
|
||||
## Whether a jump has been requested this frame.
|
||||
var _jump_requested: bool = false
|
||||
## Whether the input system is waiting for all controls to return to neutral.
|
||||
var _awaiting_neutral: bool = false
|
||||
## Whether the game window is currently focused.
|
||||
var _window_focused: bool = true
|
||||
## Dictionary tracking currently processed but not yet canceled: i.e. when you press the fire button and it has been handled but you have not yet released the button.
|
||||
var _latched_commands: Dictionary = {}
|
||||
## Outgoing/incoming controls monitored until an ownership handoff reaches neutral.
|
||||
var _neutral_actions: Array[StringName] = []
|
||||
|
||||
## List of all trigger-based actions the player can perform.
|
||||
const TRIGGER_ACTIONS: Array[StringName] = [
|
||||
&"interact", &"combat_reload", &"character_toggle_inventory", &"toggle_view",
|
||||
&"combat_toggle_weapon", &"character_toggle_mag_boots", &"character_sprint", &"character_jump", &"combat_fire",
|
||||
&"vehicle_toggle_gear", &"vehicle_toggle_crew_hatch", &"vehicle_toggle_dock_hatch",
|
||||
&"capsule_toggle_ladder", &"vehicle_activate_instruments", &"vehicle_toggle_cabin_lights",
|
||||
]
|
||||
## Explicit command membership; action prefixes are organizational only.
|
||||
const CONTEXT_TRIGGER_ACTIONS: Dictionary = {
|
||||
Context.ON_FOOT: [
|
||||
&"interact", &"toggle_view", &"character_toggle_inventory", &"character_toggle_mag_boots",
|
||||
&"character_sprint", &"character_jump", &"combat_fire", &"combat_reload", &"combat_toggle_weapon",
|
||||
],
|
||||
Context.VEHICLE: [
|
||||
&"interact", &"toggle_view", &"vehicle_toggle_gear", &"vehicle_toggle_crew_hatch",
|
||||
&"vehicle_toggle_dock_hatch", &"capsule_toggle_ladder", &"vehicle_activate_instruments",
|
||||
&"vehicle_toggle_cabin_lights",
|
||||
],
|
||||
}
|
||||
## Commands requiring the controller auxiliary modifier, independent of their names.
|
||||
const VEHICLE_AUXILIARY_ACTIONS: Array[StringName] = [
|
||||
&"vehicle_toggle_gear", &"vehicle_toggle_crew_hatch", &"vehicle_toggle_dock_hatch",
|
||||
&"capsule_toggle_ladder", &"vehicle_activate_instruments", &"vehicle_toggle_cabin_lights",
|
||||
]
|
||||
## List of all axis-based actions the player can perform.
|
||||
const AXIS_ACTIONS: Array[StringName] = [
|
||||
&"character_move_left", &"character_move_right", &"character_move_forward", &"character_move_back",
|
||||
&"look_left", &"look_right", &"look_up", &"look_down",
|
||||
&"eva_roll_left", &"eva_roll_right", &"vehicle_pitch_up", &"vehicle_pitch_down",
|
||||
&"vehicle_yaw_left", &"vehicle_yaw_right", &"vehicle_roll_left", &"vehicle_roll_right",
|
||||
]
|
||||
## Look is shared; character/EVA and vehicle axes belong to distinct contexts.
|
||||
const SHARED_AXIS_ACTIONS: Array[StringName] = [
|
||||
&"look_left", &"look_right", &"look_up", &"look_down",
|
||||
]
|
||||
const CONTEXT_AXIS_ACTIONS: Dictionary = {
|
||||
Context.ON_FOOT: [
|
||||
&"character_move_left", &"character_move_right", &"character_move_forward", &"character_move_back",
|
||||
&"eva_roll_left", &"eva_roll_right",
|
||||
],
|
||||
Context.VEHICLE: [
|
||||
&"vehicle_pitch_up", &"vehicle_pitch_down", &"vehicle_yaw_left", &"vehicle_yaw_right",
|
||||
&"vehicle_roll_left", &"vehicle_roll_right",
|
||||
],
|
||||
}
|
||||
## Held controls are polled, not dispatched through command_requested.
|
||||
const CONTEXT_HELD_ACTIONS: Dictionary = {
|
||||
Context.ON_FOOT: [&"combat_aim", &"freelook", &"release_cursor"],
|
||||
Context.VEHICLE: [&"vehicle_thrust", &"vehicle_freelook", &"vehicle_auxiliary", &"vehicle_release_cursor"],
|
||||
}
|
||||
|
||||
## Connects device/focus boundaries; project defaults are never reapplied on spawn.
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
Input.joy_connection_changed.connect(_on_controller_connection_changed)
|
||||
get_tree().root.focus_entered.connect(_on_focus_entered)
|
||||
get_tree().root.focus_exited.connect(_on_focus_exited)
|
||||
|
||||
|
||||
## Updates neutral gating and ends sprint when the movement stick is released.
|
||||
func _process(_delta: float) -> void:
|
||||
for owner_index in range(_menu_owners.size() - 1, -1, -1):
|
||||
if not is_instance_valid(_menu_owners[owner_index]):
|
||||
_menu_owners.remove_at(owner_index)
|
||||
if _awaiting_neutral and _controls_are_neutral():
|
||||
_awaiting_neutral = false
|
||||
_neutral_actions.clear()
|
||||
if not gameplay_enabled() or read_movement().length_squared() < 0.0001:
|
||||
sprint_enabled = false
|
||||
|
||||
|
||||
## UI receives events first. Only the current gameplay context gets commands.
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
for action in _latched_commands.keys():
|
||||
if event.is_action_released(action):
|
||||
_latched_commands.erase(action)
|
||||
if not gameplay_enabled():
|
||||
return
|
||||
if event is InputEventMouseMotion:
|
||||
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
record_mouse_displacement(event.relative)
|
||||
return
|
||||
for action in CONTEXT_TRIGGER_ACTIONS[context]:
|
||||
if event.is_action_pressed(action) and _command_is_available(action, event):
|
||||
if _latched_commands.has(action):
|
||||
return
|
||||
_latched_commands[action] = true
|
||||
get_viewport().set_input_as_handled()
|
||||
if action == &"character_sprint":
|
||||
sprint_enabled = not sprint_enabled
|
||||
elif action == &"character_jump":
|
||||
_jump_requested = true
|
||||
else:
|
||||
command_requested.emit(action, context)
|
||||
return
|
||||
|
||||
|
||||
## Changes control ownership and discards commands from the previous owner.
|
||||
func set_context(next_context: Context) -> void:
|
||||
_monitor_context_for_neutral(context)
|
||||
context = next_context
|
||||
clear_transient_input()
|
||||
|
||||
|
||||
## Blocks gameplay while an owning menu is open; nested menus are supported.
|
||||
func set_menu_open(owner_node: Node, is_open: bool) -> void:
|
||||
if is_open and not _menu_owners.has(owner_node):
|
||||
_menu_owners.append(owner_node)
|
||||
elif not is_open:
|
||||
_menu_owners.erase(owner_node)
|
||||
clear_transient_input()
|
||||
|
||||
|
||||
## Clears held/queued state and requires neutral controls before gameplay resumes.
|
||||
func clear_transient_input() -> void:
|
||||
_monitor_context_for_neutral(context)
|
||||
_mouse_displacement = Vector2.ZERO
|
||||
_jump_requested = false
|
||||
_latched_commands.clear()
|
||||
sprint_enabled = false
|
||||
_awaiting_neutral = true
|
||||
transient_input_cleared.emit()
|
||||
|
||||
|
||||
## Polling must use this gate: UI event consumption does not block Input state.
|
||||
## Returns true if the window is focused, the game is not paused, no menus are open, and we are not awaiting neutral input.
|
||||
func gameplay_enabled() -> bool:
|
||||
return _window_focused and not get_tree().paused and _menu_owners.is_empty() and not _awaiting_neutral
|
||||
|
||||
|
||||
## Checks explicit membership without relying on action naming prefixes.
|
||||
func is_action_available(action: StringName) -> bool:
|
||||
return (
|
||||
CONTEXT_TRIGGER_ACTIONS[context].has(action)
|
||||
or SHARED_AXIS_ACTIONS.has(action)
|
||||
or CONTEXT_AXIS_ACTIONS[context].has(action)
|
||||
or CONTEXT_HELD_ACTIONS[context].has(action)
|
||||
)
|
||||
|
||||
|
||||
## Returns body-relative movement, with forward on negative Y and magnitude <= 1.
|
||||
func read_movement() -> Vector2:
|
||||
if not gameplay_enabled() or context != Context.ON_FOOT:
|
||||
return Vector2.ZERO
|
||||
return Input.get_vector("character_move_left", "character_move_right", "character_move_forward", "character_move_back", movement_deadzone)
|
||||
|
||||
|
||||
## Returns signed analog strength without exposing device axes to gameplay.
|
||||
func read_axis(negative_action: StringName, positive_action: StringName) -> float:
|
||||
if not gameplay_enabled() or not is_action_available(negative_action) or not is_action_available(positive_action):
|
||||
return 0.0
|
||||
return Input.get_axis(negative_action, positive_action)
|
||||
|
||||
|
||||
## Returns an unsigned analog command strength in the range 0..1.
|
||||
func read_strength(action: StringName) -> float:
|
||||
return Input.get_action_strength(action) if gameplay_enabled() and is_action_available(action) else 0.0
|
||||
|
||||
|
||||
## Returns a held action; future automatic weapons can use this independently of presses.
|
||||
func is_held(action: StringName) -> bool:
|
||||
return gameplay_enabled() and is_action_available(action) and Input.is_action_pressed(action)
|
||||
|
||||
|
||||
## Consumes one UI-aware jump request during the next physics update.
|
||||
func consume_jump_request() -> bool:
|
||||
var requested: bool = _jump_requested and gameplay_enabled()
|
||||
_jump_requested = false
|
||||
return requested
|
||||
|
||||
|
||||
## Accumulates mouse pixels independently of the render/physics update rates.
|
||||
func record_mouse_displacement(displacement: Vector2) -> void:
|
||||
if gameplay_enabled():
|
||||
_mouse_displacement += displacement
|
||||
|
||||
|
||||
## Consumes mouse pixels, once per camera update, without time scaling.
|
||||
func consume_mouse_displacement() -> Vector2:
|
||||
var displacement := _mouse_displacement if gameplay_enabled() else Vector2.ZERO
|
||||
_mouse_displacement = Vector2.ZERO
|
||||
return displacement
|
||||
|
||||
|
||||
## Returns stick angular displacement in radians for this frame; positive Y looks down.
|
||||
func read_controller_look(delta: float) -> Vector2:
|
||||
if not gameplay_enabled():
|
||||
return Vector2.ZERO
|
||||
var stick := Input.get_vector("look_left", "look_right", "look_up", "look_down", look_deadzone)
|
||||
if invert_controller_y:
|
||||
stick.y = -stick.y
|
||||
return stick * controller_turn_speed * delta
|
||||
|
||||
|
||||
## Returns a current-device binding label, with a fallback for unmapped actions.
|
||||
func binding_label(action: StringName) -> String:
|
||||
var helper := get_node_or_null("/root/InputHelper")
|
||||
if helper == null:
|
||||
return "Unbound"
|
||||
var binding: InputEvent = helper.get_keyboard_or_joypad_input_for_action(action)
|
||||
return helper.get_label_for_input(binding) if binding != null else "Unbound"
|
||||
|
||||
|
||||
## Restricts contextual commands and routes modifier-layer controller buttons.
|
||||
func _command_is_available(action: StringName, event: InputEvent) -> bool:
|
||||
if not CONTEXT_TRIGGER_ACTIONS[context].has(action):
|
||||
return false
|
||||
if context == Context.VEHICLE and event is InputEventJoypadButton:
|
||||
var auxiliary_layer := Input.is_action_pressed("vehicle_auxiliary")
|
||||
return auxiliary_layer if VEHICLE_AUXILIARY_ACTIONS.has(action) else not auxiliary_layer
|
||||
return true
|
||||
|
||||
|
||||
## Adds only relevant controls to a handoff; unrelated future axes cannot block it.
|
||||
func _monitor_context_for_neutral(monitored_context: Context) -> void:
|
||||
var actions: Array = CONTEXT_TRIGGER_ACTIONS[monitored_context] + SHARED_AXIS_ACTIONS
|
||||
actions.append_array(CONTEXT_AXIS_ACTIONS[monitored_context])
|
||||
actions.append_array(CONTEXT_HELD_ACTIONS[monitored_context])
|
||||
for action in actions:
|
||||
if not _neutral_actions.has(action):
|
||||
_neutral_actions.append(action)
|
||||
|
||||
|
||||
## Waits for outgoing/incoming controls, including latched command releases.
|
||||
func _controls_are_neutral() -> bool:
|
||||
for action in _neutral_actions:
|
||||
if not AXIS_ACTIONS.has(action) and Input.is_action_pressed(action):
|
||||
return false
|
||||
if Input.get_action_strength(action) > 0.2:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## Disconnects clear held commands; reconnects require neutral input too.
|
||||
func _on_controller_connection_changed(_device: int, _connected: bool) -> void:
|
||||
clear_transient_input()
|
||||
|
||||
|
||||
## Window focus loss must not leave firing, look, or sprint latched.
|
||||
func _on_focus_exited() -> void:
|
||||
_window_focused = false
|
||||
clear_transient_input()
|
||||
|
||||
|
||||
## Focus restoration requires a fresh press rather than replaying held controls.
|
||||
func _on_focus_entered() -> void:
|
||||
_window_focused = true
|
||||
clear_transient_input()
|
||||
|
||||
Reference in New Issue
Block a user