Extract reusable bubble menu components

This commit is contained in:
Alexander Sellite 2026-07-27 14:42:57 -04:00
parent c7504d2585
commit 053cfe037d
11 changed files with 646 additions and 54 deletions

View file

@ -0,0 +1,26 @@
# Bubble menu authoring
Instance `bubble_button.tscn` for each action, or attach `bubble_button.gd`
to an existing Button. Set its neutral size, desktop and compact anchors,
font-size limits, and deterministic motion values in the Inspector. A label
child can be assigned through `label_control_path` when wrapped text is needed.
Text size is calculated once per layout from the neutral smaller dimension and
the profile ratio.
Place the buttons under a Control using `bubble_cluster.gd`. Give the cluster
the ordered BubbleButton references with `configure()`, then call
`apply_layout()` when its available size or responsive layout changes. The
button order defines explicit keyboard and controller focus neighbors. Compact
anchors and minimum sizes remain authored per button.
The shared profile owns the palette, rounded styles, proportional-font ratio,
hover response, and contact tuning. Labels, actions, sizes, anchors, per-button
motion, responsive wrapping, availability, and confirmation behavior remain
owned by the menu. Connect each Button's `pressed` signal in that parent menu.
`motion_scale` defaults to `1.0`. Setting it to `0.0` removes idle drift and
deformation while retaining hover and focus feedback.
Contact is a deterministic, bounded visual correction around authored anchors.
Real physics is intentionally avoided so layouts, focus order, and hit targets
remain stable.

View file

@ -0,0 +1,186 @@
class_name BubbleButton
extends Button
@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()
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()
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)
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 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)
)
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)

View file

@ -0,0 +1 @@
uid://d15kke1iwoueg

View file

@ -0,0 +1,9 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_button.gd" id="1_button"]
[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="2_profile"]
[node name="BubbleButton" type="Button"]
custom_minimum_size = Vector2(120, 116)
script = ExtResource("1_button")
profile = ExtResource("2_profile")

View file

@ -0,0 +1,117 @@
class_name BubbleCluster
extends Control
@export var profile: BubbleMenuProfile
@export var desktop_reference_size: Vector2 = Vector2(396.0, 318.0)
@export var compact_reference_size: Vector2 = Vector2(294.0, 200.0)
@export_range(0.0, 1.0, 0.01) var motion_scale: float = 1.0
var _bubbles: Array[BubbleButton] = []
var _elapsed: float = 0.0
var _layout_scale: float = 1.0
func configure(bubbles: Array[BubbleButton]) -> void:
_bubbles = bubbles
for index: int in _bubbles.size():
var bubble: BubbleButton = _bubbles[index]
if bubble.profile == null:
bubble.profile = profile
bubble.apply_profile()
if index > 0:
bubble.focus_neighbor_top = bubble.get_path_to(
_bubbles[index - 1]
)
if index + 1 < _bubbles.size():
bubble.focus_neighbor_bottom = bubble.get_path_to(
_bubbles[index + 1]
)
func apply_layout(field_size: Vector2, compact: bool) -> void:
_layout_scale = minf(
field_size.x / desktop_reference_size.x,
field_size.y / desktop_reference_size.y
)
var authored_extent: Vector2 = desktop_reference_size * _layout_scale
var layout_origin := Vector2(
(field_size.x - authored_extent.x) * 0.5,
(field_size.y - authored_extent.y) * 0.5
)
for bubble: BubbleButton in _bubbles:
var bubble_size: Vector2 = bubble.get_layout_size(
_layout_scale,
compact
)
var center: Vector2 = (
layout_origin
+ bubble.get_authored_anchor(compact) * _layout_scale
)
bubble.apply_layout(
center,
bubble_size,
profile.font_size_ratio
)
func advance_motion(delta: float) -> void:
_elapsed = fmod(_elapsed + delta, 120.0)
var targets: Array[Vector2] = []
var visual_radii: Array[float] = []
var visual_scales: Array[Vector2] = []
for bubble: BubbleButton in _bubbles:
bubble.advance_emphasis(delta)
var target: Vector2 = bubble.calculate_target(
_elapsed,
_layout_scale,
motion_scale
)
var visual_scale: Vector2 = bubble.calculate_visual_scale(
_elapsed,
motion_scale
)
targets.append(target)
visual_scales.append(visual_scale)
visual_radii.append(bubble.get_visual_radius(visual_scale))
for first_index: int in _bubbles.size():
for second_index: int in range(first_index + 1, _bubbles.size()):
var first_center: Vector2 = (
targets[first_index]
+ _bubbles[first_index].presented_size * 0.5
)
var second_center: Vector2 = (
targets[second_index]
+ _bubbles[second_index].presented_size * 0.5
)
var center_delta: Vector2 = second_center - first_center
var distance: float = center_delta.length()
var desired_distance: float = (
visual_radii[first_index]
+ visual_radii[second_index]
+ profile.contact_gap * _layout_scale
)
if distance >= desired_distance:
continue
var direction := (
center_delta / distance
if distance > 0.001
else Vector2.RIGHT.rotated(float(first_index + 1))
)
var correction: Vector2 = (
direction
* minf(
(desired_distance - distance) * 0.5,
profile.maximum_separation * _layout_scale
)
)
targets[first_index] -= correction
targets[second_index] += correction
var position_weight: float = 1.0 - exp(
-profile.position_response * delta
)
for index: int in _bubbles.size():
_bubbles[index].apply_presentation(
targets[index],
visual_scales[index],
position_weight
)

