@tool ## Helper node for detecting inputs. Detects the next input matching a specification and ## emits a signal with the detected input. class_name GUIDEInputDetector extends Node ## The device type for which the input should be filtered. enum DeviceType { ## Only detect input from keyboard. KEYBOARD = 1, ## Only detect input from the mouse. MOUSE = 2, ## Only detect input from joysticks/gamepads. JOY = 4 # touch doesn't make a lot of sense as this is usually # not remappable. } ## Which joy index should be used for detected joy events enum JoyIndex { # Use -1, so the detected input will match any joystick ANY = 0, # Use the actual index of the detected joystick. DETECTED = 1 } enum DetectionState { # The detector is currently idle. IDLE = 0, # The detector is currently counting down before starting the detection. COUNTDOWN = 3, # The detector is currently detecting input. DETECTING = 1, # The detector has finished detecting but is waiting for input to be released. WAITING_FOR_INPUT_CLEAR = 2, } ## A countdown between initiating a dection and the actual start of the ## detection. This is useful because when the user clicks a button to ## start a detection, we want to make sure that the player is actually ## ready (and not accidentally moves anything). If set to 0, no countdown ## will be started. @export_range(0, 2, 0.1, "or_greater") var detection_countdown_seconds:float = 0.5 ## Minimum amplitude to detect any axis. @export_range(0, 1, 0.1, "or_greater") var minimum_axis_amplitude:float = 0.2 ## If any of these inputs is encountered, the detector will ## treat this as "abort detection". @export var abort_detection_on:Array[GUIDEInput] = [] ## Which joy index should be returned for detected joy events. @export var use_joy_index:JoyIndex = JoyIndex.ANY ## Whether trigger buttons on controllers should be detected when ## then action value type is limited to boolean. @export var allow_triggers_for_boolean_actions:bool = true ## Emitted when the detection has started (e.g. countdown has elapsed). ## Can be used to signal this to the player. signal detection_started() ## Emitted when the input detector detects an input of the given type. ## If detection was aborted the given input is null. signal input_detected(input:GUIDEInput) # The timer for the detection countdown. var _timer:Timer # Our copy of the input state var _input_state:GUIDEInputState # The current state of the detection. var _status:DetectionState = DetectionState.IDLE # Mapping contexts that were active when the detection started. We need to restore these once the detection is # finished or aborted. var _saved_mapping_contexts:Array[GUIDEMappingContext] = [] # The last detected input. var _last_detected_input:GUIDEInput = null func _ready(): # don't run the process function if we are not detecting to not waste resources set_process(false) _timer = Timer.new() _input_state = GUIDEInputState.new() _timer.one_shot = true add_child(_timer, false, Node.INTERNAL_MODE_FRONT) _timer.timeout.connect(_begin_detection) ## Whether the input detector is currently detecting input. var is_detecting:bool: get: return _status != DetectionState.IDLE var _value_type:GUIDEAction.GUIDEActionValueType var _device_types:Array[DeviceType] = [] ## Detects a boolean input type. func detect_bool(device_types:Array[DeviceType] = []) -> void: detect(GUIDEAction.GUIDEActionValueType.BOOL, device_types) ## Detects a 1D axis input type. func detect_axis_1d(device_types:Array[DeviceType] = []) -> void: detect(GUIDEAction.GUIDEActionValueType.AXIS_1D, device_types) ## Detects a 2D axis input type. func detect_axis_2d(device_types:Array[DeviceType] = []) -> void: detect(GUIDEAction.GUIDEActionValueType.AXIS_2D, device_types) ## Detects a 3D axis input type. func detect_axis_3d(device_types:Array[DeviceType] = []) -> void: detect(GUIDEAction.GUIDEActionValueType.AXIS_3D, device_types) ## Detects the given input type. If device types are given ## will only detect inputs from the given device types. ## Otherwise will detect inputs from all supported device types. func detect(value_type:GUIDEAction.GUIDEActionValueType, device_types:Array[DeviceType] = []) -> void: if device_types == null: push_error("Device types must not be null. Supply an empty array if you want to detect input from all devices.") return # If we are already detecting, abort this. if _status == DetectionState.DETECTING or _status == DetectionState.WAITING_FOR_INPUT_CLEAR: for input in abort_detection_on: input._end_usage() # and start a new detection. _status = DetectionState.COUNTDOWN _value_type = value_type _device_types = device_types _timer.stop() _timer.start(detection_countdown_seconds) ## This is called by the timer when the countdown has elapsed. func _begin_detection(): # set status to detecting _status = DetectionState.DETECTING # reset and clear the input state _input_state._clear() _input_state._reset() # enable all abort detection inputs for input in abort_detection_on: input._state = _input_state input._begin_usage() # we also use this inside the editor where the GUIDE # singleton is not active. Here we don't need to enable # and disable the mapping contexts. if not Engine.is_editor_hint(): # save currently active mapping contexts _saved_mapping_contexts = GUIDE.get_enabled_mapping_contexts() # disable all mapping contexts for context in _saved_mapping_contexts: GUIDE.disable_mapping_context(context) detection_started.emit() ## Aborts a running detection. If no detection currently runs ## does nothing. func abort_detection() -> void: _timer.stop() # if we are currently detecting, deliver the null result # which will gracefully shut down everything if _status == DetectionState.DETECTING: _deliver(null) # in any other state we don't need to do anything ## This is called while we are waiting for input to be released. func _process(delta: float) -> void: # if we are not detecting, we don't need to do anything if _status != DetectionState.WAITING_FOR_INPUT_CLEAR: set_process(false) return # check if the input is still actuated. We do this to avoid the problem # of this input accidentally triggering something in the mapping contexts # when we enable them again. for input in abort_detection_on: if input._value.is_finite() and input._value.length() > 0: # we still have input, so we are still waiting # retry next frame return # if we are here, the input is no longer actuated # tear down the inputs for input in abort_detection_on: input._end_usage() # restore the mapping contexts # but only when not running in the editor if not Engine.is_editor_hint(): for context in _saved_mapping_contexts: GUIDE.enable_mapping_context(context) # set status to idle _status = DetectionState.IDLE # and deliver the detected input input_detected.emit(_last_detected_input) ## This is called in any state when input is received. func _input(event:InputEvent) -> void: if _status == DetectionState.IDLE: return # feed the event into the state _input_state._input(event) # while detecting, we're the only ones consuming input and we eat this input # to not accidentally trigger built-in Godot mappings (e.g. UI stuff) get_viewport().set_input_as_handled() # but we still feed it into GUIDE's global state so this state stays # up to date. This should have no effect because we disabled all mapping # contexts. if not Engine.is_editor_hint(): GUIDE.inject_input(event) if _status == DetectionState.DETECTING: # check if any abort input will trigger for input in abort_detection_on: # if it triggers, we abort if input._value.is_finite() and input._value.length() > 0: abort_detection() return # check if the event matches the device type we are # looking for if not _matches_device_types(event): return # then check if it can be mapped to the desired # value type match _value_type: GUIDEAction.GUIDEActionValueType.BOOL: _try_detect_bool(event) GUIDEAction.GUIDEActionValueType.AXIS_1D: _try_detect_axis_1d(event) GUIDEAction.GUIDEActionValueType.AXIS_2D: _try_detect_axis_2d(event) GUIDEAction.GUIDEActionValueType.AXIS_3D: _try_detect_axis_3d(event) func _matches_device_types(event:InputEvent) -> bool: if _device_types.is_empty(): return true if event is InputEventKey: return _device_types.has(DeviceType.KEYBOARD) if event is InputEventMouse: return _device_types.has(DeviceType.MOUSE) if event is InputEventJoypadButton or event is InputEventJoypadMotion: return _device_types.has(DeviceType.JOY) return false func _try_detect_bool(event:InputEvent) -> void: if event is InputEventKey and event.is_released(): var result := GUIDEInputKey.new() result.key = event.physical_keycode result.shift = event.shift_pressed result.control = event.ctrl_pressed result.meta = event.meta_pressed result.alt = event.alt_pressed _deliver(result) return if event is InputEventMouseButton and event.is_released(): var result := GUIDEInputMouseButton.new() result.button = event.button_index _deliver(result) return if event is InputEventJoypadButton and event.is_released(): var result := GUIDEInputJoyButton.new() result.button = event.button_index result.joy_index = _find_joy_index(event.device) _deliver(result) if allow_triggers_for_boolean_actions: # only allow joypad trigger buttons if not (event is InputEventJoypadMotion): return if event.axis != JOY_AXIS_TRIGGER_LEFT and \ event.axis != JOY_AXIS_TRIGGER_RIGHT: return var result := GUIDEInputJoyAxis1D.new() result.axis = event.axis result.joy_index = _find_joy_index(event.device) _deliver(result) func _try_detect_axis_1d(event:InputEvent) -> void: if event is InputEventMouseMotion: var result := GUIDEInputMouseAxis1D.new() # Pick the direction in which the mouse was moved more. if abs(event.relative.x) > abs(event.relative.y): result.axis = GUIDEInputMouseAxis1D.GUIDEInputMouseAxis.X else: result.axis = GUIDEInputMouseAxis1D.GUIDEInputMouseAxis.Y _deliver(result) return if event is InputEventJoypadMotion: if abs(event.axis_value) < minimum_axis_amplitude: return var result := GUIDEInputJoyAxis1D.new() result.axis = event.axis result.joy_index = _find_joy_index(event.device) _deliver(result) func _try_detect_axis_2d(event:InputEvent) -> void: if event is InputEventMouseMotion: var result := GUIDEInputMouseAxis2D.new() _deliver(result) return if event is InputEventJoypadMotion: if event.axis_value < minimum_axis_amplitude: return var result := GUIDEInputJoyAxis2D.new() match event.axis: JOY_AXIS_LEFT_X, JOY_AXIS_LEFT_Y: result.x = JOY_AXIS_LEFT_X result.y = JOY_AXIS_LEFT_Y JOY_AXIS_RIGHT_X, JOY_AXIS_RIGHT_Y: result.x = JOY_AXIS_RIGHT_X result.y = JOY_AXIS_RIGHT_Y _: # not supported for detection return result.joy_index = _find_joy_index(event.device) _deliver(result) return func _try_detect_axis_3d(event:InputEvent) -> void: # currently no input for 3D pass func _find_joy_index(device_id:int) -> int: if use_joy_index == JoyIndex.ANY: return -1 var pads := Input.get_connected_joypads() for i in pads.size(): if pads[i] == device_id: return i return -1 func _deliver(input:GUIDEInput) -> void: _last_detected_input = input _status = DetectionState.WAITING_FOR_INPUT_CLEAR # enable processing so we can check if the input is released before we re-enable GUIDE's mapping contexts set_process(true)