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()