diff --git a/Courses/Range/range.gd b/Courses/Range/range.gd index ac24694..168177c 100644 --- a/Courses/Range/range.gd +++ b/Courses/Range/range.gd @@ -23,8 +23,8 @@ var last_display: Dictionary = {} # Called when the node enters the scene tree for the first time. func _ready() -> void: - GlobalSettings.range_settings.camera_follow_mode.setting_changed.connect(set_camera_follow_mode) - set_camera_follow_mode(GlobalSettings.range_settings.camera_follow_mode.value) + GlobalSettingsManager.range_settings.camera_follow_mode.setting_changed.connect(set_camera_follow_mode) + set_camera_follow_mode(GlobalSettingsManager.range_settings.camera_follow_mode.value) if ("/root/LaunchMonitorManager"): var launch_monitor = get_node("/root/LaunchMonitorManager") if not launch_monitor.hit_ball.is_connected(_on_launch_monitor_hit_ball): @@ -42,7 +42,7 @@ func _on_tcp_client_hit_ball(data: Dictionary) -> void: _update_ball_display() # Re-enable camera follow if the setting is on - if GlobalSettings.range_settings.camera_follow_mode.value: + if GlobalSettingsManager.range_settings.camera_follow_mode.value: set_camera_follow_mode(true) @@ -64,13 +64,13 @@ func _on_golf_ball_rest(_ball_data) -> void: _update_ball_display() # Return camera to starting position if follow mode is enabled - if GlobalSettings.range_settings.camera_follow_mode.value: - var camera_reset_delay: float = GlobalSettings.range_settings.ball_reset_timer.value + if GlobalSettingsManager.range_settings.camera_follow_mode.value: + var camera_reset_delay: float = GlobalSettingsManager.range_settings.ball_reset_timer.value await get_tree().create_timer(camera_reset_delay).timeout reset_camera_to_start() - if GlobalSettings.range_settings.auto_ball_reset.value: - await get_tree().create_timer(GlobalSettings.range_settings.ball_reset_timer.value).timeout + if GlobalSettingsManager.range_settings.auto_ball_reset.value: + await get_tree().create_timer(GlobalSettingsManager.range_settings.ball_reset_timer.value).timeout _reset_display_data() $RangeUI.set_data(display_data) var player = $Player @@ -116,13 +116,13 @@ func _on_range_ui_hit_shot(data: Dictionary) -> void: _update_ball_display() # Re-enable camera follow if the setting is on - if GlobalSettings.range_settings.camera_follow_mode.value: + if GlobalSettingsManager.range_settings.camera_follow_mode.value: set_camera_follow_mode(true) func _on_player_manual_hit() -> void: # Re-enable camera follow if the setting is on - if GlobalSettings.range_settings.camera_follow_mode.value: + if GlobalSettingsManager.range_settings.camera_follow_mode.value: set_camera_follow_mode(true) @@ -147,7 +147,7 @@ func _update_ball_display() -> void: # Show distance continuously (updates during flight/rollout, final at rest) var player = $Player var show_distance: bool = true - display_data = ShotFormatter.format_ball_display(raw_ball_data, player, GlobalSettings.range_settings.range_units.value, show_distance, display_data) + display_data = ShotFormatter.format_ball_display(raw_ball_data, player, GlobalSettingsManager.range_settings.range_units.value, show_distance, display_data) last_display = display_data.duplicate() $RangeUI.set_data(display_data) diff --git a/Courses/Range/range.tscn b/Courses/Range/range.tscn index 9c90caa..6f7e0b1 100644 --- a/Courses/Range/range.tscn +++ b/Courses/Range/range.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://b1idtxfp7eoke"] [ext_resource type="Script" uid="uid://c4whkfuiysonu" path="res://Courses/Range/range.gd" id="1_sunpe"] -[ext_resource type="Script" uid="uid://bg5tbfjpq3flx" path="res://addons/launch_monitors/common/tcp_server/TcpServer.cs" id="2_d1a0v"] +[ext_resource type="Script" uid="uid://bg5tbfjpq3flx" path="res://addons/open-lm-connector/common/tcp_server/TcpServer.cs" id="2_d1a0v"] [ext_resource type="PackedScene" uid="uid://w55fmvo7ly8g" path="res://Player/player.tscn" id="3_4jr3s"] [ext_resource type="PackedScene" uid="uid://da1vsfwmbk8vw" path="res://UI/range_ui.tscn" id="4_d1a0v"] [ext_resource type="Texture2D" uid="uid://btxo07ji1qkds" path="res://Courses/Environments/grassy-meadow1-bl/grassy-meadow1_alb_ht.png" id="5_6rpr1"] diff --git a/Courses/UserCourses/Airways/course.tscn b/Courses/UserCourses/Airways/course.tscn index b3a638d..8d34efb 100644 --- a/Courses/UserCourses/Airways/course.tscn +++ b/Courses/UserCourses/Airways/course.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://ecp4h3om36dt"] [ext_resource type="Script" uid="uid://c4whkfuiysonu" path="res://Courses/UserCourses/Airways/course.gd" id="1_sunpe"] -[ext_resource type="Script" uid="uid://bg5tbfjpq3flx" path="res://addons/launch_monitors/common/tcp_server/TcpServer.cs" id="2_d1a0v"] +[ext_resource type="Script" uid="uid://bg5tbfjpq3flx" path="res://addons/open-lm-connector/common/tcp_server/TcpServer.cs" id="2_d1a0v"] [ext_resource type="PackedScene" uid="uid://w55fmvo7ly8g" path="res://Player/player.tscn" id="3_4jr3s"] [ext_resource type="PackedScene" uid="uid://da1vsfwmbk8vw" path="res://UI/range_ui.tscn" id="4_d1a0v"] [ext_resource type="Texture2D" uid="uid://btxo07ji1qkds" path="res://Courses/Environments/grassy-meadow1-bl/grassy-meadow1_alb_ht.png" id="5_6rpr1"] diff --git a/OpenShotGolf.csproj b/OpenShotGolf.csproj index 4efb116..934ce6b 100644 --- a/OpenShotGolf.csproj +++ b/OpenShotGolf.csproj @@ -1,4 +1,4 @@ - + net9.0 net9.0-windows10.0.19041.0 @@ -13,6 +13,6 @@ - + diff --git a/OpenShotGolf.csproj.old.1 b/OpenShotGolf.csproj.old.1 new file mode 100644 index 0000000..4efb116 --- /dev/null +++ b/OpenShotGolf.csproj.old.1 @@ -0,0 +1,18 @@ + + + net9.0 + net9.0-windows10.0.19041.0 + 10.0.19041.0 + true + true + enable + + + + + + + + + + diff --git a/Player/ball.gd b/Player/ball.gd index 5df903a..c55986d 100644 --- a/Player/ball.gd +++ b/Player/ball.gd @@ -173,7 +173,7 @@ func _try_initialize_ball() -> bool: func initialize_ball() -> void: _connect_settings() _update_environment() - set_surface(int(GlobalSettings.range_settings.surface_type.value)) + set_surface(int(GlobalSettingsManager.range_settings.surface_type.value)) _create_collision_and_model() @@ -192,10 +192,16 @@ func _create_collision_and_model(): func _connect_settings() -> void: - GlobalSettings.range_settings.temperature.setting_changed.connect(_on_environment_changed) - GlobalSettings.range_settings.altitude.setting_changed.connect(_on_environment_changed) - GlobalSettings.range_settings.range_units.setting_changed.connect(_on_environment_changed) - GlobalSettings.range_settings.surface_type.setting_changed.connect(_on_surface_type_changed) + var settings := GlobalSettingsManager.range_settings + + if not settings.temperature.setting_changed.is_connected(_on_environment_changed): + settings.temperature.setting_changed.connect(_on_environment_changed) + if not settings.altitude.setting_changed.is_connected(_on_environment_changed): + settings.altitude.setting_changed.connect(_on_environment_changed) + if not settings.range_units.setting_changed.is_connected(_on_environment_changed): + settings.range_units.setting_changed.connect(_on_environment_changed) + if not settings.surface_type.setting_changed.is_connected(_on_surface_type_changed): + settings.surface_type.setting_changed.connect(_on_surface_type_changed) func _create_physics_params(): @@ -227,18 +233,19 @@ func _on_surface_type_changed(value) -> void: func _update_environment() -> void: - var units: int = GlobalSettings.range_settings.range_units.value + var settings := GlobalSettingsManager.range_settings + var units: int = settings.range_units.value var density = _call_openfairway_method( _aero, &"get_air_density", &"GetAirDensity", - [GlobalSettings.range_settings.altitude.value, GlobalSettings.range_settings.temperature.value, units] + [settings.altitude.value, settings.temperature.value, units] ) var viscosity = _call_openfairway_method( _aero, &"get_dynamic_viscosity", &"GetDynamicViscosity", - [GlobalSettings.range_settings.temperature.value, units] + [settings.temperature.value, units] ) if density == null: _air_density = 1.225 @@ -268,7 +275,7 @@ func exit_surface_zone(surface: int) -> void: if not _surface_zone_stack.is_empty(): set_surface(_surface_zone_stack[_surface_zone_stack.size() - 1]) else: - set_surface(int(GlobalSettings.range_settings.surface_type.value)) + set_surface(int(GlobalSettingsManager.range_settings.surface_type.value)) func _apply_surface_params() -> void: @@ -489,7 +496,7 @@ func reset() -> void: launch_spin_rpm = 0.0 rollout_impact_spin_rpm = 0.0 _surface_zone_stack.clear() - set_surface(int(GlobalSettings.range_settings.surface_type.value)) + set_surface(int(GlobalSettingsManager.range_settings.surface_type.value)) state = PhysicsEnums.BallState.REST on_ground = false @@ -565,7 +572,7 @@ func hit_from_data(data: Dictionary) -> void: on_ground = false rollout_impact_spin_rpm = 0.0 _surface_zone_stack.clear() - set_surface(int(GlobalSettings.range_settings.surface_type.value)) + set_surface(int(GlobalSettingsManager.range_settings.surface_type.value)) position = Vector3(0.0, START_HEIGHT, 0.0) velocity = launch_velocity @@ -631,3 +638,7 @@ func _print_launch_debug(data: Dictionary, speed_mps: float, vla: float, hla: fl print("Initial omega: ", omega, " (%.0f rpm)" % (omega.length() / 0.10472)) print("Shot direction: ", shot_dir) print("===================") + + +func set_env(_value) -> void: + _update_environment() diff --git a/Player/player.gd b/Player/player.gd index c07a040..7ccf8a5 100644 --- a/Player/player.gd +++ b/Player/player.gd @@ -30,8 +30,8 @@ func _ready() -> void: ball.rest.connect(_on_ball_rest) # Set initial value and connect to setting changes - max_tracers = GlobalSettings.range_settings.shot_tracer_count.value - GlobalSettings.range_settings.shot_tracer_count.setting_changed.connect(_on_tracer_count_changed) + max_tracers = GlobalSettingsManager.range_settings.shot_tracer_count.value + GlobalSettingsManager.range_settings.shot_tracer_count.setting_changed.connect(_on_tracer_count_changed) func _on_tracer_count_changed(value) -> void: max_tracers = value diff --git a/UI/MainMenu/main_menu.gd b/UI/MainMenu/main_menu.gd index 3751d37..21c2450 100644 --- a/UI/MainMenu/main_menu.gd +++ b/UI/MainMenu/main_menu.gd @@ -1,30 +1,35 @@ extends Control -# TODO - add settings menu system on future PR. + @onready var _settings_button: Button = $VerticalLayout/TopStrip/HBoxContainer/SettingsButton @onready var _exit_button: Button = $VerticalLayout/TopStrip/HBoxContainer/ExitButton @onready var _courses_button: Button = $VerticalLayout/TilesRow/CoursesTile/CoursesTextBackdrop/CoursesButton @onready var _range_button: Button = $VerticalLayout/TilesRow/RangeTile/RangeTextBackdrop/RangeButton -@onready var _version_label: Label = $VerticalLayout/VersionLabel +@onready var _version_label: Label = $BottomInfoBar/VersionLabel +@onready var _launch_monitor_status: HBoxContainer = $BottomInfoBar/LaunchMonitorStatus +@onready var _launch_monitor_status_label: Label = $BottomInfoBar/LaunchMonitorStatus/StatusLabel +@onready var _launch_monitor_battery_label: Label = $BottomInfoBar/LaunchMonitorStatus/BatteryLabel +@onready var _launch_monitor_firmware_label: Label = $BottomInfoBar/LaunchMonitorStatus/FirmwareLabel +@onready var _settings_panel: SettingsPanel = $SettingsPanel var _version_fall_back: String = "dev" var _version_setting_path: String = "application/config/version" var _version_text: String -# Called when the node enters the scene tree for the first time. -func _ready(): - +func _ready() -> void: _exit_button.pressed.connect(_on_exit_pressed) - _settings_button.pressed.connect(_on_settings_pressed) + _settings_button.pressed.connect(_settings_panel.show_panel) _range_button.pressed.connect(_on_range_pressed) _courses_button.pressed.connect(_on_courses_pressed) - + _settings_panel.set_main_menu_button_visible(false) + _connect_launch_monitor_status_signals() + _update_version_label() + _update_launch_monitor_status() SceneManager.current_scene = self - -# Called every frame. 'delta' is the elapsed time since the previous frame. -func _process(_delta: float) -> void: - pass + +func _exit_tree() -> void: + _disconnect_launch_monitor_status_signals() func _on_range_pressed() -> void: @@ -35,16 +40,91 @@ func _on_courses_pressed() -> void: SceneManager.change_scene("res://Courses/CourseSelector/course_selector.tscn") -func _update_version_label(): +func _update_version_label() -> void: _version_text = _version_fall_back if (ProjectSettings.has_setting(_version_setting_path)): var _configured_version = str(ProjectSettings.get_setting(_version_setting_path)).strip_edges() - _version_text = _configured_version; + _version_text = _configured_version + + _version_label.text = "OSG Version %s" % _version_text + + +func _connect_launch_monitor_status_signals() -> void: + var refresh := _update_launch_monitor_status.unbind(1) + var launch_monitor = (LaunchMonitorManager as LaunchMonitorManagerAutoload) + if launch_monitor != null: + if not launch_monitor.status_changed.is_connected(refresh): + launch_monitor.status_changed.connect(refresh) + if not launch_monitor.battery_changed.is_connected(refresh): + launch_monitor.battery_changed.connect(refresh) + if not launch_monitor.firmware_changed.is_connected(refresh): + launch_monitor.firmware_changed.connect(refresh) + + var global_settings = (GlobalSettingsManager as GlobalSettings) + if global_settings != null and global_settings.app_settings != null: + var app_settings: AppSettings = global_settings.app_settings + if not app_settings.launch_monitor_enabled.setting_changed.is_connected(refresh): + app_settings.launch_monitor_enabled.setting_changed.connect(refresh) + if not app_settings.launch_monitor_provider.setting_changed.is_connected(refresh): + app_settings.launch_monitor_provider.setting_changed.connect(refresh) + + +func _disconnect_launch_monitor_status_signals() -> void: + var refresh := _update_launch_monitor_status.unbind(1) + var launch_monitor = (LaunchMonitorManager as LaunchMonitorManagerAutoload) + if launch_monitor != null: + if launch_monitor.status_changed.is_connected(refresh): + launch_monitor.status_changed.disconnect(refresh) + if launch_monitor.battery_changed.is_connected(refresh): + launch_monitor.battery_changed.disconnect(refresh) + if launch_monitor.firmware_changed.is_connected(refresh): + launch_monitor.firmware_changed.disconnect(refresh) + + var global_settings = (GlobalSettingsManager as GlobalSettings) + if global_settings != null and global_settings.app_settings != null: + var app_settings: AppSettings = global_settings.app_settings + if app_settings.launch_monitor_enabled.setting_changed.is_connected(refresh): + app_settings.launch_monitor_enabled.setting_changed.disconnect(refresh) + if app_settings.launch_monitor_provider.setting_changed.is_connected(refresh): + app_settings.launch_monitor_provider.setting_changed.disconnect(refresh) + + +func _update_launch_monitor_status() -> void: + var global_settings = (GlobalSettingsManager as GlobalSettings) + if global_settings == null or global_settings.app_settings == null: + _launch_monitor_status.visible = false + return + + var app_settings: AppSettings = global_settings.app_settings + if not bool(app_settings.launch_monitor_enabled.value): + _launch_monitor_status.visible = false + return + + var launch_monitor = (LaunchMonitorManager as LaunchMonitorManagerAutoload) + if launch_monitor == null: + _launch_monitor_status.visible = false + return + + _launch_monitor_status.visible = true + var monitor_status := str(launch_monitor.status).strip_edges() + if monitor_status.begins_with("PiTrac Listening on"): + monitor_status = "" + if monitor_status != "": + _launch_monitor_status_label.text = "Status: %s" % monitor_status + else: + _launch_monitor_status_label.text = "Status: -" + + var battery := int(launch_monitor.battery_level) + if battery >= 0: + _launch_monitor_battery_label.text = "Battery: %d%%" % battery + else: + _launch_monitor_battery_label.text = "Battery: -" - _version_label.text = "Version %s" % _version_text - -func _on_settings_pressed() -> void: - pass # Replace with function body. + var firmware := str(launch_monitor.firmware).strip_edges() + if firmware != "": + _launch_monitor_firmware_label.text = "Firmware: %s" % firmware + else: + _launch_monitor_firmware_label.text = "Firmware: -" func _on_exit_pressed() -> void: diff --git a/UI/MainMenu/main_menu.tscn b/UI/MainMenu/main_menu.tscn index ad6bd21..aa69e14 100644 --- a/UI/MainMenu/main_menu.tscn +++ b/UI/MainMenu/main_menu.tscn @@ -6,6 +6,7 @@ [ext_resource type="Texture2D" uid="uid://7h84ji1c4crd" path="res://UI/MainMenu/images/exit_door.svg" id="4_trkrl"] [ext_resource type="Texture2D" uid="uid://cjj74w7djkfgf" path="res://assets/images/menu/range_small.png" id="5_iyy2y"] [ext_resource type="Texture2D" uid="uid://cvbxn5mtc5xeh" path="res://assets/images/menu/airways_small.png" id="6_trkrl"] +[ext_resource type="PackedScene" uid="uid://d2ubcc3otyc5r" path="res://UI/Settings/settings_panel.tscn" id="7_settings_panel"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_bddxb"] @@ -244,11 +245,59 @@ theme_override_styles/focus = SubResource("StyleBoxEmpty_bddxb") layout_mode = 2 size_flags_vertical = 3 -[node name="VersionLabel" type="Label" parent="VerticalLayout" unique_id=1316645986] +[node name="BottomInfoBar" type="HBoxContainer" parent="." unique_id=374127055] +layout_mode = 1 +anchors_preset = -1 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -900.0 +offset_top = -40.0 +offset_right = -22.0 +offset_bottom = -14.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_override_constants/separation = 16 +alignment = 2 + +[node name="LaunchMonitorStatus" type="HBoxContainer" parent="BottomInfoBar" unique_id=1577263602] +visible = false layout_mode = 2 -size_flags_vertical = 10 +theme_override_constants/separation = 12 + +[node name="StatusLabel" type="Label" parent="BottomInfoBar/LaunchMonitorStatus" unique_id=8612828] +layout_mode = 2 +size_flags_horizontal = 3 theme_override_colors/font_color = Color(1, 1, 1, 1) theme_override_colors/font_outline_color = Color(0, 0, 0, 1) theme_override_font_sizes/font_size = 18 +text = "Status: -" +vertical_alignment = 1 + +[node name="BatteryLabel" type="Label" parent="BottomInfoBar/LaunchMonitorStatus" unique_id=1063738132] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_outline_color = Color(0, 0, 0, 1) +theme_override_font_sizes/font_size = 18 +text = "Battery: -" +vertical_alignment = 1 + +[node name="FirmwareLabel" type="Label" parent="BottomInfoBar/LaunchMonitorStatus" unique_id=1369280641] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_outline_color = Color(0, 0, 0, 1) +theme_override_font_sizes/font_size = 18 +text = "Firmware: -" +vertical_alignment = 1 + +[node name="VersionLabel" type="Label" parent="BottomInfoBar" unique_id=1316645986] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_outline_color = Color(0, 0, 0, 1) +theme_override_font_sizes/font_size = 18 +text = "OSG Version --" horizontal_alignment = 2 vertical_alignment = 1 + +[node name="SettingsPanel" parent="." unique_id=1118699087 instance=ExtResource("7_settings_panel")] diff --git a/UI/Settings/RangeSettings/ball_reset_timer.gd b/UI/Settings/RangeSettings/ball_reset_timer.gd index 59e070b..2141a1d 100644 --- a/UI/Settings/RangeSettings/ball_reset_timer.gd +++ b/UI/Settings/RangeSettings/ball_reset_timer.gd @@ -4,9 +4,9 @@ extends HBoxContainer # Called when the node enters the scene tree for the first time. func _ready() -> void: $ResetSpinBox.step = 0.5 - $ResetSpinBox.value = GlobalSettings.range_settings.ball_reset_timer.value - $ResetSpinBox.min_value = GlobalSettings.range_settings.ball_reset_timer.min_value - $ResetSpinBox.max_value = GlobalSettings.range_settings.ball_reset_timer.max_value + $ResetSpinBox.value = GlobalSettingsManager.range_settings.ball_reset_timer.value + $ResetSpinBox.min_value = GlobalSettingsManager.range_settings.ball_reset_timer.min_value + $ResetSpinBox.max_value = GlobalSettingsManager.range_settings.ball_reset_timer.max_value # Called every frame. 'delta' is the elapsed time since the previous frame. @@ -15,5 +15,5 @@ func _process(_delta: float) -> void: func _on_reset_spin_box_value_changed(value: float) -> void: - GlobalSettings.range_settings.ball_reset_timer.set_value(value) + GlobalSettingsManager.range_settings.ball_reset_timer.set_value(value) pass diff --git a/UI/Settings/RangeSettings/range_settings.gd b/UI/Settings/RangeSettings/range_settings.gd index dd3f28a..4931d66 100644 --- a/UI/Settings/RangeSettings/range_settings.gd +++ b/UI/Settings/RangeSettings/range_settings.gd @@ -48,11 +48,11 @@ func _setup_spin_box(spin_box: SpinBox, setting: Setting, step: float) -> void: spin_box.max_value = setting.max_value spin_box.value = setting.value spin_box.set_block_signals(false) - + if spin_box.value != setting.value: setting.set_value(spin_box.value) -# Called when the node enters the scene tree for the first time. + func _ready() -> void: reset_spin_box = $MarginContainer/VBoxContainer/BallResetTimer/ResetSpinBox temperature_spin_box = $MarginContainer/VBoxContainer/Temperature/TemperatureSpinBox @@ -60,51 +60,41 @@ func _ready() -> void: surface_option = $MarginContainer/VBoxContainer/SurfaceType/SurfaceOption tracer_count_spin_box = $MarginContainer/VBoxContainer/TracerCount/TracerCountSpinBox - # Reset Timer Settings - _setup_spin_box(reset_spin_box, GlobalSettings.range_settings.ball_reset_timer, 0.5) - - # Temperature Settings - _setup_spin_box(temperature_spin_box, GlobalSettings.range_settings.temperature, 1.0) - - # Altitude Settings - _setup_spin_box(altitude_spin_box, GlobalSettings.range_settings.altitude, 10.0) - - # Drag scale - # Tracer count - _setup_spin_box(tracer_count_spin_box, GlobalSettings.range_settings.shot_tracer_count, 1.0) + _setup_spin_box(reset_spin_box, GlobalSettingsManager.range_settings.ball_reset_timer, 0.5) + _setup_spin_box(temperature_spin_box, GlobalSettingsManager.range_settings.temperature, 1.0) + _setup_spin_box(altitude_spin_box, GlobalSettingsManager.range_settings.altitude, 10.0) + _setup_spin_box(tracer_count_spin_box, GlobalSettingsManager.range_settings.shot_tracer_count, 1.0) - # Surface type options surface_option.clear() surface_option.add_item("Fairway", PhysicsEnums.SurfaceType.FAIRWAY) surface_option.add_item("Soft Fairway", PhysicsEnums.SurfaceType.FAIRWAY_SOFT) surface_option.add_item("Rough", PhysicsEnums.SurfaceType.ROUGH) surface_option.add_item("Firm", PhysicsEnums.SurfaceType.FIRM) - var surface_id: int = GlobalSettings.range_settings.surface_type.value + var surface_id: int = GlobalSettingsManager.range_settings.surface_type.value var surface_index := surface_option.get_item_index(surface_id) if surface_index >= 0: surface_option.select(surface_index) + GlobalSettingsManager.range_settings.range_units.setting_changed.connect(update_units) - GlobalSettings.range_settings.range_units.setting_changed.connect(update_units) - - # Initialize toggle button states $MarginContainer/VBoxContainer/Units/CheckButton.set_pressed_no_signal( - GlobalSettings.range_settings.range_units.value == PhysicsEnums.Units.METRIC + GlobalSettingsManager.range_settings.range_units.value == PhysicsEnums.Units.METRIC ) $MarginContainer/VBoxContainer/CameraFollow/CheckButton.set_pressed_no_signal( - GlobalSettings.range_settings.camera_follow_mode.value + GlobalSettingsManager.range_settings.camera_follow_mode.value ) $MarginContainer/VBoxContainer/AutoBallReset/CheckButton.set_pressed_no_signal( - GlobalSettings.range_settings.auto_ball_reset.value + GlobalSettingsManager.range_settings.auto_ball_reset.value ) $MarginContainer/VBoxContainer/ShotInjector/CheckButton.set_pressed_no_signal( - GlobalSettings.range_settings.shot_injector_enabled.value + GlobalSettingsManager.app_settings.test_shots_enabled.value ) _setup_square_monitor_section() -# Called every frame. 'delta' is the elapsed time since the previous frame. -func _process(_delta: float) -> void: - pass +func _exit_tree() -> void: + var units_setting := GlobalSettingsManager.range_settings.range_units + if units_setting.setting_changed.is_connected(update_units): + units_setting.setting_changed.disconnect(update_units) func _on_settings_button_pressed() -> void: @@ -112,7 +102,6 @@ func _on_settings_button_pressed() -> void: func _on_background_clicked(event: InputEvent) -> void: - # Close the menu when clicking on the background if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: close_settings_requested.emit() @@ -123,32 +112,33 @@ func _on_exit_button_pressed() -> void: func _on_units_check_button_toggled(toggled_on: bool) -> void: if toggled_on: - GlobalSettings.range_settings.range_units.set_value(PhysicsEnums.Units.METRIC) + GlobalSettingsManager.range_settings.range_units.set_value(PhysicsEnums.Units.METRIC) else: - GlobalSettings.range_settings.range_units.set_value(PhysicsEnums.Units.IMPERIAL) + GlobalSettingsManager.range_settings.range_units.set_value(PhysicsEnums.Units.IMPERIAL) func _on_camer_check_button_toggled(toggled_on: bool) -> void: - GlobalSettings.range_settings.camera_follow_mode.set_value(toggled_on) + GlobalSettingsManager.range_settings.camera_follow_mode.set_value(toggled_on) func _on_auto_reset_check_button_toggled(toggled_on: bool) -> void: - GlobalSettings.range_settings.auto_ball_reset.set_value(toggled_on) + GlobalSettingsManager.range_settings.auto_ball_reset.set_value(toggled_on) func _on_injector_check_button_toggled(toggled_on: bool) -> void: - GlobalSettings.range_settings.shot_injector_enabled.set_value(toggled_on) + GlobalSettingsManager.app_settings.test_shots_enabled.set_value(toggled_on) + func _on_reset_spin_box_value_changed(value: float) -> void: - GlobalSettings.range_settings.ball_reset_timer.set_value(value) + GlobalSettingsManager.range_settings.ball_reset_timer.set_value(value) func _on_temperature_spin_box_value_changed(value: float) -> void: - GlobalSettings.range_settings.temperature.set_value(value) + GlobalSettingsManager.range_settings.temperature.set_value(value) func _on_altitude_spin_box_value_changed(value: float) -> void: - GlobalSettings.range_settings.altitude.set_value(value) + GlobalSettingsManager.range_settings.altitude.set_value(value) func _on_drag_spin_box_value_changed(_value: float) -> void: @@ -157,15 +147,11 @@ func _on_drag_spin_box_value_changed(_value: float) -> void: func _on_surface_option_item_selected(index: int) -> void: var id: int = surface_option.get_item_id(index) - GlobalSettings.range_settings.surface_type.set_value(id) + GlobalSettingsManager.range_settings.surface_type.set_value(id) func _on_tracer_count_spin_box_value_changed(value: float) -> void: - GlobalSettings.range_settings.shot_tracer_count.set_value(int(value)) - - -func _on_ball_type_option_item_selected(index: int) -> void: - GlobalSettings.range_settings.ball_type.set_value(index) + GlobalSettingsManager.range_settings.shot_tracer_count.set_value(int(value)) func _setup_square_monitor_section() -> void: @@ -189,7 +175,7 @@ func _setup_square_monitor_section() -> void: enabled_row.add_child(_make_label("Enabled")) enabled_row.add_child(_make_spacer()) square_enabled_button = CheckButton.new() - square_enabled_button.set_pressed_no_signal(bool(launch_monitor.settings.get("enabled", false))) + square_enabled_button.set_pressed_no_signal(bool(GlobalSettingsManager.app_settings.launch_monitor_enabled.value)) square_enabled_button.toggled.connect(_on_square_enabled_toggled) enabled_row.add_child(square_enabled_button) section.add_child(enabled_row) @@ -228,7 +214,7 @@ func _setup_square_monitor_section() -> void: var index := square_club_option.item_count square_club_option.add_item(club_name) square_club_option.set_item_metadata(index, SQUARE_CLUBS[club_name]) - var current_club := str(launch_monitor.settings.get("club_code", "0204")) + var current_club: String = launch_monitor.get_square_club_code() _select_option_by_metadata(square_club_option, current_club) square_club_option.item_selected.connect(_on_square_club_selected) club_row.add_child(square_club_option) @@ -239,7 +225,7 @@ func _setup_square_monitor_section() -> void: square_handedness_option = OptionButton.new() square_handedness_option.add_item("Right", 0) square_handedness_option.add_item("Left", 1) - var handedness := int(launch_monitor.settings.get("handedness", 0)) + var handedness: int = launch_monitor.get_square_handedness() var hand_index := square_handedness_option.get_item_index(handedness) if hand_index >= 0: square_handedness_option.select(hand_index) @@ -294,7 +280,7 @@ func _refresh_square_devices() -> void: if square_device_option == null or not has_node("/root/LaunchMonitorManager"): return var launch_monitor = get_node("/root/LaunchMonitorManager") - var selected_device := str(launch_monitor.settings.get("device_id", "")) + var selected_device: String = launch_monitor.get_selected_device_id() square_device_option.clear() for device_id in launch_monitor.devices.keys(): var device = launch_monitor.devices[device_id] @@ -404,29 +390,30 @@ func update_units(value) -> void: # Block spin box signals to prevent _on_*_value_changed from firing # during conversion, which would double-write the setting. + temperature_spin_box.set_block_signals(true) altitude_spin_box.set_block_signals(true) if value == PhysicsEnums.Units.IMPERIAL: $MarginContainer/VBoxContainer/Temperature/Label2.text = "F" - var temp_f = GlobalSettings.range_settings.temperature.value * 9.0 / 5.0 + 32.0 + var temp_f = GlobalSettingsManager.range_settings.temperature.value * 9.0 / 5.0 + 32.0 temperature_spin_box.value = temp_f - GlobalSettings.range_settings.temperature.set_value(temp_f) + GlobalSettingsManager.range_settings.temperature.set_value(temp_f) $MarginContainer/VBoxContainer/Altitude/Label2.text = "ft" - var alt_ft = GlobalSettings.range_settings.altitude.value * m2ft + var alt_ft = GlobalSettingsManager.range_settings.altitude.value * m2ft altitude_spin_box.value = alt_ft - GlobalSettings.range_settings.altitude.set_value(alt_ft) + GlobalSettingsManager.range_settings.altitude.set_value(alt_ft) else: $MarginContainer/VBoxContainer/Temperature/Label2.text = "C" - var temp_c = (GlobalSettings.range_settings.temperature.value - 32.0) * 5.0 / 9.0 + var temp_c = (GlobalSettingsManager.range_settings.temperature.value - 32.0) * 5.0 / 9.0 temperature_spin_box.value = temp_c - GlobalSettings.range_settings.temperature.set_value(temp_c) + GlobalSettingsManager.range_settings.temperature.set_value(temp_c) $MarginContainer/VBoxContainer/Altitude/Label2.text = "m" - var alt_m = GlobalSettings.range_settings.altitude.value / m2ft + var alt_m = GlobalSettingsManager.range_settings.altitude.value / m2ft altitude_spin_box.value = alt_m - GlobalSettings.range_settings.altitude.set_value(alt_m) + GlobalSettingsManager.range_settings.altitude.set_value(alt_m) temperature_spin_box.set_block_signals(false) altitude_spin_box.set_block_signals(false) diff --git a/UI/Settings/RangeSettings/range_settings.tscn b/UI/Settings/RangeSettings/range_settings.tscn index 981af1c..011827f 100644 --- a/UI/Settings/RangeSettings/range_settings.tscn +++ b/UI/Settings/RangeSettings/range_settings.tscn @@ -1,266 +1,247 @@ -[gd_scene load_steps=2 format=3 uid="uid://cr5js3cuw3b7y"] +[gd_scene format=3 uid="uid://cr5js3cuw3b7y"] [ext_resource type="Script" uid="uid://coaq5bnt4i7s6" path="res://UI/Settings/RangeSettings/range_settings.gd" id="1_btrk3"] -[node name="RangeSettings" type="PanelContainer"] +[node name="RangeSettings" type="PanelContainer" unique_id=858607318] offset_right = 379.0 offset_bottom = 197.0 size_flags_horizontal = 4 size_flags_vertical = 4 script = ExtResource("1_btrk3") -[node name="MarginContainer" type="MarginContainer" parent="."] +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1606038249] layout_mode = 2 theme_override_constants/margin_left = 20 theme_override_constants/margin_top = 20 theme_override_constants/margin_right = 20 theme_override_constants/margin_bottom = 20 -[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"] +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" unique_id=1305118213] layout_mode = 2 theme_override_constants/separation = 20 -[node name="Units" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="Units" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=2050983912] layout_mode = 2 -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/Units"] +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/Units" unique_id=878783648] layout_mode = 2 text = "Units: " -[node name="Control" type="Control" parent="MarginContainer/VBoxContainer/Units"] +[node name="Control" type="Control" parent="MarginContainer/VBoxContainer/Units" unique_id=62152639] layout_mode = 2 size_flags_horizontal = 3 size_flags_stretch_ratio = 2.4 -[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/Units"] +[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/Units" unique_id=280232343] layout_mode = 2 text = "Imperial" -[node name="CheckButton" type="CheckButton" parent="MarginContainer/VBoxContainer/Units"] +[node name="CheckButton" type="CheckButton" parent="MarginContainer/VBoxContainer/Units" unique_id=921708568] layout_mode = 2 -[node name="Label3" type="Label" parent="MarginContainer/VBoxContainer/Units"] +[node name="Label3" type="Label" parent="MarginContainer/VBoxContainer/Units" unique_id=1822062865] layout_mode = 2 text = "Metric" -[node name="Control2" type="Control" parent="MarginContainer/VBoxContainer/Units"] +[node name="Control2" type="Control" parent="MarginContainer/VBoxContainer/Units" unique_id=1265798715] layout_mode = 2 size_flags_horizontal = 3 -[node name="CameraFollow" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="CameraFollow" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=1715889945] layout_mode = 2 -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/CameraFollow"] +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/CameraFollow" unique_id=636784964] layout_mode = 2 text = "Camera Follow Mode" -[node name="Control" type="Control" parent="MarginContainer/VBoxContainer/CameraFollow"] +[node name="Control" type="Control" parent="MarginContainer/VBoxContainer/CameraFollow" unique_id=711106199] layout_mode = 2 size_flags_horizontal = 3 size_flags_stretch_ratio = 1.5 -[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/CameraFollow"] +[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/CameraFollow" unique_id=554398619] layout_mode = 2 text = "Off" -[node name="CheckButton" type="CheckButton" parent="MarginContainer/VBoxContainer/CameraFollow"] +[node name="CheckButton" type="CheckButton" parent="MarginContainer/VBoxContainer/CameraFollow" unique_id=1964125840] layout_mode = 2 -[node name="Label3" type="Label" parent="MarginContainer/VBoxContainer/CameraFollow"] +[node name="Label3" type="Label" parent="MarginContainer/VBoxContainer/CameraFollow" unique_id=1520578048] layout_mode = 2 text = "On" -[node name="Control2" type="Control" parent="MarginContainer/VBoxContainer/CameraFollow"] +[node name="Control2" type="Control" parent="MarginContainer/VBoxContainer/CameraFollow" unique_id=2005686732] layout_mode = 2 size_flags_horizontal = 3 -[node name="AutoBallReset" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="AutoBallReset" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=593490346] layout_mode = 2 -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/AutoBallReset"] +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/AutoBallReset" unique_id=1000793982] layout_mode = 2 text = "Auto Ball Reset: " -[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/AutoBallReset"] +[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/AutoBallReset" unique_id=190011344] layout_mode = 2 size_flags_horizontal = 3 -[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/AutoBallReset"] +[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/AutoBallReset" unique_id=1874941278] layout_mode = 2 text = "Off" -[node name="CheckButton" type="CheckButton" parent="MarginContainer/VBoxContainer/AutoBallReset"] +[node name="CheckButton" type="CheckButton" parent="MarginContainer/VBoxContainer/AutoBallReset" unique_id=1763855250] layout_mode = 2 -[node name="Label3" type="Label" parent="MarginContainer/VBoxContainer/AutoBallReset"] +[node name="Label3" type="Label" parent="MarginContainer/VBoxContainer/AutoBallReset" unique_id=1108671789] layout_mode = 2 text = "On" -[node name="Control2" type="Control" parent="MarginContainer/VBoxContainer/AutoBallReset"] +[node name="Control2" type="Control" parent="MarginContainer/VBoxContainer/AutoBallReset" unique_id=2128089261] layout_mode = 2 size_flags_horizontal = 3 -[node name="ShotInjector" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="ShotInjector" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=1222301673] layout_mode = 2 -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/ShotInjector"] +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/ShotInjector" unique_id=125100041] layout_mode = 2 text = "Shot Injector" -[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/ShotInjector"] +[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/ShotInjector" unique_id=868601521] layout_mode = 2 size_flags_horizontal = 3 -[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/ShotInjector"] +[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/ShotInjector" unique_id=456784805] layout_mode = 2 text = "Off" -[node name="CheckButton" type="CheckButton" parent="MarginContainer/VBoxContainer/ShotInjector"] +[node name="CheckButton" type="CheckButton" parent="MarginContainer/VBoxContainer/ShotInjector" unique_id=1905255902] layout_mode = 2 -[node name="Label3" type="Label" parent="MarginContainer/VBoxContainer/ShotInjector"] +[node name="Label3" type="Label" parent="MarginContainer/VBoxContainer/ShotInjector" unique_id=890577038] layout_mode = 2 text = "On" -[node name="Control2" type="Control" parent="MarginContainer/VBoxContainer/ShotInjector"] +[node name="Control2" type="Control" parent="MarginContainer/VBoxContainer/ShotInjector" unique_id=612207913] layout_mode = 2 size_flags_horizontal = 3 -[node name="BallResetTimer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="BallResetTimer" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=2115580534] layout_mode = 2 -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/BallResetTimer"] +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/BallResetTimer" unique_id=1372892233] layout_mode = 2 text = "Ball Reset Timer: " -[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/BallResetTimer"] +[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/BallResetTimer" unique_id=174401845] layout_mode = 2 size_flags_horizontal = 3 size_flags_stretch_ratio = 0.6 -[node name="ResetSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/BallResetTimer"] +[node name="ResetSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/BallResetTimer" unique_id=1668552285] custom_minimum_size = Vector2(100, 0) layout_mode = 2 size_flags_horizontal = 3 -[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/BallResetTimer"] +[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/BallResetTimer" unique_id=1743784890] layout_mode = 2 text = "sec" -[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/BallResetTimer"] +[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/BallResetTimer" unique_id=1372207902] layout_mode = 2 size_flags_horizontal = 3 -[node name="Temperature" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="Temperature" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=856067080] layout_mode = 2 -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/Temperature"] +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/Temperature" unique_id=1388603945] layout_mode = 2 text = "Temperature: " -[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/Temperature"] +[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/Temperature" unique_id=24545527] layout_mode = 2 size_flags_horizontal = 3 -[node name="TemperatureSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/Temperature"] +[node name="TemperatureSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/Temperature" unique_id=1166781553] custom_minimum_size = Vector2(100, 0) layout_mode = 2 size_flags_horizontal = 3 -[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/Temperature"] +[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/Temperature" unique_id=183219283] layout_mode = 2 text = "F" -[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/Temperature"] +[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/Temperature" unique_id=1455618383] layout_mode = 2 size_flags_horizontal = 3 -[node name="Altitude" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="Altitude" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=374085625] layout_mode = 2 -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/Altitude"] +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/Altitude" unique_id=26560270] layout_mode = 2 text = "Altitude: " -[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/Altitude"] +[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/Altitude" unique_id=788933725] layout_mode = 2 size_flags_horizontal = 3 size_flags_stretch_ratio = 1.7 -[node name="AltitudeSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/Altitude"] +[node name="AltitudeSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/Altitude" unique_id=489700716] custom_minimum_size = Vector2(100, 0) layout_mode = 2 size_flags_horizontal = 3 -[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/Altitude"] +[node name="Label2" type="Label" parent="MarginContainer/VBoxContainer/Altitude" unique_id=124588874] layout_mode = 2 text = "ft" -[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/Altitude"] +[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/Altitude" unique_id=162870901] layout_mode = 2 size_flags_horizontal = 3 -[node name="SurfaceType" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="SurfaceType" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=866829693] layout_mode = 2 -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/SurfaceType"] +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/SurfaceType" unique_id=786277823] layout_mode = 2 text = "Surface: " -[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/SurfaceType"] +[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/SurfaceType" unique_id=360838115] layout_mode = 2 size_flags_horizontal = 3 -[node name="SurfaceOption" type="OptionButton" parent="MarginContainer/VBoxContainer/SurfaceType"] +[node name="SurfaceOption" type="OptionButton" parent="MarginContainer/VBoxContainer/SurfaceType" unique_id=1972692762] layout_mode = 2 size_flags_horizontal = 3 -[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/SurfaceType"] +[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/SurfaceType" unique_id=256412771] layout_mode = 2 size_flags_horizontal = 3 -[node name="BallType" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="TracerCount" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=1689748192] layout_mode = 2 -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/BallType"] -layout_mode = 2 -text = "Ball: " - -[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/BallType"] -layout_mode = 2 -size_flags_horizontal = 3 - -[node name="BallTypeOption" type="OptionButton" parent="MarginContainer/VBoxContainer/BallType"] -layout_mode = 2 -size_flags_horizontal = 3 - -[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/BallType"] -layout_mode = 2 -size_flags_horizontal = 3 - -[node name="TracerCount" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] -layout_mode = 2 - -[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/TracerCount"] +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/TracerCount" unique_id=887604803] layout_mode = 2 text = "Shot Tracers: " -[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/TracerCount"] +[node name="Control3" type="Control" parent="MarginContainer/VBoxContainer/TracerCount" unique_id=1350893601] layout_mode = 2 size_flags_horizontal = 3 size_flags_stretch_ratio = 1.4 -[node name="TracerCountSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/TracerCount"] +[node name="TracerCountSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/TracerCount" unique_id=1614618871] custom_minimum_size = Vector2(100, 0) layout_mode = 2 size_flags_horizontal = 3 -[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/TracerCount"] +[node name="Control4" type="Control" parent="MarginContainer/VBoxContainer/TracerCount" unique_id=198465336] layout_mode = 2 size_flags_horizontal = 3 -[node name="ExitButton" type="Button" parent="MarginContainer/VBoxContainer"] +[node name="ExitButton" type="Button" parent="MarginContainer/VBoxContainer" unique_id=94038068] layout_mode = 2 size_flags_horizontal = 4 text = "Exit to Menu" @@ -272,7 +253,6 @@ text = "Exit to Menu" [connection signal="value_changed" from="MarginContainer/VBoxContainer/BallResetTimer/ResetSpinBox" to="." method="_on_reset_spin_box_value_changed"] [connection signal="value_changed" from="MarginContainer/VBoxContainer/Temperature/TemperatureSpinBox" to="." method="_on_temperature_spin_box_value_changed"] [connection signal="value_changed" from="MarginContainer/VBoxContainer/Altitude/AltitudeSpinBox" to="." method="_on_altitude_spin_box_value_changed"] -[connection signal="item_selected" from="MarginContainer/VBoxContainer/BallType/BallTypeOption" to="." method="_on_ball_type_option_item_selected"] [connection signal="item_selected" from="MarginContainer/VBoxContainer/SurfaceType/SurfaceOption" to="." method="_on_surface_option_item_selected"] [connection signal="value_changed" from="MarginContainer/VBoxContainer/TracerCount/TracerCountSpinBox" to="." method="_on_tracer_count_spin_box_value_changed"] [connection signal="pressed" from="MarginContainer/VBoxContainer/ExitButton" to="." method="_on_exit_button_pressed"] diff --git a/UI/Settings/settings_panel.gd b/UI/Settings/settings_panel.gd new file mode 100644 index 0000000..3fd35dd --- /dev/null +++ b/UI/Settings/settings_panel.gd @@ -0,0 +1,959 @@ +class_name SettingsPanel +extends CanvasLayer + +## Tabbed settings overlay. Hosts (Main Menu, Range, Courses) must: +## - call set_main_menu_button_visible(false) when the panel is opened from +## inside the Main Menu (the "MAIN MENU" button is meaningless there); +## - connect main_menu_requested when the button IS visible — the panel only +## emits the signal, it does not navigate scenes itself; +## - optionally connect `closed` if the host needs to react to dismissal. +signal closed +signal main_menu_requested + +enum SettingsTab { PLAYER, DISPLAY, GAME, LMONITORS, PANELS } + +const FALLBACK_PLAYER_NAME := "JesseInCode" +const FALLBACK_RESOLUTION_PRESET := "1728x972" +const FEET_PER_CAMERA_DISTANCE := 3.28084 +const CAMERA_DISTANCE_MIN_UNITS := 1.0 +const CAMERA_DISTANCE_MAX_UNITS := 8.0 +const CAMERA_DISTANCE_MIN_FEET := CAMERA_DISTANCE_MIN_UNITS * FEET_PER_CAMERA_DISTANCE +const CAMERA_DISTANCE_MAX_FEET := CAMERA_DISTANCE_MAX_UNITS * FEET_PER_CAMERA_DISTANCE +const PANEL_WIDTH := 1040.0 +const PANEL_HEIGHT := 580.0 +const PANEL_SHADOW_PADDING_X := 17.0 +const PANEL_SHADOW_PADDING_Y := 18.0 +const LMONITOR_PROVIDER_SQUARE := AppSettings.LAUNCH_MONITOR_PROVIDER_SQUARE +const LMONITOR_PROVIDER_PITRAC := AppSettings.LAUNCH_MONITOR_PROVIDER_PITRAC + +var _root_control: Control = null +var _panel_shadow: PanelContainer = null +var _panel: PanelContainer = null +var _tabs: TabContainer = null +var _player_tab_scroll: ScrollContainer = null +var _display_tab_scroll: ScrollContainer = null +var _game_tab_scroll: ScrollContainer = null +var _lmonitors_tab_scroll: ScrollContainer = null +var _player_name_input: LineEdit = null +var _range_default_club_card: PanelContainer = null +var _range_default_club_option: OptionButton = null +var _test_shots_check: CheckBox = null +var _resolution_option: OptionButton = null +var _fullscreen_check: CheckBox = null +var _camera_distance_slider: HSlider = null +var _camera_distance_value: SpinBox = null +var _camera_distance_helper: Label = null +var _camera_delay_slider: HSlider = null +var _camera_delay_value: SpinBox = null +var _camera_delay_helper: Label = null +var _tracer_history_card: PanelContainer = null +var _tracer_history_slider: HSlider = null +var _tracer_history_value: SpinBox = null +var _tracer_history_helper: Label = null +var _launch_monitor_enabled_check: CheckBox = null +var _launch_monitor_provider_card: PanelContainer = null +var _launch_monitor_provider_option: OptionButton = null +var _square_monitor_card: PanelContainer = null +var _square_device_option: OptionButton = null +var _square_scan_button: Button = null +var _square_connect_button: Button = null +var _square_disconnect_button: Button = null +var _square_club_option: OptionButton = null +var _square_handedness_option: OptionButton = null +var _pitrac_card: PanelContainer = null +var _tcp_port_value: SpinBox = null +var _shot_recording_check: CheckBox = null +var _shot_recording_path_input: LineEdit = null +var _shot_recording_browse_button: Button = null +var _shot_recording_helper: Label = null +var _shot_recording_file_dialog: FileDialog = null +var _main_menu_button: Button = null +var _save_button: Button = null +var _close_button: Button = null +var _panels_empty_label: Label = null +var _panel_toggle_checked_icon: Texture2D = null +var _panel_toggle_unchecked_icon: Texture2D = null + +var _show_tracer_history_setting := true +var _show_range_default_club_setting := true + + +func _ready() -> void: + _build_ui() + _create_panel_toggle_icons() + _configure_controls() + _connect_control_signals() + _connect_setting_signals() + _refresh_controls_from_settings() + call_deferred("_sync_panel_shadow_to_panel") + visible = false + + +func _exit_tree() -> void: + _disconnect_setting_signals() + + +func _unhandled_input(event: InputEvent) -> void: + if not visible: + return + + if event.is_action_pressed("ui_cancel"): + hide_panel() + get_viewport().set_input_as_handled() + + +func show_panel(tab: int = SettingsTab.PLAYER) -> void: + _refresh_controls_from_settings() + _set_active_tab(tab) + visible = true + call_deferred("_sync_panel_shadow_to_panel") + + if tab == SettingsTab.PLAYER: + _player_name_input.grab_focus() + +func hide_panel() -> void: + if not visible: + return + + visible = false + closed.emit() + + +func set_main_menu_button_visible(is_visible: bool) -> void: + _main_menu_button.visible = is_visible + + +func set_tracer_history_setting_visible(is_visible: bool) -> void: + _show_tracer_history_setting = is_visible + if not is_node_ready(): + return + _apply_tracer_history_visibility() + + +func set_range_default_club_setting_visible(is_visible: bool) -> void: + _show_range_default_club_setting = is_visible + if not is_node_ready(): + return + _apply_range_default_club_visibility() + + +func _build_ui() -> void: + _root_control = Control.new() + _root_control.name = "Root" + _root_control.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(_root_control) + + var backdrop := ColorRect.new() + backdrop.name = "Backdrop" + backdrop.color = Color(0, 0, 0, 0.48) + backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + backdrop.set_anchors_preset(Control.PRESET_FULL_RECT) + backdrop.gui_input.connect(_on_backdrop_gui_input) + _root_control.add_child(backdrop) + + _panel_shadow = PanelContainer.new() + _panel_shadow.name = "PanelShadow" + _panel_shadow.mouse_filter = Control.MOUSE_FILTER_IGNORE + _panel_shadow.add_theme_stylebox_override("panel", SettingsPanelStyles.make_style_box(Color(0, 0, 0, 0.38), Color.TRANSPARENT, 0, 16)) + SettingsPanelStyles.set_centered_rect(_panel_shadow, PANEL_WIDTH + PANEL_SHADOW_PADDING_X * 2.0, PANEL_HEIGHT + PANEL_SHADOW_PADDING_Y * 2.0) + _root_control.add_child(_panel_shadow) + + _panel = PanelContainer.new() + _panel.name = "Panel" + _panel.add_theme_stylebox_override("panel", SettingsPanelStyles.make_style_box(Color(0.043, 0.180, 0.310, 0.95), Color(0.337, 0.698, 0.937, 0.42), 2, 14)) + SettingsPanelStyles.set_centered_rect(_panel, PANEL_WIDTH, PANEL_HEIGHT) + _root_control.add_child(_panel) + + var margin := MarginContainer.new() + margin.name = "Margin" + margin.add_theme_constant_override("margin_left", 24) + margin.add_theme_constant_override("margin_top", 20) + margin.add_theme_constant_override("margin_right", 24) + margin.add_theme_constant_override("margin_bottom", 20) + _panel.add_child(margin) + + var content := VBoxContainer.new() + content.name = "Content" + content.add_theme_constant_override("separation", 16) + margin.add_child(content) + + _build_header(content) + _build_tabs(content) + + _shot_recording_file_dialog = FileDialog.new() + _shot_recording_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR + _shot_recording_file_dialog.access = FileDialog.ACCESS_FILESYSTEM + _shot_recording_file_dialog.title = "Select Shot Recording Directory" + add_child(_shot_recording_file_dialog) + + +func _build_header(parent: Container) -> void: + var header := PanelContainer.new() + header.name = "HeaderBanner" + header.add_theme_stylebox_override("panel", SettingsPanelStyles.make_style_box(Color(0.063, 0.086, 0.125, 0.96), Color(0.337, 0.698, 0.937, 0.45), 1, 10, false)) + parent.add_child(header) + + var margin := MarginContainer.new() + margin.name = "HeaderMargin" + margin.add_theme_constant_override("margin_left", 20) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_right", 20) + margin.add_theme_constant_override("margin_bottom", 12) + header.add_child(margin) + + var row := HBoxContainer.new() + row.name = "HeaderRow" + row.add_theme_constant_override("separation", 12) + margin.add_child(row) + + var title := Label.new() + title.name = "Title" + title.text = "SETTINGS" + title.add_theme_color_override("font_color", Color(0.96, 0.98, 1.0, 1.0)) + title.add_theme_font_size_override("font_size", 34) + row.add_child(title) + + var spacer := Control.new() + spacer.name = "HeaderSpacer" + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(spacer) + + _main_menu_button = SettingsPanelStyles.make_button("MAIN MENU", false) + _main_menu_button.name = "MainMenuButton" + _main_menu_button.visible = false + row.add_child(_main_menu_button) + + _save_button = SettingsPanelStyles.make_button("SAVE", true) + _save_button.name = "SaveButton" + row.add_child(_save_button) + + _close_button = SettingsPanelStyles.make_button("CLOSE", false) + _close_button.name = "CloseButton" + row.add_child(_close_button) + + +func _build_tabs(parent: Container) -> void: + _tabs = TabContainer.new() + _tabs.name = "Tabs" + _tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL + _tabs.add_theme_stylebox_override("panel", SettingsPanelStyles.make_style_box(Color(0.020, 0.094, 0.161, 0.70), Color(1, 1, 1, 0.20), 1, 10)) + _tabs.add_theme_stylebox_override("tab_selected", SettingsPanelStyles.make_tab_style(Color(0.129, 0.529, 0.824, 0.95), 8, 0)) + _tabs.add_theme_stylebox_override("tab_unselected", SettingsPanelStyles.make_tab_style(Color(0.063, 0.086, 0.125, 0.90), 8, 0)) + _tabs.add_theme_stylebox_override("tab_hovered", SettingsPanelStyles.make_tab_style(Color(0.090, 0.141, 0.212, 0.95), 8, 0)) + _tabs.add_theme_color_override("font_selected_color", Color(0.96, 0.98, 1, 1)) + _tabs.add_theme_color_override("font_unselected_color", Color(0.82, 0.88, 0.94, 0.92)) + _tabs.add_theme_color_override("font_hovered_color", Color(0.96, 0.98, 1, 1)) + _tabs.add_theme_font_size_override("font_size", 20) + parent.add_child(_tabs) + + _build_player_tab() + _build_display_tab() + _build_game_tab() + _build_lmonitors_tab() + _build_panels_tab() + + +func _build_player_tab() -> void: + var player_content := SettingsPanelStyles.make_scroll_tab(_tabs, "Player") + _player_tab_scroll = player_content.get_parent() as ScrollContainer + + var row := SettingsPanelStyles.make_row_card(player_content, "PlayerCard", "Player Name") + _player_name_input = LineEdit.new() + _player_name_input.name = "PlayerNameInput" + _player_name_input.placeholder_text = "Enter player name" + _player_name_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL + SettingsPanelStyles.apply_field_style(_player_name_input) + row.add_child(_player_name_input) + + _range_default_club_card = SettingsPanelStyles.make_card(player_content, "RangeDefaultClubCard") + row = SettingsPanelStyles.make_card_row(_range_default_club_card, "Default Club (Range)") + _range_default_club_option = OptionButton.new() + _range_default_club_option.name = "RangeDefaultClubOption" + _range_default_club_option.custom_minimum_size = Vector2(220, 0) + SettingsPanelStyles.apply_field_style(_range_default_club_option) + row.add_child(_range_default_club_option) + row.add_child(SettingsPanelStyles.make_spacer()) + + row = SettingsPanelStyles.make_row_card(player_content, "PlayerTestShotsCard", "Enable Test Shots") + _test_shots_check = CheckBox.new() + _test_shots_check.name = "TestShotsCheck" + _test_shots_check.text = "Enabled" + SettingsPanelStyles.apply_check_style(_test_shots_check) + row.add_child(_test_shots_check) + row.add_child(SettingsPanelStyles.make_spacer()) + + +func _build_display_tab() -> void: + var display_content := SettingsPanelStyles.make_scroll_tab(_tabs, "Display") + _display_tab_scroll = display_content.get_parent() as ScrollContainer + + var row := SettingsPanelStyles.make_row_card(display_content, "DisplayResolutionCard", "Window Size") + _resolution_option = OptionButton.new() + _resolution_option.name = "ResolutionOption" + _resolution_option.custom_minimum_size = Vector2(340, 0) + SettingsPanelStyles.apply_field_style(_resolution_option) + row.add_child(_resolution_option) + + _fullscreen_check = CheckBox.new() + _fullscreen_check.name = "FullscreenCheck" + _fullscreen_check.text = "Fullscreen" + SettingsPanelStyles.apply_check_style(_fullscreen_check) + row.add_child(_fullscreen_check) + + +func _build_game_tab() -> void: + var game_content := SettingsPanelStyles.make_scroll_tab(_tabs, "Game") + _game_tab_scroll = game_content.get_parent() as ScrollContainer + + var card := SettingsPanelStyles.make_card(game_content, "CameraDistanceCard") + var content := SettingsPanelStyles.make_card_content(card) + content.add_child(SettingsPanelStyles.make_section_label("Camera Distance (ft)")) + var row := SettingsPanelStyles.make_control_row() + _camera_distance_slider = HSlider.new() + _camera_distance_slider.name = "CameraDistanceSlider" + _camera_distance_slider.custom_minimum_size = Vector2(460, 0) + _camera_distance_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(_camera_distance_slider) + _camera_distance_value = SpinBox.new() + _camera_distance_value.name = "CameraDistanceValue" + _camera_distance_value.custom_minimum_size = Vector2(110, 0) + _camera_distance_value.alignment = HORIZONTAL_ALIGNMENT_CENTER + row.add_child(_camera_distance_value) + content.add_child(row) + _camera_distance_helper = SettingsPanelStyles.make_helper_label("Distance from ball: 0 ft") + content.add_child(_camera_distance_helper) + + card = SettingsPanelStyles.make_card(game_content, "CameraDelayCard") + content = SettingsPanelStyles.make_card_content(card) + content.add_child(SettingsPanelStyles.make_section_label("Camera Follow Delay (seconds)")) + row = SettingsPanelStyles.make_control_row() + _camera_delay_slider = HSlider.new() + _camera_delay_slider.name = "CameraDelaySlider" + _camera_delay_slider.custom_minimum_size = Vector2(460, 0) + _camera_delay_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(_camera_delay_slider) + _camera_delay_value = SpinBox.new() + _camera_delay_value.name = "CameraDelayValue" + _camera_delay_value.custom_minimum_size = Vector2(110, 0) + _camera_delay_value.alignment = HORIZONTAL_ALIGNMENT_CENTER + row.add_child(_camera_delay_value) + content.add_child(row) + _camera_delay_helper = SettingsPanelStyles.make_helper_label("Follow starts after 0.00 seconds") + content.add_child(_camera_delay_helper) + + _tracer_history_card = SettingsPanelStyles.make_card(game_content, "TracerHistoryCard") + content = SettingsPanelStyles.make_card_content(_tracer_history_card) + content.add_child(SettingsPanelStyles.make_section_label("Tracer History Count")) + row = SettingsPanelStyles.make_control_row() + _tracer_history_slider = HSlider.new() + _tracer_history_slider.name = "TracerHistorySlider" + _tracer_history_slider.custom_minimum_size = Vector2(460, 0) + _tracer_history_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(_tracer_history_slider) + _tracer_history_value = SpinBox.new() + _tracer_history_value.name = "TracerHistoryValue" + _tracer_history_value.custom_minimum_size = Vector2(110, 0) + _tracer_history_value.alignment = HORIZONTAL_ALIGNMENT_CENTER + row.add_child(_tracer_history_value) + content.add_child(row) + _tracer_history_helper = SettingsPanelStyles.make_helper_label("Retains the latest 2 tracers in Range.") + content.add_child(_tracer_history_helper) + + card = SettingsPanelStyles.make_card(game_content, "ShotRecordingCard") + content = SettingsPanelStyles.make_card_content(card) + row = SettingsPanelStyles.make_labeled_row("Shot Recording") + _shot_recording_check = CheckBox.new() + _shot_recording_check.name = "ShotRecordingCheck" + _shot_recording_check.text = "Enabled" + SettingsPanelStyles.apply_check_style(_shot_recording_check) + row.add_child(_shot_recording_check) + row.add_child(SettingsPanelStyles.make_spacer()) + content.add_child(row) + + row = SettingsPanelStyles.make_labeled_row("Save Path") + _shot_recording_path_input = LineEdit.new() + _shot_recording_path_input.name = "ShotRecordingPathInput" + _shot_recording_path_input.placeholder_text = "Select directory..." + _shot_recording_path_input.editable = false + _shot_recording_path_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL + SettingsPanelStyles.apply_field_style(_shot_recording_path_input) + row.add_child(_shot_recording_path_input) + _shot_recording_browse_button = SettingsPanelStyles.make_button("Browse", false) + _shot_recording_browse_button.name = "ShotRecordingBrowseButton" + row.add_child(_shot_recording_browse_button) + content.add_child(row) + _shot_recording_helper = SettingsPanelStyles.make_helper_label("Not recording") + content.add_child(_shot_recording_helper) + + +func _build_lmonitors_tab() -> void: + var lmonitors_content := SettingsPanelStyles.make_scroll_tab(_tabs, "LMonitor") + _lmonitors_tab_scroll = lmonitors_content.get_parent() as ScrollContainer + + var row := SettingsPanelStyles.make_row_card(lmonitors_content, "LaunchMonitorEnabledCard", "Launch Monitor") + _launch_monitor_enabled_check = CheckBox.new() + _launch_monitor_enabled_check.name = "LaunchMonitorEnabledCheck" + _launch_monitor_enabled_check.text = "Enabled" + SettingsPanelStyles.apply_check_style(_launch_monitor_enabled_check) + row.add_child(_launch_monitor_enabled_check) + row.add_child(SettingsPanelStyles.make_spacer()) + + _launch_monitor_provider_card = SettingsPanelStyles.make_card(lmonitors_content, "LaunchMonitorProviderCard") + row = SettingsPanelStyles.make_card_row(_launch_monitor_provider_card, "Monitor") + _launch_monitor_provider_option = OptionButton.new() + _launch_monitor_provider_option.name = "LaunchMonitorProviderOption" + _launch_monitor_provider_option.custom_minimum_size = Vector2(240, 0) + SettingsPanelStyles.apply_field_style(_launch_monitor_provider_option) + row.add_child(_launch_monitor_provider_option) + row.add_child(SettingsPanelStyles.make_spacer()) + + _square_monitor_card = SettingsPanelStyles.make_card(lmonitors_content, "SquareMonitorCard") + var content := SettingsPanelStyles.make_card_content(_square_monitor_card) + content.add_child(SettingsPanelStyles.make_section_label("Square")) + + row = SettingsPanelStyles.make_labeled_row("Device") + _square_device_option = OptionButton.new() + _square_device_option.name = "SquareDeviceOption" + _square_device_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + SettingsPanelStyles.apply_field_style(_square_device_option) + row.add_child(_square_device_option) + content.add_child(row) + + var action_row := SettingsPanelStyles.make_control_row() + _square_scan_button = SettingsPanelStyles.make_button("Scan", false) + _square_scan_button.name = "SquareScanButton" + action_row.add_child(_square_scan_button) + _square_connect_button = SettingsPanelStyles.make_button("Connect", true) + _square_connect_button.name = "SquareConnectButton" + action_row.add_child(_square_connect_button) + _square_disconnect_button = SettingsPanelStyles.make_button("Disconnect", false) + _square_disconnect_button.name = "SquareDisconnectButton" + action_row.add_child(_square_disconnect_button) + content.add_child(action_row) + + row = SettingsPanelStyles.make_labeled_row("Club") + _square_club_option = OptionButton.new() + _square_club_option.name = "SquareClubOption" + _square_club_option.custom_minimum_size = Vector2(220, 0) + SettingsPanelStyles.apply_field_style(_square_club_option) + row.add_child(_square_club_option) + row.add_child(SettingsPanelStyles.make_spacer()) + content.add_child(row) + + row = SettingsPanelStyles.make_labeled_row("Handedness") + _square_handedness_option = OptionButton.new() + _square_handedness_option.name = "SquareHandednessOption" + _square_handedness_option.custom_minimum_size = Vector2(220, 0) + SettingsPanelStyles.apply_field_style(_square_handedness_option) + row.add_child(_square_handedness_option) + row.add_child(SettingsPanelStyles.make_spacer()) + content.add_child(row) + + _pitrac_card = SettingsPanelStyles.make_card(lmonitors_content, "PiTracCard") + content = SettingsPanelStyles.make_card_content(_pitrac_card) + content.add_child(SettingsPanelStyles.make_section_label("PiTrac")) + row = SettingsPanelStyles.make_labeled_row("TCP Port") + _tcp_port_value = SpinBox.new() + _tcp_port_value.name = "TcpPortValue" + _tcp_port_value.custom_minimum_size = Vector2(180, 0) + _tcp_port_value.alignment = HORIZONTAL_ALIGNMENT_CENTER + SettingsPanelStyles.apply_field_style(_tcp_port_value) + row.add_child(_tcp_port_value) + row.add_child(SettingsPanelStyles.make_spacer()) + content.add_child(row) + content.add_child(SettingsPanelStyles.make_helper_label("PiTrac TCP listening port.")) + + +func _build_panels_tab() -> void: + var panels := VBoxContainer.new() + panels.name = "Panels" + panels.add_theme_constant_override("separation", 10) + _tabs.add_child(panels) + + var top_spacer := Control.new() + top_spacer.custom_minimum_size = Vector2(0, 8) + panels.add_child(top_spacer) + + _panels_empty_label = Label.new() + _panels_empty_label.name = "PanelsEmptyLabel" + _panels_empty_label.text = "No HUD panels are available in this screen." + _panels_empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _panels_empty_label.add_theme_color_override("font_color", Color(0.82, 0.88, 0.94, 0.92)) + _panels_empty_label.add_theme_font_size_override("font_size", 16) + panels.add_child(_panels_empty_label) + + +func _configure_controls() -> void: + _populate_resolution_options() + _populate_range_default_club_options() + _populate_launch_monitor_provider_options() + _populate_square_club_options() + _populate_square_handedness_options() + _refresh_square_devices() + + _camera_distance_slider.min_value = CAMERA_DISTANCE_MIN_FEET + _camera_distance_slider.max_value = CAMERA_DISTANCE_MAX_FEET + _camera_distance_slider.step = 0.1 + _camera_distance_value.min_value = CAMERA_DISTANCE_MIN_FEET + _camera_distance_value.max_value = CAMERA_DISTANCE_MAX_FEET + _camera_distance_value.step = 0.1 + + _camera_delay_slider.min_value = 0.0 + _camera_delay_slider.max_value = 5.0 + _camera_delay_slider.step = 0.05 + _camera_delay_value.min_value = 0.0 + _camera_delay_value.max_value = 5.0 + _camera_delay_value.step = 0.05 + + var setting: Setting = GlobalSettingsManager.game_settings.shot_tracer_count + var tracer_min := int(setting.min_value) + var tracer_max := int(setting.max_value) + + _tracer_history_slider.min_value = tracer_min + _tracer_history_slider.max_value = tracer_max + _tracer_history_slider.step = 1.0 + _tracer_history_value.min_value = tracer_min + _tracer_history_value.max_value = tracer_max + _tracer_history_value.step = 1.0 + + _tcp_port_value.min_value = 1 + _tcp_port_value.max_value = 65535 + _tcp_port_value.step = 1 + + _apply_panel_toggle_icons(_test_shots_check) + _apply_panel_toggle_icons(_fullscreen_check) + _apply_panel_toggle_icons(_launch_monitor_enabled_check) + _apply_panel_toggle_icons(_shot_recording_check) + _apply_tracer_history_visibility() + _apply_range_default_club_visibility() + _apply_launch_monitor_visibility() + + +func _connect_control_signals() -> void: + _root_control.resized.connect(_on_panel_layout_changed) + _panel.resized.connect(_on_panel_layout_changed) + _main_menu_button.pressed.connect(_on_main_menu_pressed) + _save_button.pressed.connect(_on_save_pressed) + _close_button.pressed.connect(hide_panel) + _player_name_input.text_submitted.connect(_commit_player_name) + _player_name_input.focus_exited.connect(_on_player_name_focus_exited) + _range_default_club_option.item_selected.connect(_on_range_default_club_selected) + _test_shots_check.toggled.connect(_on_test_shots_toggled) + _resolution_option.item_selected.connect(_on_resolution_selected) + _fullscreen_check.toggled.connect(_on_fullscreen_toggled) + _camera_distance_slider.value_changed.connect(_on_camera_distance_slider_changed) + _camera_distance_value.value_changed.connect(_on_camera_distance_value_changed) + _camera_delay_slider.value_changed.connect(_on_camera_delay_slider_changed) + _camera_delay_value.value_changed.connect(_on_camera_delay_value_changed) + _tracer_history_slider.value_changed.connect(_on_tracer_history_slider_changed) + _tracer_history_value.value_changed.connect(_on_tracer_history_value_changed) + _launch_monitor_enabled_check.toggled.connect(_on_launch_monitor_enabled_toggled) + _launch_monitor_provider_option.item_selected.connect(_on_launch_monitor_provider_selected) + _square_scan_button.pressed.connect(_on_square_scan_pressed) + _square_connect_button.pressed.connect(_on_square_connect_pressed) + _square_disconnect_button.pressed.connect(_on_square_disconnect_pressed) + _square_club_option.item_selected.connect(_on_square_club_selected) + _square_handedness_option.item_selected.connect(_on_square_handedness_selected) + _tcp_port_value.value_changed.connect(_on_tcp_port_value_changed) + _shot_recording_check.toggled.connect(_on_shot_recording_toggled) + _shot_recording_browse_button.pressed.connect(_on_shot_recording_browse_pressed) + _shot_recording_file_dialog.dir_selected.connect(_on_shot_recording_dir_selected) + + +func _connect_setting_signals() -> void: + var callback := Callable(self, "_on_any_setting_changed") + for setting: Setting in GlobalSettingsManager.app_settings.settings.values(): + if not setting.setting_changed.is_connected(callback): + setting.setting_changed.connect(callback) + + if not GlobalSettingsManager.game_settings.shot_tracer_count.setting_changed.is_connected(callback): + GlobalSettingsManager.game_settings.shot_tracer_count.setting_changed.connect(callback) + + var device_callback := Callable(self, "_on_square_device_discovered") + if not LaunchMonitorManager.device_discovered.is_connected(device_callback): + LaunchMonitorManager.device_discovered.connect(device_callback) + + +func _disconnect_setting_signals() -> void: + var callback := Callable(self, "_on_any_setting_changed") + for setting: Setting in GlobalSettingsManager.app_settings.settings.values(): + if setting.setting_changed.is_connected(callback): + setting.setting_changed.disconnect(callback) + + if GlobalSettingsManager.game_settings.shot_tracer_count.setting_changed.is_connected(callback): + GlobalSettingsManager.game_settings.shot_tracer_count.setting_changed.disconnect(callback) + + var device_callback := Callable(self, "_on_square_device_discovered") + if LaunchMonitorManager.device_discovered.is_connected(device_callback): + LaunchMonitorManager.device_discovered.disconnect(device_callback) + + +func _refresh_controls_from_settings() -> void: + _player_name_input.text = _sanitize_player_name(str(GlobalSettingsManager.app_settings.player_name.value)) + _test_shots_check.set_pressed_no_signal(bool(GlobalSettingsManager.app_settings.test_shots_enabled.value)) + + var preset := str(GlobalSettingsManager.app_settings.display_resolution_preset.value).strip_edges() + if preset == "": + preset = FALLBACK_RESOLUTION_PRESET + _select_or_add_resolution_preset(preset) + _fullscreen_check.set_pressed_no_signal(bool(GlobalSettingsManager.app_settings.display_fullscreen.value)) + + var camera_distance_units := float(GlobalSettingsManager.app_settings.camera_orbit_distance.value) + var camera_distance_feet := _units_to_feet(camera_distance_units) + _camera_distance_slider.set_value_no_signal(camera_distance_feet) + _camera_distance_value.set_value_no_signal(camera_distance_feet) + _camera_distance_helper.text = "Distance from ball: %d ft" % int(round(camera_distance_feet)) + + var camera_delay := float(GlobalSettingsManager.app_settings.camera_follow_delay_seconds.value) + _camera_delay_slider.set_value_no_signal(camera_delay) + _camera_delay_value.set_value_no_signal(camera_delay) + _camera_delay_helper.text = "Follow starts after %.2f seconds" % camera_delay + + _launch_monitor_enabled_check.set_pressed_no_signal(bool(GlobalSettingsManager.app_settings.launch_monitor_enabled.value)) + _select_option_by_metadata(_launch_monitor_provider_option, _get_selected_launch_monitor_provider()) + _tcp_port_value.set_value_no_signal(int(GlobalSettingsManager.app_settings.tcp_port.value)) + _shot_recording_check.set_pressed_no_signal(bool(GlobalSettingsManager.app_settings.shot_recording_enabled.value)) + _shot_recording_path_input.text = str(GlobalSettingsManager.app_settings.shot_recording_path.value) + _update_shot_recording_helper() + + var default_club := RangeClubCatalog.normalize_label(str(GlobalSettingsManager.app_settings.range_default_club.value)) + _select_range_default_club(default_club) + + var tracer_count := int(round(float(GlobalSettingsManager.game_settings.shot_tracer_count.value))) + _tracer_history_slider.set_value_no_signal(tracer_count) + _tracer_history_value.set_value_no_signal(tracer_count) + _update_tracer_history_helper(tracer_count) + + _refresh_square_controls() + _apply_tracer_history_visibility() + _apply_range_default_club_visibility() + _apply_launch_monitor_visibility() + + +func _populate_resolution_options() -> void: + _resolution_option.clear() + for preset: String in AppSettingsDisplayService.PRESETS: + _resolution_option.add_item(preset) + + +func _populate_range_default_club_options() -> void: + _range_default_club_option.clear() + for label: String in RangeClubCatalog.LABELS: + _range_default_club_option.add_item(label) + + +func _populate_launch_monitor_provider_options() -> void: + _launch_monitor_provider_option.clear() + _add_option_with_metadata(_launch_monitor_provider_option, LMONITOR_PROVIDER_SQUARE, LMONITOR_PROVIDER_SQUARE) + _add_option_with_metadata(_launch_monitor_provider_option, LMONITOR_PROVIDER_PITRAC, LMONITOR_PROVIDER_PITRAC) + + +func _populate_square_club_options() -> void: + _square_club_option.clear() + for club_name: String in SquareClubCatalog.labels(): + _add_option_with_metadata(_square_club_option, club_name, SquareClubCatalog.code_for(club_name)) + + +func _populate_square_handedness_options() -> void: + _square_handedness_option.clear() + _square_handedness_option.add_item("Right", 0) + _square_handedness_option.add_item("Left", 1) + + +func _add_option_with_metadata(option: OptionButton, label: String, metadata: Variant) -> void: + var index := option.item_count + option.add_item(label) + option.set_item_metadata(index, metadata) + + +func _select_or_add_resolution_preset(preset: String) -> void: + var selected_index := -1 + for index in range(_resolution_option.item_count): + if _resolution_option.get_item_text(index) == preset: + selected_index = index + break + + if selected_index < 0: + _resolution_option.add_item(preset) + selected_index = _resolution_option.item_count - 1 + + _resolution_option.select(selected_index) + + +func _select_range_default_club(club_label: String) -> void: + var selected_index := 0 + for index in range(_range_default_club_option.item_count): + if _range_default_club_option.get_item_text(index) == club_label: + selected_index = index + break + + _range_default_club_option.select(selected_index) + + +func _select_option_by_metadata(option: OptionButton, metadata: Variant) -> void: + for index in range(option.item_count): + if str(option.get_item_metadata(index)) == str(metadata): + option.select(index) + return + + if option.item_count > 0: + option.select(0) + + +func _select_option_by_item_id(option: OptionButton, item_id: int) -> void: + var index := option.get_item_index(item_id) + if index >= 0: + option.select(index) + elif option.item_count > 0: + option.select(0) + + +func _on_any_setting_changed(_value: Variant) -> void: + if not visible: + return + _refresh_controls_from_settings() + + +func _on_save_pressed() -> void: + GlobalSettingsManager.save_app_settings() + hide_panel() + + +func _on_main_menu_pressed() -> void: + GlobalSettingsManager.save_app_settings() + main_menu_requested.emit() + hide_panel() + + +func _on_backdrop_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + hide_panel() + + +func _on_panel_layout_changed() -> void: + _sync_panel_shadow_to_panel() + + +func _on_player_name_focus_exited() -> void: + _commit_player_name(_player_name_input.text) + + +func _commit_player_name(input: String) -> void: + GlobalSettingsManager.app_settings.player_name.set_value(_sanitize_player_name(input)) + + +func _on_range_default_club_selected(index: int) -> void: + var safe_index: int = int(clamp(index, 0, _range_default_club_option.item_count - 1)) + var club := RangeClubCatalog.normalize_label(_range_default_club_option.get_item_text(safe_index)) + GlobalSettingsManager.app_settings.range_default_club.set_value(club) + + +func _on_test_shots_toggled(enabled: bool) -> void: + GlobalSettingsManager.app_settings.test_shots_enabled.set_value(enabled) + + +func _on_resolution_selected(index: int) -> void: + GlobalSettingsManager.app_settings.display_resolution_preset.set_value(_resolution_option.get_item_text(index)) + + +func _on_fullscreen_toggled(is_pressed: bool) -> void: + GlobalSettingsManager.app_settings.display_fullscreen.set_value(is_pressed) + + +func _on_camera_distance_slider_changed(value: float) -> void: + GlobalSettingsManager.app_settings.camera_orbit_distance.set_value(_feet_to_units(value)) + + +func _on_camera_distance_value_changed(value: float) -> void: + GlobalSettingsManager.app_settings.camera_orbit_distance.set_value(_feet_to_units(value)) + + +func _on_camera_delay_slider_changed(value: float) -> void: + GlobalSettingsManager.app_settings.camera_follow_delay_seconds.set_value(value) + + +func _on_camera_delay_value_changed(value: float) -> void: + GlobalSettingsManager.app_settings.camera_follow_delay_seconds.set_value(value) + + +func _on_tracer_history_slider_changed(value: float) -> void: + GlobalSettingsManager.game_settings.shot_tracer_count.set_value(int(round(value))) + + +func _on_tracer_history_value_changed(value: float) -> void: + GlobalSettingsManager.game_settings.shot_tracer_count.set_value(int(round(value))) + + +func _on_launch_monitor_enabled_toggled(enabled: bool) -> void: + GlobalSettingsManager.app_settings.launch_monitor_enabled.set_value(enabled) + + +func _on_launch_monitor_provider_selected(index: int) -> void: + GlobalSettingsManager.app_settings.launch_monitor_provider.set_value(str(_launch_monitor_provider_option.get_item_metadata(index))) + + +func _on_square_scan_pressed() -> void: + LaunchMonitorManager.start_scan() + + +func _on_square_connect_pressed() -> void: + if _square_device_option.item_count == 0: + return + + var selected_index := int(clamp(_square_device_option.selected, 0, _square_device_option.item_count - 1)) + var device_id := str(_square_device_option.get_item_metadata(selected_index)) + if device_id == "": + return + + LaunchMonitorManager.connect_to_device(device_id) + + +func _on_square_disconnect_pressed() -> void: + LaunchMonitorManager.disconnect_device() + + +func _on_square_club_selected(index: int) -> void: + LaunchMonitorManager.set_club_code(str(_square_club_option.get_item_metadata(index))) + + +func _on_square_handedness_selected(index: int) -> void: + LaunchMonitorManager.set_handedness(_square_handedness_option.get_item_id(index)) + + +func _on_square_device_discovered(_device_id: String, _name: String, _rssi: int) -> void: + _refresh_square_devices() + + +func _on_tcp_port_value_changed(value: float) -> void: + GlobalSettingsManager.app_settings.tcp_port.set_value(int(round(value))) + + +func _on_shot_recording_toggled(enabled: bool) -> void: + GlobalSettingsManager.app_settings.shot_recording_enabled.set_value(enabled) + + +func _on_shot_recording_browse_pressed() -> void: + _shot_recording_file_dialog.popup() + + +func _on_shot_recording_dir_selected(dir: String) -> void: + GlobalSettingsManager.app_settings.shot_recording_path.set_value(dir) + + +func _update_shot_recording_helper() -> void: + _shot_recording_helper.text = "Not recording" + + +func _update_tracer_history_helper(tracer_count: int) -> void: + if tracer_count <= 0: + _tracer_history_helper.text = "No tracer history retained." + elif tracer_count == 1: + _tracer_history_helper.text = "Retains the latest tracer in Range." + else: + _tracer_history_helper.text = "Retains the latest %d tracers in Range." % tracer_count + + +func _refresh_square_controls() -> void: + _select_option_by_metadata(_square_club_option, LaunchMonitorManager.get_square_club_code()) + _select_option_by_item_id(_square_handedness_option, LaunchMonitorManager.get_square_handedness()) + _refresh_square_devices() + + +func _refresh_square_devices() -> void: + _square_device_option.clear() + var selected_device := LaunchMonitorManager.get_selected_device_id() + for device_id in LaunchMonitorManager.devices.keys(): + var device = LaunchMonitorManager.devices[device_id] + var label := str(device.get("name", "Square")) + if int(device.get("rssi", 0)) != 0: + label = "%s (%d)" % [label, int(device.get("rssi", 0))] + var index := _square_device_option.item_count + _square_device_option.add_item(label) + _square_device_option.set_item_metadata(index, device_id) + if str(device_id) == selected_device: + _square_device_option.select(index) + + +func _apply_launch_monitor_visibility() -> void: + var enabled := bool(GlobalSettingsManager.app_settings.launch_monitor_enabled.value) + var provider := _get_selected_launch_monitor_provider() + + _launch_monitor_provider_card.visible = enabled + _square_monitor_card.visible = enabled and provider == LMONITOR_PROVIDER_SQUARE + _pitrac_card.visible = enabled and provider == LMONITOR_PROVIDER_PITRAC + + call_deferred("_sync_panel_shadow_to_panel") + + +func _get_selected_launch_monitor_provider() -> String: + return AppSettings.normalize_provider(str(GlobalSettingsManager.app_settings.launch_monitor_provider.value)) + + +func _set_active_tab(tab: int) -> void: + if _tabs.get_tab_count() == 0: + return + + var tab_index: int = int(clamp(tab, 0, _tabs.get_tab_count() - 1)) + _tabs.current_tab = tab_index + _reset_tab_scroll(tab_index) + + +func _reset_tab_scroll(tab: int) -> void: + var scroll: ScrollContainer + match tab: + SettingsTab.PLAYER: + scroll = _player_tab_scroll + SettingsTab.DISPLAY: + scroll = _display_tab_scroll + SettingsTab.GAME: + scroll = _game_tab_scroll + SettingsTab.LMONITORS: + scroll = _lmonitors_tab_scroll + _: + return + + scroll.scroll_vertical = 0 + scroll.scroll_horizontal = 0 + + +func _apply_tracer_history_visibility() -> void: + _tracer_history_card.visible = _show_tracer_history_setting + call_deferred("_sync_panel_shadow_to_panel") + + +func _apply_range_default_club_visibility() -> void: + _range_default_club_card.visible = _show_range_default_club_setting + call_deferred("_sync_panel_shadow_to_panel") + + +func _sanitize_player_name(value: String) -> String: + var trimmed := value.strip_edges() + if trimmed == "": + trimmed = FALLBACK_PLAYER_NAME + + if trimmed.length() > 24: + return trimmed.substr(0, 24) + + return trimmed + + +func _units_to_feet(units: float) -> float: + return units * FEET_PER_CAMERA_DISTANCE + + +func _feet_to_units(feet: float) -> float: + return feet / FEET_PER_CAMERA_DISTANCE + + +func _sync_panel_shadow_to_panel() -> void: + _panel_shadow.position = _panel.position - Vector2(PANEL_SHADOW_PADDING_X, PANEL_SHADOW_PADDING_Y) + _panel_shadow.size = _panel.size + Vector2(PANEL_SHADOW_PADDING_X * 2.0, PANEL_SHADOW_PADDING_Y * 2.0) + + +func _create_panel_toggle_icons() -> void: + _panel_toggle_checked_icon = SettingsPanelStyles.build_panel_toggle_icon(true) + _panel_toggle_unchecked_icon = SettingsPanelStyles.build_panel_toggle_icon(false) + + +func _apply_panel_toggle_icons(toggle: CheckBox) -> void: + SettingsPanelStyles.apply_panel_toggle_icons(toggle, _panel_toggle_checked_icon, _panel_toggle_unchecked_icon) diff --git a/UI/Settings/settings_panel.gd.uid b/UI/Settings/settings_panel.gd.uid new file mode 100644 index 0000000..6c5de7a --- /dev/null +++ b/UI/Settings/settings_panel.gd.uid @@ -0,0 +1 @@ +uid://b4xwv43tnntov diff --git a/UI/Settings/settings_panel.tscn b/UI/Settings/settings_panel.tscn new file mode 100644 index 0000000..543b9b3 --- /dev/null +++ b/UI/Settings/settings_panel.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3 uid="uid://d2ubcc3otyc5r"] + +[ext_resource type="Script" path="res://UI/Settings/settings_panel.gd" id="1_settings_panel"] + +[node name="SettingsPanel" type="CanvasLayer"] +visible = false +script = ExtResource("1_settings_panel") diff --git a/UI/Settings/settings_panel_styles.gd b/UI/Settings/settings_panel_styles.gd new file mode 100644 index 0000000..b97e261 --- /dev/null +++ b/UI/Settings/settings_panel_styles.gd @@ -0,0 +1,267 @@ +class_name SettingsPanelStyles +extends RefCounted + +const TEXT_PRIMARY := Color(0.91, 0.95, 1, 1) +const TEXT_BUTTON := Color(0.96, 0.98, 1, 1) +const TEXT_HELPER := Color(0.72, 0.80, 0.88, 0.96) +const PLACEHOLDER := Color(0.82, 0.88, 0.94, 0.60) +const CARD_BG := Color(0.063, 0.086, 0.125, 0.72) +const CARD_BORDER := Color(1, 1, 1, 0.20) +const FIELD_BG := Color(0.039, 0.071, 0.110, 0.96) +const FIELD_BG_FOCUS := Color(0.039, 0.071, 0.110, 0.98) +const FIELD_BORDER := Color(1, 1, 1, 0.20) +const FIELD_BORDER_FOCUS := Color(0.337, 0.698, 0.937, 0.80) +const PRIMARY_BG := Color(0.129, 0.529, 0.824, 0.96) +const PRIMARY_BG_HOVER := Color(0.188, 0.616, 0.902, 0.98) +const PRIMARY_BORDER := Color(1, 1, 1, 0.35) +const PRIMARY_BORDER_HOVER := Color(1, 1, 1, 0.45) +const SECONDARY_BG := Color(0.063, 0.086, 0.125, 0.95) +const SECONDARY_BG_HOVER := Color(0.090, 0.133, 0.192, 0.98) +const SECONDARY_BORDER := Color(0.337, 0.698, 0.937, 0.60) +const SECONDARY_BORDER_HOVER := Color(0.337, 0.698, 0.937, 0.80) + + +static func make_scroll_tab(tabs: TabContainer, tab_name: String) -> VBoxContainer: + var scroll := ScrollContainer.new() + scroll.name = tab_name + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + tabs.add_child(scroll) + + var content := VBoxContainer.new() + content.name = "%sContent" % tab_name + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content.add_theme_constant_override("separation", 14) + scroll.add_child(content) + return content + + +static func make_card(parent: Container, card_name: String) -> PanelContainer: + var card := PanelContainer.new() + card.name = card_name + card.add_theme_stylebox_override("panel", make_style_box(CARD_BG, CARD_BORDER, 1, 8)) + parent.add_child(card) + return card + + +static func make_card_content(card: PanelContainer) -> VBoxContainer: + var margin := MarginContainer.new() + margin.name = "%sMargin" % card.name + margin.add_theme_constant_override("margin_left", 16) + margin.add_theme_constant_override("margin_top", 14) + margin.add_theme_constant_override("margin_right", 16) + margin.add_theme_constant_override("margin_bottom", 14) + card.add_child(margin) + + var content := VBoxContainer.new() + content.name = "%sContent" % card.name + content.add_theme_constant_override("separation", 10) + margin.add_child(content) + return content + + +static func make_row_card(parent: Container, card_name: String, label_text: String) -> HBoxContainer: + var card := make_card(parent, card_name) + return make_card_row(card, label_text) + + +static func make_card_row(card: PanelContainer, label_text: String) -> HBoxContainer: + var margin := MarginContainer.new() + margin.name = "%sMargin" % card.name + margin.add_theme_constant_override("margin_left", 16) + margin.add_theme_constant_override("margin_top", 14) + margin.add_theme_constant_override("margin_right", 16) + margin.add_theme_constant_override("margin_bottom", 14) + card.add_child(margin) + + var row := make_labeled_row(label_text) + margin.add_child(row) + return row + + +static func make_labeled_row(label_text: String) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 16) + + var label := Label.new() + label.text = label_text + label.custom_minimum_size = Vector2(210, 0) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_color_override("font_color", TEXT_PRIMARY) + label.add_theme_font_size_override("font_size", 20) + row.add_child(label) + return row + + +static func make_control_row() -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + return row + + +static func make_section_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_color_override("font_color", TEXT_PRIMARY) + label.add_theme_font_size_override("font_size", 20) + return label + + +static func make_helper_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_color_override("font_color", TEXT_HELPER) + label.add_theme_font_size_override("font_size", 15) + return label + + +static func make_spacer() -> Control: + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + return spacer + + +static func make_button(text: String, is_primary: bool) -> Button: + var button := Button.new() + button.text = text + button.add_theme_font_size_override("font_size", 18) + button.add_theme_color_override("font_color", TEXT_BUTTON) + button.add_theme_color_override("font_hover_color", TEXT_BUTTON) + button.add_theme_color_override("font_pressed_color", TEXT_BUTTON) + button.add_theme_color_override("font_focus_color", TEXT_BUTTON) + + var normal_bg := PRIMARY_BG if is_primary else SECONDARY_BG + var hover_bg := PRIMARY_BG_HOVER if is_primary else SECONDARY_BG_HOVER + var normal_border := PRIMARY_BORDER if is_primary else SECONDARY_BORDER + var hover_border := PRIMARY_BORDER_HOVER if is_primary else SECONDARY_BORDER_HOVER + + button.add_theme_stylebox_override("normal", make_button_style(normal_bg, normal_border)) + button.add_theme_stylebox_override("hover", make_button_style(hover_bg, hover_border)) + button.add_theme_stylebox_override("pressed", make_button_style(normal_bg, normal_border)) + button.add_theme_stylebox_override("focus", make_button_style(hover_bg, hover_border)) + button.add_theme_stylebox_override("disabled", make_button_style(normal_bg, normal_border)) + return button + + +static func apply_field_style(control: Control) -> void: + control.add_theme_stylebox_override("normal", make_field_style(FIELD_BG, FIELD_BORDER)) + control.add_theme_stylebox_override("hover", make_field_style(FIELD_BG_FOCUS, FIELD_BORDER_FOCUS)) + control.add_theme_stylebox_override("pressed", make_field_style(FIELD_BG, FIELD_BORDER)) + control.add_theme_stylebox_override("focus", make_field_style(FIELD_BG_FOCUS, FIELD_BORDER_FOCUS)) + control.add_theme_stylebox_override("read_only", make_field_style(FIELD_BG, FIELD_BORDER)) + control.add_theme_stylebox_override("disabled", make_field_style(FIELD_BG, FIELD_BORDER)) + control.add_theme_color_override("font_color", TEXT_BUTTON) + control.add_theme_color_override("font_hover_color", TEXT_BUTTON) + control.add_theme_color_override("font_pressed_color", TEXT_BUTTON) + control.add_theme_color_override("font_focus_color", TEXT_BUTTON) + control.add_theme_color_override("font_placeholder_color", PLACEHOLDER) + control.add_theme_font_size_override("font_size", 18) + + +static func apply_check_style(check_box: CheckBox) -> void: + check_box.add_theme_color_override("font_color", TEXT_PRIMARY) + check_box.add_theme_font_size_override("font_size", 20) + + +static func make_style_box( + bg_color: Color, + border_color: Color, + border_width: int, + corner_radius: int, + all_borders: bool = true +) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = bg_color + style.border_color = border_color + if all_borders: + style.border_width_left = border_width + style.border_width_top = border_width + style.border_width_right = border_width + style.border_width_bottom = border_width + else: + style.border_width_bottom = border_width + style.corner_radius_top_left = corner_radius + style.corner_radius_top_right = corner_radius + style.corner_radius_bottom_right = corner_radius + style.corner_radius_bottom_left = corner_radius + return style + + +static func make_tab_style(bg_color: Color, top_radius: int, bottom_radius: int) -> StyleBoxFlat: + var style := make_style_box(bg_color, Color(1, 1, 1, 0.25), 1, top_radius) + style.corner_radius_bottom_left = bottom_radius + style.corner_radius_bottom_right = bottom_radius + style.content_margin_left = 20.0 + style.content_margin_top = 10.0 + style.content_margin_right = 20.0 + style.content_margin_bottom = 8.0 + return style + + +static func make_button_style(bg_color: Color, border_color: Color) -> StyleBoxFlat: + var style := make_style_box(bg_color, border_color, 1, 7) + style.content_margin_left = 16.0 + style.content_margin_top = 7.0 + style.content_margin_right = 16.0 + style.content_margin_bottom = 7.0 + return style + + +static func make_field_style(bg_color: Color, border_color: Color) -> StyleBoxFlat: + var style := make_style_box(bg_color, border_color, 1, 6) + style.content_margin_left = 12.0 + style.content_margin_top = 8.0 + style.content_margin_right = 12.0 + style.content_margin_bottom = 8.0 + return style + + +static func set_centered_rect(control: Control, width: float, height: float) -> void: + control.anchor_left = 0.5 + control.anchor_top = 0.5 + control.anchor_right = 0.5 + control.anchor_bottom = 0.5 + control.offset_left = -width * 0.5 + control.offset_top = -height * 0.5 + control.offset_right = width * 0.5 + control.offset_bottom = height * 0.5 + + +static func build_panel_toggle_icon(is_checked: bool) -> Texture2D: + const SIZE := 16 + var image := Image.create_empty(SIZE, SIZE, false, Image.FORMAT_RGBA8) + var fill := Color(0.070, 0.149, 0.243, 1.0) + var border := Color(0.820, 0.900, 0.980, 1.0) + var check := Color(0.929, 0.969, 1.0, 1.0) + image.fill(fill) + + for index in range(SIZE): + image.set_pixel(index, 0, border) + image.set_pixel(index, SIZE - 1, border) + image.set_pixel(0, index, border) + image.set_pixel(SIZE - 1, index, border) + + if is_checked: + for index in range(4): + image.set_pixel(3 + index, 8 + index, check) + image.set_pixel(4 + index, 8 + index, check) + + for index in range(6): + image.set_pixel(6 + index, 10 - index, check) + image.set_pixel(6 + index, 9 - index, check) + + return ImageTexture.create_from_image(image) + + +static func apply_panel_toggle_icons(toggle: CheckBox, checked: Texture2D, unchecked: Texture2D) -> void: + if toggle == null: + return + if checked != null: + toggle.add_theme_icon_override("checked", checked) + toggle.add_theme_icon_override("checked_disabled", checked) + toggle.add_theme_icon_override("radio_checked", checked) + if unchecked != null: + toggle.add_theme_icon_override("unchecked", unchecked) + toggle.add_theme_icon_override("unchecked_disabled", unchecked) + toggle.add_theme_icon_override("radio_unchecked", unchecked) diff --git a/UI/Settings/settings_panel_styles.gd.uid b/UI/Settings/settings_panel_styles.gd.uid new file mode 100644 index 0000000..cbda1cc --- /dev/null +++ b/UI/Settings/settings_panel_styles.gd.uid @@ -0,0 +1 @@ +uid://b6ug8v43bld0y diff --git a/UI/grid_canvas.gd b/UI/grid_canvas.gd index 2c9e6a0..87cd90d 100644 --- a/UI/grid_canvas.gd +++ b/UI/grid_canvas.gd @@ -25,7 +25,7 @@ func _draw(): func _ready(): load_layout() - GlobalSettings.range_settings.range_units.setting_changed.connect(set_units) + GlobalSettingsManager.range_settings.range_units.setting_changed.connect(set_units) func snap_to_grid(panel: Control): var global_snap_x = round((panel.global_position.x - GRID_ORIGIN.x) / GRID_SIZE.x) * GRID_SIZE.x + GRID_ORIGIN.x diff --git a/UI/range_ui.gd b/UI/range_ui.gd index 9f2ef09..6f7b433 100644 --- a/UI/range_ui.gd +++ b/UI/range_ui.gd @@ -5,18 +5,19 @@ signal set_session(dir: String, player_name: String) signal hit_shot(data) -# Called when the node enters the scene tree for the first time. func _ready() -> void: - GlobalSettings.range_settings.shot_injector_enabled.setting_changed.connect(toggle_shot_injector) + GlobalSettingsManager.app_settings.test_shots_enabled.setting_changed.connect(toggle_shot_injector) + toggle_shot_injector(GlobalSettingsManager.app_settings.test_shots_enabled.value) -# Called every frame. 'delta' is the elapsed time since the previous frame. -func _process(_delta: float) -> void: - pass +func _exit_tree() -> void: + var setting := GlobalSettingsManager.app_settings.test_shots_enabled + if setting.setting_changed.is_connected(toggle_shot_injector): + setting.setting_changed.disconnect(toggle_shot_injector) func set_data(data: Dictionary) -> void: - if GlobalSettings.range_settings.range_units.value == PhysicsEnums.Units.IMPERIAL: + if GlobalSettingsManager.range_settings.range_units.value == PhysicsEnums.Units.IMPERIAL: $GridCanvas/Distance.set_data(data["Distance"]) $GridCanvas/Carry.set_data(data["Carry"]) $GridCanvas/Side.set_data(data["Offline"]) @@ -80,7 +81,6 @@ func _on_session_recorder_recording_state(value: bool) -> void: func _on_session_pop_up_dir_selected(dir: String, player_name: String) -> void: $HBoxContainer/PlayerName.text = player_name emit_signal("set_session", dir, player_name) - pass # Replace with function body. @@ -92,8 +92,8 @@ func _on_session_recorder_set_session(user: String, dir: String) -> void: func _on_shot_injector_inject(data: Variant) -> void: emit_signal("hit_shot", data) -func toggle_shot_injector(value) -> void: - $ShotInjector.visible = value +func toggle_shot_injector(value: Variant) -> void: + $ShotInjector.visible = bool(value) func _on_toggle_settings_requested() -> void: diff --git a/Utils/Settings/app_settings.gd b/Utils/Settings/app_settings.gd new file mode 100644 index 0000000..7f054f2 --- /dev/null +++ b/Utils/Settings/app_settings.gd @@ -0,0 +1,62 @@ +class_name AppSettings +extends SettingCollector + +const DEFAULT_PLAYER_NAME := "Tiger" +const DEFAULT_TEST_SHOTS_ENABLED := true +const DEFAULT_RESOLUTION_PRESET := "1728x972" +const FEET_PER_CAMERA_DISTANCE := 3.28084 +const DEFAULT_CAMERA_ORBIT_DISTANCE := 7.0 / FEET_PER_CAMERA_DISTANCE +const DEFAULT_CAMERA_FOLLOW_DELAY_SECONDS := 3.0 +const DEFAULT_TCP_PORT := 49152 +const DEFAULT_SHOT_RECORDING_ENABLED := false +const DEFAULT_SHOT_RECORDING_PATH := "" +const DEFAULT_RANGE_DEFAULT_CLUB := "DRIVER" +const LAUNCH_MONITOR_PROVIDER_PITRAC := "PiTrac" +const LAUNCH_MONITOR_PROVIDER_SQUARE := "Square" +const LAUNCH_MONITOR_PROVIDERS := [LAUNCH_MONITOR_PROVIDER_PITRAC, LAUNCH_MONITOR_PROVIDER_SQUARE] + + +static func is_valid_provider(provider: String) -> bool: + return provider in LAUNCH_MONITOR_PROVIDERS + + +static func normalize_provider(provider: String) -> String: + if is_valid_provider(provider): + return provider + return LAUNCH_MONITOR_PROVIDER_PITRAC + +var player_name: Setting = Setting.new(DEFAULT_PLAYER_NAME) +var test_shots_enabled: Setting = Setting.new(DEFAULT_TEST_SHOTS_ENABLED) +var display_resolution_preset: Setting = Setting.new(DEFAULT_RESOLUTION_PRESET) +var display_fullscreen: Setting = Setting.new(false) +var camera_orbit_distance: Setting = Setting.new(DEFAULT_CAMERA_ORBIT_DISTANCE, 1.0, 8.0) +var camera_follow_delay_seconds: Setting = Setting.new(DEFAULT_CAMERA_FOLLOW_DELAY_SECONDS, 0.0, 5.0) +var tcp_port: Setting = Setting.new(DEFAULT_TCP_PORT, 1, 65535) +var shot_recording_enabled: Setting = Setting.new(DEFAULT_SHOT_RECORDING_ENABLED) +var shot_recording_path: Setting = Setting.new(DEFAULT_SHOT_RECORDING_PATH) +var range_default_club: Setting = Setting.new(DEFAULT_RANGE_DEFAULT_CLUB) +var launch_monitor_enabled: Setting = Setting.new(false) +var launch_monitor_provider: Setting = Setting.new(LAUNCH_MONITOR_PROVIDER_PITRAC) +var square_device_id: Setting = Setting.new("") +var square_club_code: Setting = Setting.new(SquareClubCatalog.DEFAULT_CLUB_CODE) +var square_handedness: Setting = Setting.new(0, 0, 1) + + +func _init() -> void: + settings = { + "player_name": player_name, + "test_shots_enabled": test_shots_enabled, + "display_resolution_preset": display_resolution_preset, + "display_fullscreen": display_fullscreen, + "camera_orbit_distance": camera_orbit_distance, + "camera_follow_delay_seconds": camera_follow_delay_seconds, + "tcp_port": tcp_port, + "shot_recording_enabled": shot_recording_enabled, + "shot_recording_path": shot_recording_path, + "range_default_club": range_default_club, + "launch_monitor_enabled": launch_monitor_enabled, + "launch_monitor_provider": launch_monitor_provider, + "square_device_id": square_device_id, + "square_club_code": square_club_code, + "square_handedness": square_handedness + } diff --git a/Utils/Settings/app_settings.gd.uid b/Utils/Settings/app_settings.gd.uid new file mode 100644 index 0000000..dae8027 --- /dev/null +++ b/Utils/Settings/app_settings.gd.uid @@ -0,0 +1 @@ +uid://cda64no5m787n diff --git a/Utils/Settings/app_settings_display_service.gd b/Utils/Settings/app_settings_display_service.gd new file mode 100644 index 0000000..75f2635 --- /dev/null +++ b/Utils/Settings/app_settings_display_service.gd @@ -0,0 +1,37 @@ +class_name AppSettingsDisplayService +extends RefCounted + +const DEFAULT_SIZE := Vector2i(1728, 972) +const PRESETS := [ + "1024x768", + "1280x720", + "1600x900", + "1728x972", + "1920x1080", + "2560x1080", + "3440x1440" +] + + +static func apply(app_settings: AppSettings, window: Window) -> void: + if app_settings == null or window == null: + return + + window.size = parse_resolution_preset(str(app_settings.display_resolution_preset.value)) + window.mode = Window.MODE_FULLSCREEN if bool(app_settings.display_fullscreen.value) else Window.MODE_WINDOWED + + +static func parse_resolution_preset(preset: String) -> Vector2i: + var parts := preset.strip_edges().to_lower().split("x", false) + if parts.size() != 2: + return DEFAULT_SIZE + + if not parts[0].is_valid_int() or not parts[1].is_valid_int(): + return DEFAULT_SIZE + + var width := int(parts[0]) + var height := int(parts[1]) + if width < 320 or height < 240: + return DEFAULT_SIZE + + return Vector2i(width, height) diff --git a/Utils/Settings/app_settings_display_service.gd.uid b/Utils/Settings/app_settings_display_service.gd.uid new file mode 100644 index 0000000..ecf8c5c --- /dev/null +++ b/Utils/Settings/app_settings_display_service.gd.uid @@ -0,0 +1 @@ +uid://dgdo031vprqqh diff --git a/Utils/Settings/app_settings_persistence_service.gd b/Utils/Settings/app_settings_persistence_service.gd new file mode 100644 index 0000000..33edc41 --- /dev/null +++ b/Utils/Settings/app_settings_persistence_service.gd @@ -0,0 +1,107 @@ +class_name AppSettingsPersistenceService +extends RefCounted + +const SAVE_PATH := "user://app_settings.cfg" +const LEGACY_SQUARE_PATH := "user://square_launch_monitor.cfg" +const SAVE_VERSION := 3 +const OLD_DEFAULT_TCP_PORT := 55000 + + +static func load_into(app_settings: AppSettings) -> void: + if app_settings == null: + return + + var config := ConfigFile.new() + var load_error := config.load(SAVE_PATH) + var save_version := int(config.get_value("meta", "version", 0)) if load_error == OK else 0 + if load_error == OK: + _set_if_present(config, "player", "name", app_settings.player_name) + _set_if_present(config, "player", "test_shots_enabled", app_settings.test_shots_enabled) + _set_if_present(config, "player", "range_default_club", app_settings.range_default_club) + _set_if_present(config, "display", "resolution_preset", app_settings.display_resolution_preset) + _set_if_present(config, "display", "fullscreen", app_settings.display_fullscreen) + _set_if_present(config, "game", "camera_orbit_distance", app_settings.camera_orbit_distance) + _set_if_present(config, "game", "camera_follow_delay_seconds", app_settings.camera_follow_delay_seconds) + _load_tcp_port(config, app_settings.tcp_port) + _set_if_present(config, "game", "shot_recording_enabled", app_settings.shot_recording_enabled) + _set_if_present(config, "game", "shot_recording_path", app_settings.shot_recording_path) + _set_if_present(config, "launch_monitor", "enabled", app_settings.launch_monitor_enabled) + _load_provider(config, app_settings.launch_monitor_provider) + _set_if_present(config, "square", "device_id", app_settings.square_device_id) + _set_if_present(config, "square", "club_code", app_settings.square_club_code) + _set_if_present(config, "square", "handedness", app_settings.square_handedness) + + if save_version < SAVE_VERSION: + _migrate_legacy_square(app_settings) + + +static func save(app_settings: AppSettings) -> void: + if app_settings == null: + return + + var config := ConfigFile.new() + config.set_value("meta", "version", SAVE_VERSION) + config.set_value("player", "name", app_settings.player_name.value) + config.set_value("player", "test_shots_enabled", app_settings.test_shots_enabled.value) + config.set_value("player", "range_default_club", app_settings.range_default_club.value) + config.set_value("display", "resolution_preset", app_settings.display_resolution_preset.value) + config.set_value("display", "fullscreen", app_settings.display_fullscreen.value) + config.set_value("game", "camera_orbit_distance", app_settings.camera_orbit_distance.value) + config.set_value("game", "camera_follow_delay_seconds", app_settings.camera_follow_delay_seconds.value) + config.set_value("game", "tcp_port", app_settings.tcp_port.value) + config.set_value("game", "shot_recording_enabled", app_settings.shot_recording_enabled.value) + config.set_value("game", "shot_recording_path", app_settings.shot_recording_path.value) + config.set_value("launch_monitor", "enabled", app_settings.launch_monitor_enabled.value) + config.set_value("launch_monitor", "provider", app_settings.launch_monitor_provider.value) + config.set_value("square", "device_id", app_settings.square_device_id.value) + config.set_value("square", "club_code", app_settings.square_club_code.value) + config.set_value("square", "handedness", app_settings.square_handedness.value) + + var error := config.save(SAVE_PATH) + if error != OK: + push_error("App settings could not be saved at %s." % SAVE_PATH) + + +static func _migrate_legacy_square(app_settings: AppSettings) -> void: + var legacy := ConfigFile.new() + if legacy.load(LEGACY_SQUARE_PATH) != OK: + return + + if legacy.has_section_key("square", "device_id"): + app_settings.square_device_id.set_value(str(legacy.get_value("square", "device_id", ""))) + if legacy.has_section_key("square", "club_code"): + app_settings.square_club_code.set_value(str(legacy.get_value("square", "club_code", SquareClubCatalog.DEFAULT_CLUB_CODE))) + if legacy.has_section_key("square", "handedness"): + app_settings.square_handedness.set_value(int(legacy.get_value("square", "handedness", 0))) + + +static func _set_if_present(config: ConfigFile, section: String, key: String, setting: Setting) -> void: + if setting == null or not config.has_section_key(section, key): + return + + setting.set_value(config.get_value(section, key)) + + +static func _load_tcp_port(config: ConfigFile, setting: Setting) -> void: + if setting == null or not config.has_section_key("game", "tcp_port"): + return + + var save_version := int(config.get_value("meta", "version", 0)) + var port := int(config.get_value("game", "tcp_port", AppSettings.DEFAULT_TCP_PORT)) + if save_version < SAVE_VERSION and port == OLD_DEFAULT_TCP_PORT: + port = AppSettings.DEFAULT_TCP_PORT + + setting.set_value(port) + + +static func _load_provider(config: ConfigFile, setting: Setting) -> void: + if setting == null or not config.has_section_key("launch_monitor", "provider"): + return + + var raw := str(config.get_value("launch_monitor", "provider", "")) + if not AppSettings.is_valid_provider(raw): + push_warning("Unknown launch_monitor_provider '%s' in %s; falling back to default." % [raw, SAVE_PATH]) + setting.set_value(AppSettings.LAUNCH_MONITOR_PROVIDER_PITRAC) + return + + setting.set_value(raw) diff --git a/Utils/Settings/app_settings_persistence_service.gd.uid b/Utils/Settings/app_settings_persistence_service.gd.uid new file mode 100644 index 0000000..b6f6676 --- /dev/null +++ b/Utils/Settings/app_settings_persistence_service.gd.uid @@ -0,0 +1 @@ +uid://yqje4dsydl66 diff --git a/Utils/Settings/game_settings.gd b/Utils/Settings/game_settings.gd new file mode 100644 index 0000000..a5ef357 --- /dev/null +++ b/Utils/Settings/game_settings.gd @@ -0,0 +1,28 @@ +class_name GameSettings +extends SettingCollector + +var game_units: Setting = Setting.new(PhysicsEnums.Units.IMPERIAL) +var camera_follow_mode: Setting = Setting.new(false) +var auto_ball_reset: Setting = Setting.new(false) +var ball_reset_timer: Setting = Setting.new(3.0, 1.0, 15.0) +var temperature: Setting = Setting.new(75, -40, 120) +var altitude: Setting = Setting.new(0.0, -1000.0, 10000.0) +var drag_scale: Setting = Setting.new(1.0, 0.5, 1.5) +var lift_scale: Setting = Setting.new(1.0, 0.8, 2.0) +var surface_type: Setting = Setting.new(PhysicsEnums.SurfaceType.FAIRWAY) +var shot_tracer_count: Setting = Setting.new(2, 0, 5) + + +func _init() -> void: + settings = { + "game_units": game_units, + "camera_follow_mode": camera_follow_mode, + "auto_ball_reset": auto_ball_reset, + "ball_reset_timer": ball_reset_timer, + "temperature": temperature, + "altitude": altitude, + "drag_scale": drag_scale, + "lift_scale": lift_scale, + "surface_type": surface_type, + "shot_tracer_count": shot_tracer_count + } diff --git a/Utils/Settings/game_settings.gd.uid b/Utils/Settings/game_settings.gd.uid new file mode 100644 index 0000000..0c859bc --- /dev/null +++ b/Utils/Settings/game_settings.gd.uid @@ -0,0 +1 @@ +uid://cj5e3ywox87pn diff --git a/Utils/Settings/global_settings.gd b/Utils/Settings/global_settings.gd index 8d33e45..7e2aaaa 100644 --- a/Utils/Settings/global_settings.gd +++ b/Utils/Settings/global_settings.gd @@ -1,16 +1,74 @@ +class_name GlobalSettings extends Node signal settings_changed -# Range Settings -var range_settings := RangeSettings.new() const OPENFAIRWAY_LOG_LEVEL_INFO := 2 +var range_settings: RangeSettings = RangeSettings.new() +var game_settings: GameSettings = GameSettings.new() +var app_settings: AppSettings = AppSettings.new() + +var _suppress_save := false + func _ready() -> void: PhysicsLogger.SetLevel(OPENFAIRWAY_LOG_LEVEL_INFO) + AppSettingsPersistenceService.load_into(app_settings) + _connect_app_settings_signals() + AppSettingsDisplayService.apply(app_settings, get_window()) + +func _exit_tree() -> void: + _disconnect_app_settings_signals() -func resett_defaults(): + +func reset_all_settings_to_defaults() -> void: + _suppress_save = true range_settings.reset_defaults() + game_settings.reset_defaults() + app_settings.reset_defaults() + _suppress_save = false + save_app_settings() emit_signal("settings_changed") + + +func save_app_settings() -> void: + AppSettingsPersistenceService.save(app_settings) + + +func _connect_app_settings_signals() -> void: + var save_callback := Callable(self, "_on_any_app_setting_changed") + for setting: Setting in app_settings.settings.values(): + if not setting.setting_changed.is_connected(save_callback): + setting.setting_changed.connect(save_callback) + + var display_callback := Callable(self, "_on_display_setting_changed") + if not app_settings.display_resolution_preset.setting_changed.is_connected(display_callback): + app_settings.display_resolution_preset.setting_changed.connect(display_callback) + if not app_settings.display_fullscreen.setting_changed.is_connected(display_callback): + app_settings.display_fullscreen.setting_changed.connect(display_callback) + + +func _disconnect_app_settings_signals() -> void: + var save_callback := Callable(self, "_on_any_app_setting_changed") + for setting: Setting in app_settings.settings.values(): + if setting.setting_changed.is_connected(save_callback): + setting.setting_changed.disconnect(save_callback) + + var display_callback := Callable(self, "_on_display_setting_changed") + if app_settings.display_resolution_preset.setting_changed.is_connected(display_callback): + app_settings.display_resolution_preset.setting_changed.disconnect(display_callback) + if app_settings.display_fullscreen.setting_changed.is_connected(display_callback): + app_settings.display_fullscreen.setting_changed.disconnect(display_callback) + + +func _on_any_app_setting_changed(_value: Variant) -> void: + if _suppress_save: + return + save_app_settings() + emit_signal("settings_changed") + + +func _on_display_setting_changed(_value: Variant) -> void: + AppSettingsDisplayService.apply(app_settings, get_window()) diff --git a/Utils/Settings/range_settings.gd b/Utils/Settings/range_settings.gd index abf35c0..925aae2 100644 --- a/Utils/Settings/range_settings.gd +++ b/Utils/Settings/range_settings.gd @@ -3,7 +3,6 @@ extends SettingCollector var range_units := Setting.new(PhysicsEnums.Units.IMPERIAL) var camera_follow_mode := Setting.new(true) -var shot_injector_enabled := Setting.new(false) var auto_ball_reset := Setting.new(false) var ball_reset_timer := Setting.new(3.0, 1.0, 15.0) var temperature := Setting.new(75, -40, 120) @@ -15,7 +14,6 @@ func _init(): settings = { "range_units": range_units, "camera_follow_mode": camera_follow_mode, - "shot_injector_enabled": shot_injector_enabled, "auto_ball_reset": auto_ball_reset, "ball_reset_timer": ball_reset_timer, "temperature": temperature, diff --git a/Utils/range_club_catalog.gd b/Utils/range_club_catalog.gd new file mode 100644 index 0000000..075810a --- /dev/null +++ b/Utils/range_club_catalog.gd @@ -0,0 +1,29 @@ +class_name RangeClubCatalog +extends RefCounted + +const DEFAULT_CLUB_LABEL := "DRIVER" +const LABELS := [ + "DRIVER", + "3W", + "5W", + "4H", + "3I", + "4I", + "5I", + "6I", + "7I", + "8I", + "9I", + "PW", + "GW", + "SW", + "LW" +] + + +static func normalize_label(label: String) -> String: + var normalized := label.strip_edges().to_upper() + if normalized in LABELS: + return normalized + + return DEFAULT_CLUB_LABEL diff --git a/Utils/range_club_catalog.gd.uid b/Utils/range_club_catalog.gd.uid new file mode 100644 index 0000000..763be61 --- /dev/null +++ b/Utils/range_club_catalog.gd.uid @@ -0,0 +1 @@ +uid://dqasv2mgp6cph diff --git a/addons/launch_monitors/square/SquareProtocol.cs b/addons/launch_monitors/square/SquareProtocol.cs deleted file mode 100644 index 56b0bd2..0000000 --- a/addons/launch_monitors/square/SquareProtocol.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using System.Buffers.Binary; - -namespace LaunchMonitors.Square; - -public static class SquareProtocol -{ - public static bool IsSensorPacket(ReadOnlySpan data) - { - return data.Length >= 17 && data[0] == 0x11 && data[1] == 0x01; - } - - public static bool IsShotPacket(ReadOnlySpan data) - { - return data.Length >= 17 && data[0] == 0x11 && data[1] == 0x02; - } - - public static bool TryParseSensor(ReadOnlySpan data, out SquareSensorData sensor) - { - sensor = default; - if (!IsSensorPacket(data)) - { - return false; - } - - sensor = new SquareSensorData( - data[3] is 0x01 or 0x02, - data[4] == 0x01, - BinaryPrimitives.ReadInt32LittleEndian(data[5..9]), - BinaryPrimitives.ReadInt32LittleEndian(data[9..13]), - BinaryPrimitives.ReadInt32LittleEndian(data[13..17])); - - return true; - } - - public static bool TryParseShot(ReadOnlySpan data, out SquareShotMetrics metrics) - { - metrics = default; - if (!IsShotPacket(data)) - { - return false; - } - - var shotType = data[2] switch - { - 0x37 => "full", - 0x13 => "putt", - _ => "unknown" - }; - - metrics = new SquareShotMetrics( - BinaryPrimitives.ReadInt16LittleEndian(data[3..5]) / 100.0f, - BinaryPrimitives.ReadInt16LittleEndian(data[5..7]) / 100.0f, - BinaryPrimitives.ReadInt16LittleEndian(data[7..9]) / 100.0f, - BinaryPrimitives.ReadInt16LittleEndian(data[9..11]), - BinaryPrimitives.ReadInt16LittleEndian(data[11..13]) / -100.0f, - BinaryPrimitives.ReadInt16LittleEndian(data[13..15]), - BinaryPrimitives.ReadInt16LittleEndian(data[15..17]), - shotType); - - return IsPlausible(metrics); - } - - private static bool IsPlausible(SquareShotMetrics metrics) - { - return metrics.BallSpeedMps > 0 - && metrics.BallSpeedMps < 250 - && metrics.TotalSpinRpm >= 0 - && metrics.TotalSpinRpm < 30_000 - && metrics.VerticalAngle >= 0; - } -} diff --git a/addons/launch_monitors/LICENSE b/addons/open-lm-connector/LICENSE similarity index 100% rename from addons/launch_monitors/LICENSE rename to addons/open-lm-connector/LICENSE diff --git a/addons/launch_monitors/README.md b/addons/open-lm-connector/README.md similarity index 96% rename from addons/launch_monitors/README.md rename to addons/open-lm-connector/README.md index b5111ba..d38cf2d 100644 --- a/addons/launch_monitors/README.md +++ b/addons/open-lm-connector/README.md @@ -39,6 +39,8 @@ Registered in `project.godot` as `LaunchMonitorManager`. Owns the active monitor `SquareLaunchMonitor` wraps `SquareConnectionSession`, which drives the BLE GATT lifecycle through `IBluetoothGattClient` (resolved at runtime by `BluetoothGattClientFactory`). Square is the only consumer of `common/bluetooth/` today. +See [`square/README.md`](square/README.md) for the integration overview and [`square/PROTOCOL.md`](square/PROTOCOL.md) for the reverse-engineered BLE protocol map. + ### `common/bluetooth/` Cross-platform BLE GATT abstraction. `BluetoothGattClientFactory.Create()` picks the platform implementation: diff --git a/addons/launch_monitors/common/bluetooth/BluetoothCharacteristicValue.cs b/addons/open-lm-connector/common/bluetooth/BluetoothCharacteristicValue.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothCharacteristicValue.cs rename to addons/open-lm-connector/common/bluetooth/BluetoothCharacteristicValue.cs diff --git a/addons/launch_monitors/common/bluetooth/BluetoothCharacteristicValue.cs.uid b/addons/open-lm-connector/common/bluetooth/BluetoothCharacteristicValue.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothCharacteristicValue.cs.uid rename to addons/open-lm-connector/common/bluetooth/BluetoothCharacteristicValue.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/BluetoothConnectionOptions.cs b/addons/open-lm-connector/common/bluetooth/BluetoothConnectionOptions.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothConnectionOptions.cs rename to addons/open-lm-connector/common/bluetooth/BluetoothConnectionOptions.cs diff --git a/addons/launch_monitors/common/bluetooth/BluetoothConnectionOptions.cs.uid b/addons/open-lm-connector/common/bluetooth/BluetoothConnectionOptions.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothConnectionOptions.cs.uid rename to addons/open-lm-connector/common/bluetooth/BluetoothConnectionOptions.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/BluetoothDevice.cs b/addons/open-lm-connector/common/bluetooth/BluetoothDevice.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothDevice.cs rename to addons/open-lm-connector/common/bluetooth/BluetoothDevice.cs diff --git a/addons/launch_monitors/common/bluetooth/BluetoothDevice.cs.uid b/addons/open-lm-connector/common/bluetooth/BluetoothDevice.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothDevice.cs.uid rename to addons/open-lm-connector/common/bluetooth/BluetoothDevice.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/BluetoothGattClientFactory.cs b/addons/open-lm-connector/common/bluetooth/BluetoothGattClientFactory.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothGattClientFactory.cs rename to addons/open-lm-connector/common/bluetooth/BluetoothGattClientFactory.cs diff --git a/addons/launch_monitors/common/bluetooth/BluetoothGattClientFactory.cs.uid b/addons/open-lm-connector/common/bluetooth/BluetoothGattClientFactory.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothGattClientFactory.cs.uid rename to addons/open-lm-connector/common/bluetooth/BluetoothGattClientFactory.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/BluetoothScanOptions.cs b/addons/open-lm-connector/common/bluetooth/BluetoothScanOptions.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothScanOptions.cs rename to addons/open-lm-connector/common/bluetooth/BluetoothScanOptions.cs diff --git a/addons/launch_monitors/common/bluetooth/BluetoothScanOptions.cs.uid b/addons/open-lm-connector/common/bluetooth/BluetoothScanOptions.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothScanOptions.cs.uid rename to addons/open-lm-connector/common/bluetooth/BluetoothScanOptions.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/BluetoothWriteMode.cs b/addons/open-lm-connector/common/bluetooth/BluetoothWriteMode.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothWriteMode.cs rename to addons/open-lm-connector/common/bluetooth/BluetoothWriteMode.cs diff --git a/addons/launch_monitors/common/bluetooth/BluetoothWriteMode.cs.uid b/addons/open-lm-connector/common/bluetooth/BluetoothWriteMode.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/BluetoothWriteMode.cs.uid rename to addons/open-lm-connector/common/bluetooth/BluetoothWriteMode.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/IBluetoothGattClient.cs b/addons/open-lm-connector/common/bluetooth/IBluetoothGattClient.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/IBluetoothGattClient.cs rename to addons/open-lm-connector/common/bluetooth/IBluetoothGattClient.cs diff --git a/addons/launch_monitors/common/bluetooth/IBluetoothGattClient.cs.uid b/addons/open-lm-connector/common/bluetooth/IBluetoothGattClient.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/IBluetoothGattClient.cs.uid rename to addons/open-lm-connector/common/bluetooth/IBluetoothGattClient.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/UnsupportedBluetoothGattClient.cs b/addons/open-lm-connector/common/bluetooth/UnsupportedBluetoothGattClient.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/UnsupportedBluetoothGattClient.cs rename to addons/open-lm-connector/common/bluetooth/UnsupportedBluetoothGattClient.cs diff --git a/addons/launch_monitors/common/bluetooth/UnsupportedBluetoothGattClient.cs.uid b/addons/open-lm-connector/common/bluetooth/UnsupportedBluetoothGattClient.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/UnsupportedBluetoothGattClient.cs.uid rename to addons/open-lm-connector/common/bluetooth/UnsupportedBluetoothGattClient.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/linux/BlueZMapper.cs b/addons/open-lm-connector/common/bluetooth/linux/BlueZMapper.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/linux/BlueZMapper.cs rename to addons/open-lm-connector/common/bluetooth/linux/BlueZMapper.cs diff --git a/addons/launch_monitors/common/bluetooth/linux/BlueZMapper.cs.uid b/addons/open-lm-connector/common/bluetooth/linux/BlueZMapper.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/linux/BlueZMapper.cs.uid rename to addons/open-lm-connector/common/bluetooth/linux/BlueZMapper.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/linux/LinuxBluetoothGattClient.cs b/addons/open-lm-connector/common/bluetooth/linux/LinuxBluetoothGattClient.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/linux/LinuxBluetoothGattClient.cs rename to addons/open-lm-connector/common/bluetooth/linux/LinuxBluetoothGattClient.cs diff --git a/addons/launch_monitors/common/bluetooth/linux/LinuxBluetoothGattClient.cs.uid b/addons/open-lm-connector/common/bluetooth/linux/LinuxBluetoothGattClient.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/linux/LinuxBluetoothGattClient.cs.uid rename to addons/open-lm-connector/common/bluetooth/linux/LinuxBluetoothGattClient.cs.uid diff --git a/addons/launch_monitors/common/bluetooth/windows/WindowsBluetoothGattClient.cs b/addons/open-lm-connector/common/bluetooth/windows/WindowsBluetoothGattClient.cs similarity index 100% rename from addons/launch_monitors/common/bluetooth/windows/WindowsBluetoothGattClient.cs rename to addons/open-lm-connector/common/bluetooth/windows/WindowsBluetoothGattClient.cs diff --git a/addons/launch_monitors/common/bluetooth/windows/WindowsBluetoothGattClient.cs.uid b/addons/open-lm-connector/common/bluetooth/windows/WindowsBluetoothGattClient.cs.uid similarity index 100% rename from addons/launch_monitors/common/bluetooth/windows/WindowsBluetoothGattClient.cs.uid rename to addons/open-lm-connector/common/bluetooth/windows/WindowsBluetoothGattClient.cs.uid diff --git a/addons/launch_monitors/common/tcp_server/TcpServer.cs b/addons/open-lm-connector/common/tcp_server/TcpServer.cs similarity index 77% rename from addons/launch_monitors/common/tcp_server/TcpServer.cs rename to addons/open-lm-connector/common/tcp_server/TcpServer.cs index c236cc6..8261795 100644 --- a/addons/launch_monitors/common/tcp_server/TcpServer.cs +++ b/addons/open-lm-connector/common/tcp_server/TcpServer.cs @@ -15,6 +15,7 @@ public partial class TcpServer : Node private StreamPeerTcp? _tcpConnection; private bool _tcpConnected; private string _tcpString = string.Empty; + private string _connectedHost = string.Empty; private Dictionary _shotData = new(); private readonly Dictionary _resp200 = new() { { "Code", 200 } }; private readonly Dictionary _resp50x = new() { { "Code", 501 }, { "Message", "Failure Occured" } }; @@ -22,12 +23,21 @@ public partial class TcpServer : Node [Signal] public delegate void HitBallEventHandler(Dictionary data); + [Signal] + public delegate void StatusChangedEventHandler(string status); + [Export] public int Port { get; set; } = DefaultPort; - public override void _Ready() + public bool IsListening => _tcpServer.IsListening(); + + public bool HasConnection => _tcpConnected; + + public string ConnectedHost => _connectedHost; + + public void StartListening(int port) { - ListenOnPort(Port); + ListenOnPort(port); } public override void _ExitTree() @@ -42,8 +52,11 @@ public override void _Process(double delta) _tcpConnection = _tcpServer.TakeConnection(); if (_tcpConnection != null) { - GD.Print($"We have a tcp connection at {_tcpConnection.GetConnectedHost()}"); + _connectedHost = _tcpConnection.GetConnectedHost() ?? string.Empty; + GD.Print($"We have a tcp connection at {_connectedHost}"); _tcpConnected = true; + var hostLabel = string.IsNullOrEmpty(_connectedHost) ? "unknown" : _connectedHost; + EmitStatus($"Connected: {hostLabel}"); } return; @@ -52,6 +65,7 @@ public override void _Process(double delta) if (_tcpConnection == null) { _tcpConnected = false; + _connectedHost = string.Empty; return; } @@ -60,7 +74,9 @@ public override void _Process(double delta) if (status == StreamPeerTcp.Status.None) { _tcpConnected = false; + _connectedHost = string.Empty; GD.Print("tcp disconnected"); + EmitListeningStatus(); return; } @@ -170,19 +186,41 @@ public void _on_player_bad_data() respond_error(501, "Invalid ball data"); } + public void StopListening() + { + Shutdown(); + EmitStatus("Stopped"); + } + + public bool GetIsListening() + { + return IsListening; + } + + public bool GetIsConnected() + { + return HasConnection; + } + + public string GetConnectedHost() + { + return ConnectedHost; + } + private void ListenOnPort(int port) { Port = Math.Clamp(port, 1, 65535); - if (_tcpServer.IsListening()) - { - _tcpServer.Stop(); - } + Shutdown(); var error = _tcpServer.Listen((ushort)Port); if (error != Error.Ok) { GD.PushError($"TCP server failed to listen on port {Port}. Error: {error}"); + EmitStatus($"Failed: {error}"); + return; } + + EmitListeningStatus(); } private void Shutdown() @@ -194,6 +232,7 @@ private void Shutdown() } _tcpConnected = false; + _connectedHost = string.Empty; _shotData.Clear(); _tcpString = string.Empty; @@ -202,4 +241,14 @@ private void Shutdown() _tcpServer.Stop(); } } + + private void EmitListeningStatus() + { + EmitStatus(_tcpServer.IsListening() ? $"Listening on {Port}" : "Stopped"); + } + + private void EmitStatus(string status) + { + EmitSignal(SignalName.StatusChanged, status); + } } diff --git a/addons/launch_monitors/common/tcp_server/TcpServer.cs.uid b/addons/open-lm-connector/common/tcp_server/TcpServer.cs.uid similarity index 100% rename from addons/launch_monitors/common/tcp_server/TcpServer.cs.uid rename to addons/open-lm-connector/common/tcp_server/TcpServer.cs.uid diff --git a/addons/launch_monitors/launch_monitor_manager.gd b/addons/open-lm-connector/launch_monitor_manager.gd similarity index 57% rename from addons/launch_monitors/launch_monitor_manager.gd rename to addons/open-lm-connector/launch_monitor_manager.gd index e47bb51..2691c6e 100644 --- a/addons/launch_monitors/launch_monitor_manager.gd +++ b/addons/open-lm-connector/launch_monitor_manager.gd @@ -1,3 +1,4 @@ +class_name LaunchMonitorManagerAutoload extends Node # Monitor implementations live in sibling folders (e.g. `square/`); shared transports and external receivers live under `common/`. @@ -11,10 +12,14 @@ signal firmware_changed(firmware: String) signal ready_changed(is_ready: bool) const SETTINGS_PATH := "user://square_launch_monitor.cfg" -const DEFAULT_CLUB_CODE := "0204" +const DEFAULT_CLUB_CODE := SquareClubCatalog.DEFAULT_CLUB_CODE +const PROVIDER_PITRAC := AppSettings.LAUNCH_MONITOR_PROVIDER_PITRAC +const PROVIDER_SQUARE := AppSettings.LAUNCH_MONITOR_PROVIDER_SQUARE const SQUARE_CLASS_NAME := "SquareLaunchMonitor" -const SQUARE_SCRIPT_PATH := "res://addons/launch_monitors/square/SquareLaunchMonitor.cs" -const SQUARE_LOG_PREFIX := "[SquareLM]" +const SQUARE_SCRIPT_PATH := "res://addons/open-lm-connector/square/SquareLaunchMonitor.cs" +const TCP_SERVER_CLASS_NAME := "TcpServer" +const TCP_SERVER_SCRIPT_PATH := "res://addons/open-lm-connector/common/tcp_server/TcpServer.cs" +const LMONITOR_LOG_PREFIX := "[LMonitor]" const SQUARE_DEVICE_PREFIX := "squaregolf" const BLUEZ_DEVICE_SEGMENT_PREFIX := "/dev_" const LINUX_AUTO_CONNECT_SCAN_SECONDS := 15.0 @@ -29,18 +34,17 @@ var battery_level := -1 var firmware := "" var is_ready := false var _square_init_error := "" -var settings := { - "enabled": false, - "device_id": "", - "club_code": DEFAULT_CLUB_CODE, - "handedness": 0 -} +var _square_runtime_enabled := false var _square: Node = null var _config := ConfigFile.new() var _linux_auto_connect_active := false var _linux_auto_connect_target_address := "" var _linux_auto_connect_timer: Timer = null +var _tcp_server: Node = null +var _tcp_init_error := "" +var _app_settings: AppSettings = null +var _active_provider := "" func _ready() -> void: @@ -49,15 +53,24 @@ func _ready() -> void: str(ClassDB.class_exists("CSharpScript")), str(ProjectSettings.get_setting("dotnet/project/assembly_name", "")) ]) - _load_settings() + _load_runtime_enabled() _create_square_monitor() if _square == null: _debug_error("Square monitor unavailable during startup: %s" % _square_init_error) - if bool(settings.get("enabled", false)): - _connect_saved_device_on_startup(str(settings.get("device_id", ""))) + _app_settings = _get_app_settings() + _connect_launch_monitor_settings() + _apply_launch_monitor_settings() + + +func _exit_tree() -> void: + _disconnect_launch_monitor_settings() + _stop_pitrac() func start_scan() -> void: + if not _is_provider_active(PROVIDER_SQUARE): + _set_status("Select Square") + return _cancel_linux_auto_connect_scan() _start_square_scan() @@ -68,6 +81,9 @@ func stop_scan() -> void: func connect_to_device(device_id: String) -> void: + if not _is_provider_active(PROVIDER_SQUARE): + _set_status("Select Square") + return _cancel_linux_auto_connect_scan() if _square == null: var message := _missing_support_message() @@ -76,10 +92,10 @@ func connect_to_device(device_id: String) -> void: emit_signal("error_occurred", message) return _debug_log("connect_to_device requested for %s" % device_id) - settings["device_id"] = device_id - _save_settings() - _square.call("SetHandedness", int(settings.get("handedness", 0))) - _square.call("SetClub", str(settings.get("club_code", DEFAULT_CLUB_CODE))) + if _app_settings != null: + _app_settings.square_device_id.set_value(device_id) + _square.call("SetHandedness", get_square_handedness()) + _square.call("SetClub", get_square_club_code()) _square.call("ConnectToDevice", device_id) @@ -88,6 +104,7 @@ func disconnect_device() -> void: if _square != null: _debug_log("disconnect_device requested") _square.call("DisconnectFromDevice") + _clear_monitor_details() func _start_square_scan() -> void: @@ -109,26 +126,46 @@ func _stop_square_scan() -> void: func set_enabled(value: bool) -> void: + if _square_runtime_enabled == value: + return if not value: _cancel_linux_auto_connect_scan() - settings["enabled"] = value - _save_settings() + _square_runtime_enabled = value + _save_runtime_enabled() func set_club_code(club_code: String) -> void: - settings["club_code"] = club_code - _save_settings() + if _app_settings != null: + _app_settings.square_club_code.set_value(club_code) if _square != null: _square.call("SetClub", club_code) func set_handedness(handedness: int) -> void: - settings["handedness"] = handedness - _save_settings() + if _app_settings != null: + _app_settings.square_handedness.set_value(handedness) if _square != null: _square.call("SetHandedness", handedness) +func get_square_club_code() -> String: + if _app_settings == null: + return DEFAULT_CLUB_CODE + return str(_app_settings.square_club_code.value) + + +func get_square_handedness() -> int: + if _app_settings == null: + return 0 + return int(_app_settings.square_handedness.value) + + +func get_selected_device_id() -> String: + if _app_settings == null: + return "" + return str(_app_settings.square_device_id.value) + + func set_ready() -> void: if _square != null: _debug_log("set_ready requested") @@ -173,28 +210,23 @@ func _create_square_monitor() -> void: _square.connect("ShotReceived", _on_square_shot_received) -func _load_settings() -> void: - var err := _config.load(SETTINGS_PATH) - if err != OK: +func _load_runtime_enabled() -> void: + if _config.load(SETTINGS_PATH) != OK: return - settings["enabled"] = bool(_config.get_value("square", "enabled", false)) - settings["device_id"] = str(_config.get_value("square", "device_id", "")) - settings["club_code"] = str(_config.get_value("square", "club_code", DEFAULT_CLUB_CODE)) - settings["handedness"] = int(_config.get_value("square", "handedness", 0)) + _square_runtime_enabled = bool(_config.get_value("square", "enabled", false)) -func _save_settings() -> void: - _config.set_value("square", "enabled", bool(settings.get("enabled", false))) - _config.set_value("square", "device_id", str(settings.get("device_id", ""))) - _config.set_value("square", "club_code", str(settings.get("club_code", DEFAULT_CLUB_CODE))) - _config.set_value("square", "handedness", int(settings.get("handedness", 0))) +func _save_runtime_enabled() -> void: + _config.set_value("square", "enabled", _square_runtime_enabled) var err := _config.save(SETTINGS_PATH) if err != OK: - _debug_error("Failed to save Square settings file at %s" % SETTINGS_PATH) + _debug_error("Failed to save Square runtime flag at %s" % SETTINGS_PATH) emit_signal("error_occurred", "Square settings could not be saved.") func _on_square_device_discovered(device_id: String, name: String, rssi: int) -> void: + if not _is_provider_active(PROVIDER_SQUARE): + return if not _is_square_device_name(name): _debug_log("ignoring non-square device discovery: %s (%s)" % [name, device_id]) return @@ -210,10 +242,14 @@ func _on_square_device_discovered(device_id: String, name: String, rssi: int) -> func _on_square_status_changed(value: String) -> void: - _set_status(value) + if not _is_provider_active(PROVIDER_SQUARE): + return + _set_status(_normalize_square_status(value)) func _on_square_error_occurred(message: String) -> void: + if not _is_provider_active(PROVIDER_SQUARE): + return if _is_transient_square_connect_error(message): _debug_log("Square runtime warning: %s" % message) else: @@ -222,28 +258,196 @@ func _on_square_error_occurred(message: String) -> void: func _on_square_battery_changed(level: int) -> void: + if not _is_provider_active(PROVIDER_SQUARE): + return _debug_log("battery changed: %d%%" % level) battery_level = level emit_signal("battery_changed", level) func _on_square_firmware_changed(value: String) -> void: + if not _is_provider_active(PROVIDER_SQUARE): + return _debug_log("firmware changed: %s" % value) firmware = value emit_signal("firmware_changed", value) func _on_square_ready_changed(value: bool) -> void: + if not _is_provider_active(PROVIDER_SQUARE): + return _debug_log("ready changed: %s" % str(value)) is_ready = value emit_signal("ready_changed", value) func _on_square_shot_received(data: Dictionary) -> void: + if not _is_provider_active(PROVIDER_SQUARE): + return _debug_log("shot received with %d fields" % data.size()) emit_signal("hit_ball", data) +func _get_app_settings() -> AppSettings: + if GlobalSettingsManager == null: + return null + return GlobalSettingsManager.app_settings + + +func _connect_launch_monitor_settings() -> void: + if _app_settings == null: + return + + var callback := _apply_launch_monitor_settings.unbind(1) + if not _app_settings.launch_monitor_enabled.setting_changed.is_connected(callback): + _app_settings.launch_monitor_enabled.setting_changed.connect(callback) + if not _app_settings.launch_monitor_provider.setting_changed.is_connected(callback): + _app_settings.launch_monitor_provider.setting_changed.connect(callback) + if not _app_settings.tcp_port.setting_changed.is_connected(callback): + _app_settings.tcp_port.setting_changed.connect(callback) + + +func _disconnect_launch_monitor_settings() -> void: + if _app_settings == null: + return + + var callback := _apply_launch_monitor_settings.unbind(1) + if _app_settings.launch_monitor_enabled.setting_changed.is_connected(callback): + _app_settings.launch_monitor_enabled.setting_changed.disconnect(callback) + if _app_settings.launch_monitor_provider.setting_changed.is_connected(callback): + _app_settings.launch_monitor_provider.setting_changed.disconnect(callback) + if _app_settings.tcp_port.setting_changed.is_connected(callback): + _app_settings.tcp_port.setting_changed.disconnect(callback) + + +func _apply_launch_monitor_settings() -> void: + if _app_settings == null or not bool(_app_settings.launch_monitor_enabled.value): + _disable_launch_monitors() + return + + var provider := _get_selected_provider() + if provider == PROVIDER_SQUARE: + _start_square_provider() + else: + _start_pitrac_provider() + + +func _get_selected_provider() -> String: + if _app_settings == null: + return PROVIDER_PITRAC + + return AppSettings.normalize_provider(str(_app_settings.launch_monitor_provider.value)) + + +func _is_provider_active(provider: String) -> bool: + return _app_settings != null and bool(_app_settings.launch_monitor_enabled.value) and _get_selected_provider() == provider + + +func _disable_launch_monitors() -> void: + _stop_pitrac() + _stop_square_provider() + _active_provider = "" + _clear_monitor_details() + _set_status("Disabled") + + +func _start_square_provider() -> void: + if _active_provider != PROVIDER_SQUARE: + _stop_pitrac() + _active_provider = PROVIDER_SQUARE + set_enabled(true) + if status == "Disabled" or status.begins_with(PROVIDER_PITRAC): + _set_status("Disconnected") + _connect_saved_device_on_startup(get_selected_device_id()) + else: + set_enabled(true) + + +func _stop_square_provider() -> void: + var should_stop_runtime := _active_provider == PROVIDER_SQUARE or _linux_auto_connect_active + _cancel_linux_auto_connect_scan() + set_enabled(false) + if not should_stop_runtime: + _clear_monitor_details() + return + + _stop_square_scan() + disconnect_device() + + +func _start_pitrac_provider() -> void: + if _active_provider != PROVIDER_PITRAC: + _stop_square_provider() + _active_provider = PROVIDER_PITRAC + _clear_monitor_details() + _start_pitrac(int(_app_settings.tcp_port.value)) + + +func _start_pitrac(port: int) -> void: + if _tcp_server == null: + _create_pitrac_tcp_server() + if _tcp_server == null: + _set_status(_tcp_init_error) + return + + if bool(_tcp_server.call("GetIsListening")): + _tcp_server.call("StopListening") + await get_tree().process_frame + + _tcp_server.call("StartListening", port) + + +func _stop_pitrac() -> void: + if _tcp_server == null: + return + _tcp_server.call("StopListening") + + +func _create_pitrac_tcp_server() -> void: + _tcp_init_error = "" + var tcp_script := load(TCP_SERVER_SCRIPT_PATH) as Script + if tcp_script == null: + _tcp_init_error = "%s script could not be loaded at %s." % [TCP_SERVER_CLASS_NAME, TCP_SERVER_SCRIPT_PATH] + _debug_error(_tcp_init_error) + return + + if not tcp_script.can_instantiate(): + _tcp_init_error = "%s script is loaded but cannot instantiate. Ensure C# build succeeds and class name matches filename." % TCP_SERVER_CLASS_NAME + _debug_error(_tcp_init_error) + return + + _tcp_server = tcp_script.new() as Node + if _tcp_server == null: + _tcp_init_error = "%s could not be created from %s. Check C# build output for load errors." % [TCP_SERVER_CLASS_NAME, TCP_SERVER_SCRIPT_PATH] + _debug_error(_tcp_init_error) + return + + add_child(_tcp_server) + _tcp_server.connect("HitBall", _on_pitrac_hit_ball) + _tcp_server.connect("StatusChanged", _on_pitrac_status_changed) + + +func _on_pitrac_hit_ball(data: Dictionary) -> void: + _debug_log("PiTrac shot received with %d fields" % data.size()) + emit_signal("hit_ball", data) + + +func _on_pitrac_status_changed(value: String) -> void: + if _active_provider != PROVIDER_PITRAC or not _is_provider_active(PROVIDER_PITRAC): + return + + _set_status("%s %s" % [PROVIDER_PITRAC, value]) + + +func _clear_monitor_details() -> void: + if battery_level != -1: + battery_level = -1 + emit_signal("battery_changed", battery_level) + if firmware != "": + firmware = "" + emit_signal("firmware_changed", firmware) + + func _missing_support_message() -> String: if _square_init_error != "": return "Square support is unavailable in this build. %s" % _square_init_error @@ -363,12 +567,19 @@ func _set_status(value: String) -> void: _debug_log("status -> %s" % value) +func _normalize_square_status(value: String) -> String: + var normalized := value.strip_edges() + if normalized == "Ready": + return "Connected" + return normalized + + func _debug_log(message: String) -> void: - print("%s %s" % [SQUARE_LOG_PREFIX, message]) + print("%s %s" % [LMONITOR_LOG_PREFIX, message]) func _debug_error(message: String) -> void: - push_error("%s %s" % [SQUARE_LOG_PREFIX, message]) + push_error("%s %s" % [LMONITOR_LOG_PREFIX, message]) func _is_transient_square_connect_error(message: String) -> bool: diff --git a/addons/launch_monitors/launch_monitor_manager.gd.uid b/addons/open-lm-connector/launch_monitor_manager.gd.uid similarity index 100% rename from addons/launch_monitors/launch_monitor_manager.gd.uid rename to addons/open-lm-connector/launch_monitor_manager.gd.uid diff --git a/addons/open-lm-connector/square/PROTOCOL.md b/addons/open-lm-connector/square/PROTOCOL.md new file mode 100644 index 0000000..b9911f9 --- /dev/null +++ b/addons/open-lm-connector/square/PROTOCOL.md @@ -0,0 +1,276 @@ +# Square Launch Monitor — BLE Protocol Notes + +This document maps what the Square BLE protocol looks like as implemented in this +folder, and records how that mapping was (most plausibly) derived. The Square +protocol is not publicly documented by the vendor; everything here was learned by +observing the device's BLE traffic alongside the official client. + +> **Provenance.** The protocol map in Section A is sourced directly from the code +> in this folder — it is verifiable against the files cited inline. The process +> notes in Section B are *reconstructed from code patterns*, not from a recorded +> reverse-engineering session. If something disagrees with the source files, the +> source files win. + +--- + +## Section A — Protocol map (reference) + +### A.1 Transport + +- BLE GATT, accessed through `LaunchMonitors.Common.Bluetooth.IBluetoothGattClient` + (`addons/launch_monitors/common/bluetooth/`). The factory picks the per-OS + implementation: + - Linux → BlueZ via D-Bus (`Tmds.DBus`). + - Windows → `Windows.Devices.Bluetooth` (WinRT), loaded reflectively and only + compiled on Windows builds (see `OpenShotGolf.csproj`). +- Device discovery is name-prefix filtered. The vendor's advertising name starts + with `SquareGolf` (see `SquareConnectionOptions.Default`). +- Four characteristics are used. The canonical UUIDs are in + `SquareConnectionOptions.cs`: + - **Command** — write (`WithResponse`). Outbound frames. + - **Event** — notify. Inbound sensor + shot frames. + - **Battery** — standard `0x2A19`. Read + notify. + - **Firmware** — read. Returns either a raw string or a JSON object with an + `"lm"` field (see `SquareConnectionSession.ParseFirmware`). + +### A.2 Connection handshake + +Implemented in `SquareConnectionSession.ConnectToDeviceAsync` +(`SquareConnectionSession.cs:73-120`). Order matters; the delays are not +cosmetic: + +1. Connect via the GATT client. +2. Read battery + firmware. +3. Subscribe to **Event** (required) and **Battery** (best-effort) notifications. +4. Write `Heartbeat`. +5. Wait `ConnectionClubDelay` (default **2 s**). +6. Write `Club` with the currently selected club code + handedness. +7. Wait `ConnectionReadyDelay` (default **3 s**). +8. Write `DetectBall(mode=1, spinMode=1)` — the "ready to detect a shot" trigger. +9. Start a heartbeat timer (default **every 5 s**) — periodic `Heartbeat` writes. + +After every parsed shot, the session waits `ConnectionReadyDelay` and re-issues +`DetectBall` to arm the next shot (`SquareConnectionSession.cs:295-322`). + +### A.3 Outbound command frames + +From `SquareCommandBuilder.cs`. `{seq}` is a wrapping byte that increments per +command (`SquareConnectionSession.NextSequence`). Frame lengths are fixed per +command (Heartbeat = 8 bytes, DetectBall and Club = 9 bytes). + +| Command | Bytes (hex) | Source | +| ----------- | -------------------------------------------- | ------------------------------ | +| Heartbeat | `11 83 {seq} 00 00 00 00 00` | `SquareCommandBuilder.cs:10` | +| DetectBall | `11 81 {seq} 0{mode} 1{spinMode} 00 00 00 00`| `SquareCommandBuilder.cs:15` | +| Club | `11 82 {seq} {clubCode_2B} 0{handedness} 00 00 00` | `SquareCommandBuilder.cs:20` | + +- `mode` and `spinMode` are single hex digits packed into the upper nibble of + bytes 3 and 4 — the code path that uses them only ever sends `mode=1, + spinMode=1` (`SquareConnectionSession.SetReadyAsync`). +- `handedness`: `0` = right-handed, `1` = left-handed (`SetHandedness` coerces + any other value to `0`). +- `clubCode` is two bytes from the club catalog — see A.5. + +### A.4 Inbound frames + +From `SquareProtocol.cs`. Both known frame types are ≥17 bytes and start with +`0x11`. The second byte discriminates. + +#### Sensor frame — `0x11 0x01 …` + +`SquareProtocol.TryParseSensor` (`SquareProtocol.cs:18-34`): + +| Offset | Width | Field | +| ------ | ------ | ------------------------------------ | +| 0 | 1 | `0x11` (frame marker) | +| 1 | 1 | `0x01` (sensor discriminator) | +| 3 | 1 | `BallReady`: true if value ∈ {0x01, 0x02} | +| 4 | 1 | `BallDetected`: true if value == 0x01 | +| 5 | 4 | `PositionX` (Int32 LE) | +| 9 | 4 | `PositionY` (Int32 LE) | +| 13 | 4 | `PositionZ` (Int32 LE) | + +The session converts `BallReady && BallDetected` into the `ReadyChanged(true)` +signal (`SquareConnectionSession.HandleNotificationAsync`). Position bytes are +parsed but currently unused downstream. + +#### Shot frame — `0x11 0x02 …` + +`SquareProtocol.TryParseShot` (`SquareProtocol.cs:36-62`): + +| Offset | Width | Field | Decode | +| ------ | ----- | ---------------- | ----------------------- | +| 0 | 1 | `0x11` | frame marker | +| 1 | 1 | `0x02` | shot discriminator | +| 2 | 1 | shot type | `0x37` = full, `0x13` = putt, else `"unknown"` | +| 3 | 2 | ball speed | Int16 LE ÷ 100 → m/s | +| 5 | 2 | vertical angle | Int16 LE ÷ 100 → degrees| +| 7 | 2 | horizontal angle | Int16 LE ÷ 100 → degrees| +| 9 | 2 | total spin | Int16 LE → rpm | +| 11 | 2 | spin axis | Int16 LE ÷ **−100** → degrees (sign flipped) | +| 13 | 2 | back spin | Int16 LE → rpm | +| 15 | 2 | side spin | Int16 LE → rpm | + +Byte 2 (shot type) is **opaque metadata** on the Home device; the `0x37`/`0x13` +mapping is consistent with observed full-swing/putt frames but `ShotType` is +informational only (no downstream consumer). + +The spin-axis sign flip is the only field with a negative scale factor — it +exists because the vendor encodes positive-clockwise while OSG/GSPro expect the +opposite convention. + +**Invalid-reading sentinel.** Any field the device could not measure this shot is +sent as `0x8000` (`−32768`). `SquareProtocol` maps that to "no reading" (value 0 + +an internal validity flag) rather than passing it through. Without this, an +unmeasured spin leaked through as a huge negative value, and an unmeasured +speed / total spin / vertical angle would fail the plausibility filter below and +**drop the entire shot**. + +Frames that parse but fail the plausibility filter +(`SquareProtocol.IsPlausible`, `SquareProtocol.cs:64-71`) are dropped: ball +speed in (0, 250) m/s, total spin in [0, 30000) rpm, vertical angle ≥ 0. This +filter is a guard against partially-understood frames; it is not a documented +vendor constraint. + +Duplicate shot frames (identical payload to the previous one) are suppressed in +`SquareConnectionSession.HandleNotificationAsync`. + +### A.5 Club codes + +From `square_club_catalog.gd`. Two bytes per club, written into bytes 4–5 +of the `Club` command (so the on-wire order is `{first_byte}{second_byte}`). +These mirror the `RegularCode` values in the `squaregolf-connector` reference +project. + +| Club | Code | Byte 1 (id) | Byte 2 (family) | +| ------- | ------ | ----------- | --------------- | +| Driver | `0204` | `02` | `04` (driver) | +| 3 Wood | `0305` | `03` | `05` (fwy wood) | +| 5 Wood | `0505` | `05` | `05` | +| 7 Wood | `0705` | `07` | `05` | +| 4 Iron | `0406` | `04` | `06` (iron/wedge) | +| 5 Iron | `0506` | `05` | `06` | +| 6 Iron | `0606` | `06` | `06` | +| 7 Iron | `0706` | `07` | `06` | +| 8 Iron | `0806` | `08` | `06` | +| 9 Iron | `0906` | `09` | `06` | +| PW | `0a06` | `0a` | `06` | +| GW | `0b06` | `0b` | `06` | +| SW | `0c06` | `0c` | `06` | +| Putter | `0107` | `01` | `07` (putter) | + +The structural pattern is clear: the second byte clusters by club family, and +the first byte is the club number / position within the family. Default is +Driver (`0204`); `SquareCommandBuilder.DriverClubCode` and +`SquareClubCatalog.DEFAULT_CLUB_CODE` are the single sources of truth. + +> **`0b06` is the Approach/Gap wedge (GW), not a lob wedge.** The Square hardware +> exposes no distinct lob-wedge code. This code was previously mislabeled "LW" +> in OSG and was corrected to "GW" to match the reference project. + +**Alignment stick** — code `0008` (`SquareClubCatalog.ALIGNMENT_STICK_CODE`). The +device treats this as a special "club" used to enter alignment mode rather than a +normal shot club, so it is kept out of the selectable `CLUBS` table. The alignment +flow that consumes it is not yet implemented in OSG (planned). + +### A.6 Downstream mapping + +`SquareShotDataMapper.ToOsgBallData` (`SquareShotDataMapper.cs`) converts a +parsed `SquareShotMetrics` into the OSG/GSPro ball-data dictionary: + +- Speed: m/s → mph (× 2.23694). +- VLA / HLA / SpinAxis pass through. +- TotalSpin is floored at 0. + +Back/side-spin **decomposition** lives in `SquareProtocol.TryParseShot`, not the +mapper: when total spin and spin axis are valid but a spin component was not +measured (its sentinel was seen), the parser derives it from `TotalSpin` and +`SpinAxis` (degrees → radians, cos/sin decomposition). This handles +devices/firmwares that emit only total spin + axis without the per-axis split. +The mapper is intentionally thin. + +--- + +## Section B — How this was (probably) derived + +> Reconstructed from code shape, not from a logged session. + +### B.1 Tooling that matches the evidence + +The codebase abstracts at the GATT layer, not raw HCI — that rules out +Wireshark-only HCI sniffing as the *primary* tool and points at one of: + +- BlueZ `btmon` on Linux, or +- The Windows Bluetooth BTSnoop log (`btsnoop_hci.log` via the WinRT BLE logs), + parsed in Wireshark, or +- The **nRF Connect** mobile app, which enumerates services / characteristics + and lets you write/notify-subscribe manually, or +- A vendor-app capture on Android via the developer-options "enable Bluetooth + HCI snoop log" toggle. + +Any of these gives you the same view: GATT services, characteristic UUIDs, +notification payloads, and command payloads as the vendor app sends them. + +### B.2 Sniff-and-replay workflow (the likely path) + +1. **Pair the Square with the vendor app while capturing.** Walk through a + normal session — connect, pick a club, hit a few balls — so the capture + contains the full lifecycle. +2. **Enumerate GATT services.** Identify which characteristic the vendor app + *writes to* (→ command), which it *subscribes to for notifications* + (→ event), and which is the standard `0x2A19` battery service. +3. **Replay the connect sequence.** The vendor app's writes during connect + reveal the order and the inter-write delays. The codebase pins these as + `ConnectionClubDelay = 2s` and `ConnectionReadyDelay = 3s` — both are + round-number safety margins, the signature of "this delay made it work + reliably." +4. **Derive the club table.** Cycle through clubs in the vendor app. Each club + change produces one `11 82 {seq} {XX YY} {handedness} …` write. Recording + one row per club gives you `square_club_catalog.gd`. Family clustering in + the second byte is something you only see *after* you've tabulated, but + it's a useful sanity check that no rows are mis-transcribed. +5. **Label notification frames by triggering known events.** Place a ball → + sensor frame fires. Remove the ball → another sensor frame. Hit a full + swing → shot frame. Putt → shot frame with a different byte 2. That's where + `0x11 0x01` vs `0x11 0x02` and `0x37` vs `0x13` come from. +6. **Derive scale factors empirically.** Take a shot with a known ball speed + (e.g., a slow controlled chip, or compared against another monitor). Bisect + the 2-byte little-endian slices of the shot frame until one of them, when + divided by 100, matches the expected m/s. Do the same for spin and angles. + The `÷ -100` on spin axis is the moment you realise the vendor uses the + opposite sign convention from GSPro/OSG. +7. **Gate with a plausibility filter.** While the byte map is still + incomplete, frames that "almost parse" can leak garbage values into + gameplay. `IsPlausible` is the temporary fence that lets you keep moving + without wiring every unknown field — it's deliberately conservative. + +### B.3 Why this matches the code + +A protocol that was *given to you* by the vendor would not have: + +- A `÷ -100` field sitting next to six `÷ 100` fields (sign conventions get + fixed at the spec, not the parser). +- Round-number `2 s` / `3 s` startup delays (a spec would give you a real + handshake or a ready notification — these delays exist because empirically + *3 seconds was enough and 2 seconds wasn't*). +- A plausibility filter as the gate between parser and gameplay. +- A two-byte club code with a discoverable family/id structure rather than a + numeric enum. + +All four are signatures of *replayed-and-trimmed vendor traffic*. + +### B.4 Playbook for the next launch monitor + +Same steps will work for any vendor BLE launch monitor: + +1. Capture vendor-app traffic on Windows or Linux (BTSnoop / `btmon`). +2. Identify GATT roles: command (write), event (notify), battery, firmware. +3. Replay the connect handshake from the capture. Pin any required delays. +4. Cycle through configuration UI (club, handedness, mode) and tabulate the + resulting writes. +5. Trigger known shot events; bisect-decode notification payloads to find + field offsets and scale factors. +6. Gate the parser with a plausibility filter until the byte map is complete. +7. Add a new folder under `addons/launch_monitors//` and depend on + `LaunchMonitors.Common.Bluetooth.IBluetoothGattClient` for the transport + (see `../README.md` for the addon contract). diff --git a/addons/open-lm-connector/square/README.md b/addons/open-lm-connector/square/README.md new file mode 100644 index 0000000..3bfa6af --- /dev/null +++ b/addons/open-lm-connector/square/README.md @@ -0,0 +1,70 @@ +# Square Launch Monitor + +BLE integration for the **Square Golf** launch monitor (Home device). Implemented +in C# behind the addon's common Bluetooth transport, exposed to GDScript as a +single Godot `Node` (`SquareLaunchMonitor`) that the autoload +`launch_monitor_manager.gd` drives. + +> **Protocol reference:** see [`PROTOCOL.md`](PROTOCOL.md) for the full BLE +> protocol map — transport, handshake, command/notification frame layouts, club +> codes, and how the protocol was reverse-engineered. + +## Scope + +- Supports the Square **"Home"** device. The **Omni** variant is intentionally + **not** supported. +- Targets both **Windows** (WinRT) and **Linux** (BlueZ/D-Bus) via the shared + `common/bluetooth/` transport. + +## Files + +| File | Role | +| ---- | ---- | +| `SquareLaunchMonitor.cs` | Public Godot `Node`. Exposes `StartScan`/`ConnectToDevice`/`SetClub`/… and re-emits session events as Godot signals (`ShotReceived`, `ReadyChanged`, `StatusChanged`, …). | +| `SquareConnectionSession.cs` | Connection lifecycle + state machine: handshake, heartbeat, club/ready commands, notification routing, shot re-arm. | +| `SquareProtocol.cs` | Binary frame parser (`11 01` sensor, `11 02` shot) incl. invalid-reading sentinel handling and spin decomposition. | +| `SquareCommandBuilder.cs` | Builds outbound command byte frames (`Heartbeat`, `DetectBall`, `Club`). | +| `SquareConnectionOptions.cs` | UUIDs, device-name prefix, and connection/heartbeat timing constants. | +| `SquareShotMetrics.cs` | Parsed shot/sensor value records. | +| `SquareShotDataMapper.cs` | Maps `SquareShotMetrics` → OSG/GSPro ball-data dictionary (units + clamp). | +| `SquareGodotMapper.cs` | Wraps the mapper output into a Godot `Dictionary`. | +| `square_club_catalog.gd` | Club label → 2-byte Square code lookup (`SquareClubCatalog`). | +| [`PROTOCOL.md`](PROTOCOL.md) | Reverse-engineered BLE protocol notes. | + +## Signal flow + +``` +Square device ──BLE──▶ IBluetoothGattClient ──▶ SquareConnectionSession + │ events + ▼ + SquareLaunchMonitor (Godot Node, [Signal]s) + │ + ▼ + launch_monitor_manager.gd (re-emits hit_ball, etc.) + │ + ▼ + gameplay (Range, Player) +``` + +`SquareLaunchMonitor` resolves the platform BLE client via +`BluetoothGattClientFactory.Create()` — see [`../README.md`](../README.md) for the +addon's transport contract and how to add a new monitor. + +## Clubs + +Club codes live in `square_club_catalog.gd` (`SquareClubCatalog`) and mirror the +`RegularCode` values of the `squaregolf-connector` reference project. The full +table is in [`PROTOCOL.md` §A.5](PROTOCOL.md). Notes: + +- `0b06` is the **Approach/Gap wedge (GW)** — the hardware has no distinct lob + wedge. +- The **alignment stick** (`0008`, `ALIGNMENT_STICK_CODE`) is a special mode + trigger, not a selectable shot club; the alignment flow that uses it is not yet + implemented. + +## Building & testing + +The integration is C#. Build with the project's C# solution +(`dotnet build OpenShotGolf.csproj`) or by opening the project in Godot. +`IBluetoothGattClient` is the seam to mock for unit tests; protocol parsing in +`SquareProtocol` is pure and unit-testable without hardware. diff --git a/addons/launch_monitors/square/SquareCommandBuilder.cs b/addons/open-lm-connector/square/SquareCommandBuilder.cs similarity index 100% rename from addons/launch_monitors/square/SquareCommandBuilder.cs rename to addons/open-lm-connector/square/SquareCommandBuilder.cs diff --git a/addons/launch_monitors/square/SquareCommandBuilder.cs.uid b/addons/open-lm-connector/square/SquareCommandBuilder.cs.uid similarity index 100% rename from addons/launch_monitors/square/SquareCommandBuilder.cs.uid rename to addons/open-lm-connector/square/SquareCommandBuilder.cs.uid diff --git a/addons/launch_monitors/square/SquareConnectionOptions.cs b/addons/open-lm-connector/square/SquareConnectionOptions.cs similarity index 100% rename from addons/launch_monitors/square/SquareConnectionOptions.cs rename to addons/open-lm-connector/square/SquareConnectionOptions.cs diff --git a/addons/launch_monitors/square/SquareConnectionOptions.cs.uid b/addons/open-lm-connector/square/SquareConnectionOptions.cs.uid similarity index 100% rename from addons/launch_monitors/square/SquareConnectionOptions.cs.uid rename to addons/open-lm-connector/square/SquareConnectionOptions.cs.uid diff --git a/addons/launch_monitors/square/SquareConnectionSession.cs b/addons/open-lm-connector/square/SquareConnectionSession.cs similarity index 100% rename from addons/launch_monitors/square/SquareConnectionSession.cs rename to addons/open-lm-connector/square/SquareConnectionSession.cs diff --git a/addons/launch_monitors/square/SquareConnectionSession.cs.uid b/addons/open-lm-connector/square/SquareConnectionSession.cs.uid similarity index 100% rename from addons/launch_monitors/square/SquareConnectionSession.cs.uid rename to addons/open-lm-connector/square/SquareConnectionSession.cs.uid diff --git a/addons/launch_monitors/square/SquareGodotMapper.cs b/addons/open-lm-connector/square/SquareGodotMapper.cs similarity index 100% rename from addons/launch_monitors/square/SquareGodotMapper.cs rename to addons/open-lm-connector/square/SquareGodotMapper.cs diff --git a/addons/launch_monitors/square/SquareGodotMapper.cs.uid b/addons/open-lm-connector/square/SquareGodotMapper.cs.uid similarity index 100% rename from addons/launch_monitors/square/SquareGodotMapper.cs.uid rename to addons/open-lm-connector/square/SquareGodotMapper.cs.uid diff --git a/addons/launch_monitors/square/SquareLaunchMonitor.cs b/addons/open-lm-connector/square/SquareLaunchMonitor.cs similarity index 100% rename from addons/launch_monitors/square/SquareLaunchMonitor.cs rename to addons/open-lm-connector/square/SquareLaunchMonitor.cs diff --git a/addons/launch_monitors/square/SquareLaunchMonitor.cs.uid b/addons/open-lm-connector/square/SquareLaunchMonitor.cs.uid similarity index 100% rename from addons/launch_monitors/square/SquareLaunchMonitor.cs.uid rename to addons/open-lm-connector/square/SquareLaunchMonitor.cs.uid diff --git a/addons/open-lm-connector/square/SquareProtocol.cs b/addons/open-lm-connector/square/SquareProtocol.cs new file mode 100644 index 0000000..681e99b --- /dev/null +++ b/addons/open-lm-connector/square/SquareProtocol.cs @@ -0,0 +1,116 @@ +using System; +using System.Buffers.Binary; + +namespace LaunchMonitors.Square; + +public static class SquareProtocol +{ + public static bool IsSensorPacket(ReadOnlySpan data) + { + return data.Length >= 17 && data[0] == 0x11 && data[1] == 0x01; + } + + public static bool IsShotPacket(ReadOnlySpan data) + { + return data.Length >= 17 && data[0] == 0x11 && data[1] == 0x02; + } + + public static bool TryParseSensor(ReadOnlySpan data, out SquareSensorData sensor) + { + sensor = default; + if (!IsSensorPacket(data)) + { + return false; + } + + sensor = new SquareSensorData( + data[3] is 0x01 or 0x02, + data[4] == 0x01, + BinaryPrimitives.ReadInt32LittleEndian(data[5..9]), + BinaryPrimitives.ReadInt32LittleEndian(data[9..13]), + BinaryPrimitives.ReadInt32LittleEndian(data[13..17])); + + return true; + } + + // The device sends 0x8000 (-32768) for a field it could not measure this + // shot. Treated as "no reading" rather than a real value, otherwise the + // sentinel leaks through as a huge negative spin / angle and can drop the + // whole shot at the plausibility gate. + private const short InvalidReadingSentinel = unchecked((short)0x8000); + + public static bool TryParseShot(ReadOnlySpan data, out SquareShotMetrics metrics) + { + metrics = default; + if (!IsShotPacket(data)) + { + return false; + } + + // Byte[2] is opaque metadata on the Home device; 0x37 is observed on + // full-swing frames and 0x13 on putts. ShotType is informational only. + var shotType = data[2] switch + { + 0x37 => "full", + 0x13 => "putt", + _ => "unknown" + }; + + var (ballSpeed, _) = ReadScaledInt16(data, 3, 100.0f); + var (verticalAngle, _) = ReadScaledInt16(data, 5, 100.0f); + var (horizontalAngle, _) = ReadScaledInt16(data, 7, 100.0f); + var (totalSpin, totalSpinValid) = ReadInt16(data, 9); + var (spinAxis, spinAxisValid) = ReadScaledInt16(data, 11, -100.0f); + var (backSpin, backSpinValid) = ReadInt16(data, 13); + var (sideSpin, sideSpinValid) = ReadInt16(data, 15); + + // When total spin and spin axis are known but a spin component was not + // measured, derive it from the axis (matches squaregolf-connector). + if (totalSpinValid && spinAxisValid) + { + var spinAxisRadians = MathF.PI * spinAxis / 180.0f; + if (!backSpinValid) + { + backSpin = (short)MathF.Round(totalSpin * MathF.Cos(spinAxisRadians)); + } + + if (!sideSpinValid) + { + sideSpin = (short)MathF.Round(totalSpin * MathF.Sin(spinAxisRadians)); + } + } + + metrics = new SquareShotMetrics( + ballSpeed, + verticalAngle, + horizontalAngle, + totalSpin, + spinAxis, + backSpin, + sideSpin, + shotType); + + return IsPlausible(metrics); + } + + private static (float Value, bool Valid) ReadScaledInt16(ReadOnlySpan data, int offset, float scale) + { + var (raw, valid) = ReadInt16(data, offset); + return (raw / scale, valid); + } + + private static (short Value, bool Valid) ReadInt16(ReadOnlySpan data, int offset) + { + var raw = BinaryPrimitives.ReadInt16LittleEndian(data[offset..(offset + 2)]); + return raw == InvalidReadingSentinel ? ((short)0, false) : (raw, true); + } + + private static bool IsPlausible(SquareShotMetrics metrics) + { + return metrics.BallSpeedMps > 0 + && metrics.BallSpeedMps < 250 + && metrics.TotalSpinRpm >= 0 + && metrics.TotalSpinRpm < 30_000 + && metrics.VerticalAngle >= 0; + } +} diff --git a/addons/launch_monitors/square/SquareProtocol.cs.uid b/addons/open-lm-connector/square/SquareProtocol.cs.uid similarity index 100% rename from addons/launch_monitors/square/SquareProtocol.cs.uid rename to addons/open-lm-connector/square/SquareProtocol.cs.uid diff --git a/addons/launch_monitors/square/SquareShotDataMapper.cs b/addons/open-lm-connector/square/SquareShotDataMapper.cs similarity index 73% rename from addons/launch_monitors/square/SquareShotDataMapper.cs rename to addons/open-lm-connector/square/SquareShotDataMapper.cs index b0742df..a188716 100644 --- a/addons/launch_monitors/square/SquareShotDataMapper.cs +++ b/addons/open-lm-connector/square/SquareShotDataMapper.cs @@ -9,17 +9,13 @@ public static class SquareShotDataMapper public static IReadOnlyDictionary ToOsgBallData(SquareShotMetrics metrics) { + // Spin components are already resolved by SquareProtocol (including + // deriving missing back/side spin from total spin + axis), so only the + // unsigned-total clamp remains here. var totalSpin = Math.Max(0, metrics.TotalSpinRpm); var backSpin = metrics.BackSpinRpm; var sideSpin = metrics.SideSpinRpm; - if (backSpin == 0 && sideSpin == 0 && totalSpin > 0) - { - var spinAxisRadians = MathF.PI * metrics.SpinAxis / 180.0f; - backSpin = (int)MathF.Round(totalSpin * MathF.Cos(spinAxisRadians)); - sideSpin = (int)MathF.Round(totalSpin * MathF.Sin(spinAxisRadians)); - } - return new Dictionary { { "Speed", metrics.BallSpeedMps * MetersPerSecondToMph }, diff --git a/addons/launch_monitors/square/SquareShotDataMapper.cs.uid b/addons/open-lm-connector/square/SquareShotDataMapper.cs.uid similarity index 100% rename from addons/launch_monitors/square/SquareShotDataMapper.cs.uid rename to addons/open-lm-connector/square/SquareShotDataMapper.cs.uid diff --git a/addons/launch_monitors/square/SquareShotMetrics.cs b/addons/open-lm-connector/square/SquareShotMetrics.cs similarity index 100% rename from addons/launch_monitors/square/SquareShotMetrics.cs rename to addons/open-lm-connector/square/SquareShotMetrics.cs diff --git a/addons/launch_monitors/square/SquareShotMetrics.cs.uid b/addons/open-lm-connector/square/SquareShotMetrics.cs.uid similarity index 100% rename from addons/launch_monitors/square/SquareShotMetrics.cs.uid rename to addons/open-lm-connector/square/SquareShotMetrics.cs.uid diff --git a/addons/open-lm-connector/square/square_club_catalog.gd b/addons/open-lm-connector/square/square_club_catalog.gd new file mode 100644 index 0000000..cb4d5cb --- /dev/null +++ b/addons/open-lm-connector/square/square_club_catalog.gd @@ -0,0 +1,49 @@ +class_name SquareClubCatalog +extends RefCounted + +const DEFAULT_CLUB_CODE := "0204" + +# Alignment stick is a special "club" the device uses to enter alignment mode +# (RegularCode 0008 in the squaregolf-connector reference). It is intentionally +# kept out of CLUBS so it does not appear as a selectable shot club; the +# alignment-mode flow that uses it is implemented separately. +const ALIGNMENT_STICK_CODE := "0008" + +# Square Hex Code Lookup. Codes mirror the squaregolf-connector RegularCode +# values. Note: 0b06 is the Approach/Gap wedge (GW); the Square hardware has no +# distinct lob-wedge code. +const CLUBS := { + "Driver": "0204", + "Putter": "0107", + "3 Wood": "0305", + "5 Wood": "0505", + "7 Wood": "0705", + "4 Iron": "0406", + "5 Iron": "0506", + "6 Iron": "0606", + "7 Iron": "0706", + "8 Iron": "0806", + "9 Iron": "0906", + "PW": "0a06", + "GW": "0b06", + "SW": "0c06" +} + + +static func labels() -> Array: + return CLUBS.keys() + + +static func code_for(label: String) -> String: + return str(CLUBS.get(label, DEFAULT_CLUB_CODE)) + + +static func is_valid_code(code: String) -> bool: + for club_code in CLUBS.values(): + if str(club_code) == code: + return true + return false + + +static func is_alignment_stick(code: String) -> bool: + return code == ALIGNMENT_STICK_CODE diff --git a/addons/open-lm-connector/square/square_club_catalog.gd.uid b/addons/open-lm-connector/square/square_club_catalog.gd.uid new file mode 100644 index 0000000..71f7ab6 --- /dev/null +++ b/addons/open-lm-connector/square/square_club_catalog.gd.uid @@ -0,0 +1 @@ +uid://dwmcx55328km0 diff --git a/addons/openfairway/plugin.cfg b/addons/openfairway/plugin.cfg index 516b192..536b06c 100644 --- a/addons/openfairway/plugin.cfg +++ b/addons/openfairway/plugin.cfg @@ -3,5 +3,5 @@ name="OpenFairway Physics" description="Realistic golf ball physics engine with aerodynamics, bounce, and surface interactions for Godot 4.6+ C# projects." author="jesseincode, Jakobi" -version="0.1.3" +version="1.1.1" script="plugin.gd" diff --git a/project.godot b/project.godot index dcf4b58..aeda785 100644 --- a/project.godot +++ b/project.godot @@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] config/name="OpenShotGolf" -config/version="0.1.3" +config/version="0.1.4" run/main_scene="uid://dgyd437dgyv0y" config/features=PackedStringArray("4.6", "C#", "Mobile") boot_splash/image="uid://db1qv4yqokht7" @@ -26,8 +26,8 @@ config/icon="uid://bcv0yjfm7ggro" PhantomCameraManager="*uid://duq6jhf6unyis" SceneManager="*res://Utils/SceneManager.gd" EventBus="*res://Utils/EventBus.gd" -GlobalSettings="*res://Utils/Settings/global_settings.gd" -LaunchMonitorManager="*res://addons/launch_monitors/launch_monitor_manager.gd" +GlobalSettingsManager="*res://Utils/Settings/global_settings.gd" +LaunchMonitorManager="*res://addons/open-lm-connector/launch_monitor_manager.gd" [display]