class_name BubbleButton extends Button const ICON_FILL_RATIO: float = 0.76 @export var profile: BubbleMenuProfile @export_group("Authored Layout") @export var neutral_size: Vector2 = Vector2(120.0, 116.0) @export var desktop_anchor: Vector2 = Vector2.ZERO @export var compact_anchor: Vector2 = Vector2.ZERO @export var compact_minimum_size: Vector2 = Vector2.ZERO @export_group("Typography") @export var label_control_path: NodePath @export_range(1, 256, 1) var minimum_font_size: int = 14 @export_range(1, 256, 1) var maximum_font_size: int = 32 @export_group("Motion") @export_range(0.0, 32.0, 0.1) var horizontal_amplitude: float = 1.8 @export_range(0.0, 32.0, 0.1) var vertical_amplitude: float = 4.0 @export_range(0.1, 30.0, 0.1) var motion_period: float = 5.0 @export_range(0.0, TAU, 0.01) var motion_phase: float = 0.0 @export_range(0.0, 0.2, 0.001) var deformation_amplitude: float = 0.016 @export_range(0.1, 30.0, 0.1) var deformation_period: float = 6.0 var neutral_position: Vector2 = Vector2.ZERO var presented_size: Vector2 = Vector2.ZERO var emphasis: float = 0.0 var _hovered: bool = false var _focused: bool = false func _ready() -> void: mouse_entered.connect(_set_hovered.bind(true)) mouse_exited.connect(_set_hovered.bind(false)) focus_entered.connect(_set_focused.bind(true)) focus_exited.connect(_set_focused.bind(false)) resized.connect(_update_pivot) _update_pivot() apply_profile() _apply_icon_presentation(neutral_size) func apply_layout( center: Vector2, bubble_size: Vector2, font_size_ratio: float, ) -> void: position = center - bubble_size * 0.5 size = bubble_size neutral_position = position presented_size = bubble_size _update_pivot() _apply_icon_presentation(bubble_size) var label_control: Control = get_label_control() var font_size := clampi( roundi(minf(bubble_size.x, bubble_size.y) * font_size_ratio), minimum_font_size, maximum_font_size ) label_control.add_theme_font_size_override("font_size", font_size) func get_layout_size(layout_scale: float, compact: bool) -> Vector2: var bubble_size: Vector2 = neutral_size * layout_scale if compact: bubble_size.x = maxf(bubble_size.x, compact_minimum_size.x) bubble_size.y = maxf(bubble_size.y, compact_minimum_size.y) if _uses_icon_only_presentation(): var diameter: float = maxf(bubble_size.x, bubble_size.y) bubble_size = Vector2(diameter, diameter) return bubble_size func get_authored_anchor(compact: bool) -> Vector2: return compact_anchor if compact else desktop_anchor func get_label_control() -> Control: if not label_control_path.is_empty(): var custom_label := get_node_or_null(label_control_path) as Control if custom_label != null: return custom_label return self func _uses_icon_only_presentation() -> bool: return icon != null and text.is_empty() and label_control_path.is_empty() func _apply_icon_presentation(bubble_size: Vector2) -> void: if not _uses_icon_only_presentation(): return alignment = HORIZONTAL_ALIGNMENT_CENTER icon_alignment = HORIZONTAL_ALIGNMENT_CENTER vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER expand_icon = true texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST add_theme_constant_override( "icon_max_width", maxi(1, roundi(minf(bubble_size.x, bubble_size.y) * ICON_FILL_RATIO)), ) func advance_emphasis(delta: float) -> void: var target: float = 1.0 if _hovered or _focused else 0.0 emphasis = move_toward( emphasis, target, profile.emphasis_speed * delta ) func calculate_target( elapsed: float, layout_scale: float, motion_scale: float, ) -> Vector2: var phase: float = ( elapsed / motion_period * TAU + motion_phase ) var idle_offset := Vector2( sin(phase * 0.73 + motion_phase) * horizontal_amplitude, sin(phase) * vertical_amplitude ) * layout_scale * motion_scale idle_offset.y -= ( profile.hover_focus_lift * emphasis * layout_scale ) return neutral_position + idle_offset func calculate_visual_scale( elapsed: float, motion_scale: float, ) -> Vector2: var deformation_phase: float = ( elapsed / deformation_period * TAU + motion_phase * 1.37 ) var deformation_amount: float = ( sin(deformation_phase) * deformation_amplitude * motion_scale * lerpf(1.0, profile.emphasized_deformation_scale, emphasis) ) if _uses_icon_only_presentation(): deformation_amount = 0.0 var hover_scale: float = lerpf( 1.0, profile.hover_focus_scale, emphasis ) return Vector2( 1.0 + deformation_amount, 1.0 - deformation_amount ) * hover_scale func get_visual_radius(visual_scale: Vector2) -> float: var visual_size: Vector2 = presented_size * visual_scale return (visual_size.x + visual_size.y) * 0.25 func apply_presentation( target_position: Vector2, visual_scale: Vector2, position_weight: float, ) -> void: position = position.lerp(target_position, position_weight) scale = visual_scale func _has_point(point: Vector2) -> bool: var radius: Vector2 = size * 0.5 if radius.x <= 0.0 or radius.y <= 0.0: return false var normalized: Vector2 = (point - radius) / radius return normalized.length_squared() <= 1.0 func _set_hovered(value: bool) -> void: _hovered = value func _set_focused(value: bool) -> void: _focused = value func _update_pivot() -> void: pivot_offset = size * 0.5 func apply_profile() -> void: if profile == null: return add_theme_stylebox_override("normal", profile.make_normal_style()) var hover_style: StyleBoxFlat = profile.make_hover_style() add_theme_stylebox_override("hover", hover_style) add_theme_stylebox_override("focus", hover_style) add_theme_stylebox_override("pressed", profile.make_pressed_style()) add_theme_stylebox_override("disabled", profile.make_disabled_style()) add_theme_color_override("font_color", profile.text_color) add_theme_color_override("font_hover_color", profile.text_hover_color) add_theme_color_override("font_focus_color", profile.text_hover_color) add_theme_color_override("font_pressed_color", profile.text_pressed_color) add_theme_color_override("font_disabled_color", profile.text_disabled_color) var label_control: Control = get_label_control() if label_control != self: label_control.add_theme_color_override("font_color", profile.text_color)