View file

@ -0,0 +1 @@
uid://cvqif55pw6r32

View file

@ -0,0 +1,91 @@
class_name BubbleMenuProfile
extends Resource
@export_group("Typography")
@export_range(0.01, 1.0, 0.001) var font_size_ratio: float = 0.17
@export_group("Interaction")
@export_range(1.0, 1.2, 0.001) var hover_focus_scale: float = 1.035
@export_range(0.0, 16.0, 0.1) var hover_focus_lift: float = 2.0
@export_range(0.1, 30.0, 0.1) var emphasis_speed: float = 10.0
@export_range(0.0, 1.0, 0.01) var emphasized_deformation_scale: float = 0.6
@export_group("Cluster")
@export_range(0.1, 30.0, 0.1) var position_response: float = 8.0
@export_range(0.0, 24.0, 0.1) var contact_gap: float = 3.0
@export_range(0.0, 24.0, 0.1) var maximum_separation: float = 3.0
@export_group("Shape")
@export_range(0.0, 32.0, 0.5) var content_margin: float = 8.0
@export_range(0, 512, 1) var corner_radius: int = 128
@export_range(0, 12, 1) var normal_border_width: int = 2
@export_range(0, 12, 1) var emphasized_border_width: int = 3
@export_group("Colors")
@export var normal_fill: Color = Color(0.82, 0.93, 0.96, 0.96)
@export var normal_border: Color = Color(0.91, 0.98, 1.0, 0.85)
@export var hover_fill: Color = Color(0.91, 0.975, 0.985, 1.0)
@export var hover_border: Color = Color(1.0, 1.0, 1.0, 0.95)
@export var pressed_fill: Color = Color(0.7, 0.87, 0.92, 1.0)
@export var pressed_border: Color = Color(0.94, 0.99, 1.0, 1.0)
@export var disabled_fill: Color = Color(0.66, 0.77, 0.8, 0.78)
@export var disabled_border: Color = Color(0.81, 0.9, 0.92, 0.55)
@export var text_color: Color = Color(0.035, 0.145, 0.22, 1.0)
@export var text_hover_color: Color = Color(0.025, 0.12, 0.19, 1.0)
@export var text_pressed_color: Color = Color(0.025, 0.11, 0.17, 1.0)
@export var text_disabled_color: Color = Color(0.16, 0.25, 0.29, 0.72)
func make_normal_style() -> StyleBoxFlat:
return _make_style(
normal_fill,
normal_border,
normal_border_width
)
func make_hover_style() -> StyleBoxFlat:
return _make_style(
hover_fill,
hover_border,
emphasized_border_width
)
func make_pressed_style() -> StyleBoxFlat:
return _make_style(
pressed_fill,
pressed_border,
emphasized_border_width
)
func make_disabled_style() -> StyleBoxFlat:
return _make_style(
disabled_fill,
disabled_border,
normal_border_width
)
func _make_style(
fill_color: Color,
outline_color: Color,
outline_width: int,
) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.content_margin_left = content_margin
style.content_margin_top = content_margin
style.content_margin_right = content_margin
style.content_margin_bottom = content_margin
style.bg_color = fill_color
style.border_width_left = outline_width
style.border_width_top = outline_width
style.border_width_right = outline_width
style.border_width_bottom = outline_width
style.border_color = outline_color
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

View file

@ -0,0 +1 @@
uid://ccliyvoivfimr

View file

@ -0,0 +1,6 @@
[gd_resource type="Resource" script_class="BubbleMenuProfile" load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_menu_profile.gd" id="1_profile"]
[resource]
script = ExtResource("1_profile")