extends CanvasLayer @export var jet_path: NodePath @export var config_path: String = "user://hotas_mapping.cfg" @export var axis_capture_threshold: float = 0.35 @export var toggle_action: String = "toggle_hotas_mapper" @export var panel_position: Vector2 = Vector2(12, 12) @export var panel_size: Vector2 = Vector2(520, 360) const AXIS_CONFIG = [ { "name": "Roll", "axis_prop": "roll_axis", "invert_prop": "roll_invert", "device_prop": "roll_device_id", }, { "name": "Pitch", "axis_prop": "pitch_axis", "invert_prop": "pitch_invert", "device_prop": "pitch_device_id", }, { "name": "Yaw", "axis_prop": "yaw_axis", "invert_prop": "yaw_invert", "device_prop": "yaw_device_id", }, { "name": "Throttle", "axis_prop": "throttle_axis", "invert_prop": "throttle_invert", "signed_prop": "throttle_signed", "device_prop": "throttle_device_id", }, { "name": "Strafe", "axis_prop": "strafe_axis", "invert_prop": "strafe_invert", "device_prop": "strafe_device_id", }, { "name": "Lift", "axis_prop": "lift_axis", "invert_prop": "lift_invert", "device_prop": "lift_device_id", }, ] var _jet: Node var _panel: PanelContainer var _status_label: Label var _device_label: Label var _rows := {} var _listening_axis: String = "" func _ready() -> void: _jet = get_node_or_null(jet_path) _build_ui() _load_config() _refresh_labels() func _unhandled_input(event: InputEvent) -> void: if _is_toggle_event(event): visible = not visible if not visible: _listening_axis = "" _refresh_labels() get_viewport().set_input_as_handled() func _input(event: InputEvent) -> void: if not visible: return if _listening_axis.is_empty(): return if event is InputEventJoypadMotion: if abs(event.axis_value) < axis_capture_threshold: return _apply_axis_mapping(_listening_axis, event.axis, event.device) _listening_axis = "" _status_label.text = "Axis captured." _refresh_labels() func _build_ui() -> void: _panel = PanelContainer.new() _panel.name = "HotasMapper" _panel.anchor_left = 0.0 _panel.anchor_top = 0.0 _panel.anchor_right = 0.0 _panel.anchor_bottom = 0.0 _panel.offset_left = panel_position.x _panel.offset_top = panel_position.y _panel.offset_right = panel_position.x + panel_size.x _panel.offset_bottom = panel_position.y + panel_size.y add_child(_panel) var root := VBoxContainer.new() root.size_flags_vertical = Control.SIZE_EXPAND_FILL root.size_flags_horizontal = Control.SIZE_EXPAND_FILL root.add_theme_constant_override("separation", 8) _panel.add_child(root) var header := HBoxContainer.new() header.size_flags_horizontal = Control.SIZE_EXPAND_FILL root.add_child(header) var title := Label.new() title.text = "HOTAS Axis Mapper" header.add_child(title) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(spacer) var close_button := Button.new() close_button.text = "Close" close_button.pressed.connect(_on_close_pressed) header.add_child(close_button) _device_label = Label.new() _device_label.text = _get_device_text() root.add_child(_device_label) _status_label = Label.new() _status_label.text = "Click Map, then move the desired axis. Toggle: %s" % _get_toggle_hint() root.add_child(_status_label) for config in AXIS_CONFIG: root.add_child(_build_axis_row(config)) func _build_axis_row(config: Dictionary) -> Control: var row := HBoxContainer.new() row.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_theme_constant_override("separation", 8) var name_label := Label.new() name_label.text = config.name name_label.custom_minimum_size = Vector2(120, 0) row.add_child(name_label) var map_button := Button.new() map_button.text = "Map" map_button.pressed.connect(_on_map_pressed.bind(config.axis_prop)) row.add_child(map_button) var axis_label := Label.new() axis_label.text = "Axis: ?" axis_label.custom_minimum_size = Vector2(120, 0) row.add_child(axis_label) var device_label := Label.new() device_label.text = "Dev: ?" device_label.custom_minimum_size = Vector2(160, 0) row.add_child(device_label) var invert_check := CheckBox.new() invert_check.text = "Invert" invert_check.toggled.connect(_on_invert_toggled.bind(config.invert_prop)) row.add_child(invert_check) var signed_check: CheckBox = null if config.has("signed_prop"): signed_check = CheckBox.new() signed_check.text = "Signed" signed_check.toggled.connect(_on_signed_toggled.bind(config.signed_prop)) row.add_child(signed_check) _rows[config.axis_prop] = { "axis_label": axis_label, "device_label": device_label, "map_button": map_button, "invert_check": invert_check, "signed_check": signed_check, } return row func _on_map_pressed(axis_prop: String) -> void: if _listening_axis == axis_prop: _listening_axis = "" _status_label.text = "Mapping cancelled." else: _listening_axis = axis_prop _status_label.text = "Listening for %s axis..." % axis_prop _refresh_labels() func _on_close_pressed() -> void: _listening_axis = "" visible = false func _on_invert_toggled(pressed: bool, invert_prop: String) -> void: if _jet == null: return _jet.set(invert_prop, pressed) _save_config() func _on_signed_toggled(pressed: bool, signed_prop: String) -> void: if _jet == null: return _jet.set(signed_prop, pressed) _save_config() func _apply_axis_mapping(axis_prop: String, axis_index: int, device_id: int) -> void: if _jet == null: return _jet.set(axis_prop, axis_index) if device_id >= 0: var device_prop = _get_device_prop(axis_prop) if not device_prop.is_empty(): _jet.set(device_prop, device_id) _jet.set("joy_device_id", device_id) _save_config() _device_label.text = _get_device_text() func _refresh_labels() -> void: if _jet == null: return _device_label.text = _get_device_text() for config in AXIS_CONFIG: var axis_prop: String = config.axis_prop var row = _rows.get(axis_prop, null) if row == null: continue row["axis_label"].text = "Axis: %d" % int(_jet.get(axis_prop)) var device_prop: String = config.device_prop var device_id: int = int(_jet.get(device_prop)) row["device_label"].text = _get_device_label(device_id) var invert_prop: String = config.invert_prop row["invert_check"].set_pressed_no_signal(bool(_jet.get(invert_prop))) if config.has("signed_prop") and row["signed_check"] != null: row["signed_check"].set_pressed_no_signal(bool(_jet.get(config.signed_prop))) var map_button: Button = row["map_button"] if _listening_axis == axis_prop: map_button.text = "Listening..." else: map_button.text = "Map" func _load_config() -> void: if _jet == null: return var config := ConfigFile.new() var err = config.load(config_path) if err != OK: return for entry in AXIS_CONFIG: var axis_prop: String = entry.axis_prop if config.has_section_key("axes", axis_prop): _jet.set(axis_prop, int(config.get_value("axes", axis_prop))) var invert_prop: String = entry.invert_prop if config.has_section_key("invert", invert_prop): _jet.set(invert_prop, bool(config.get_value("invert", invert_prop))) var device_prop: String = entry.device_prop if config.has_section_key("devices", device_prop): _jet.set(device_prop, int(config.get_value("devices", device_prop))) if entry.has("signed_prop"): var signed_prop: String = entry.signed_prop if config.has_section_key("flags", signed_prop): _jet.set(signed_prop, bool(config.get_value("flags", signed_prop))) if config.has_section_key("device", "joy_device_id"): _jet.set("joy_device_id", int(config.get_value("device", "joy_device_id"))) func _save_config() -> void: if _jet == null: return var config := ConfigFile.new() for entry in AXIS_CONFIG: var axis_prop: String = entry.axis_prop config.set_value("axes", axis_prop, int(_jet.get(axis_prop))) var invert_prop: String = entry.invert_prop config.set_value("invert", invert_prop, bool(_jet.get(invert_prop))) var device_prop: String = entry.device_prop config.set_value("devices", device_prop, int(_jet.get(device_prop))) if entry.has("signed_prop"): var signed_prop: String = entry.signed_prop config.set_value("flags", signed_prop, bool(_jet.get(signed_prop))) config.set_value("device", "joy_device_id", int(_jet.get("joy_device_id"))) config.save(config_path) func _get_device_text() -> String: if _jet == null: return "Device: none" var device_id: int = int(_jet.get("joy_device_id")) if device_id < 0: var pads = Input.get_connected_joypads() if pads.is_empty(): return "Device: none" device_id = pads[0] var name = Input.get_joy_name(device_id) return "Device: %s (id %d)" % [name, device_id] func _get_device_label(device_id: int) -> String: if device_id < 0: return "Dev: auto" var name = Input.get_joy_name(device_id) if name.is_empty(): return "Dev: %d" % device_id return "Dev: %s (%d)" % [name, device_id] func _get_device_prop(axis_prop: String) -> String: for entry in AXIS_CONFIG: if entry.axis_prop == axis_prop: return entry.device_prop return "" func _is_toggle_event(event: InputEvent) -> bool: if InputMap.has_action(toggle_action): return event.is_action_pressed(toggle_action) if event is InputEventKey and event.pressed and not event.echo: return event.keycode == KEY_F1 return false func _get_toggle_hint() -> String: if InputMap.has_action(toggle_action): return toggle_action return "F1"