227 lines
6.6 KiB
GDScript
227 lines
6.6 KiB
GDScript
# MobileTouchButton —— a mouse and screen-touch friendly action surface.
|
|
#
|
|
# BaseButton's mouse emulation is intentionally disabled in project.godot, so
|
|
# the mobile layer owns the small amount of press/release handling it needs.
|
|
# This keeps a touch index alive until release and gives gameplay a reliable
|
|
# cancellation path when the app loses focus.
|
|
extends Control
|
|
|
|
signal pressed
|
|
signal released
|
|
signal press_state(down: bool)
|
|
signal long_pressed
|
|
signal drag_started
|
|
signal drag_changed(delta: Vector2)
|
|
signal drag_released(delta: Vector2)
|
|
|
|
var label_text := ""
|
|
var accent := Color(0.36, 0.64, 0.95, 0.95)
|
|
var idle_modulate := Color.WHITE
|
|
var circular := true
|
|
var active := false
|
|
var _touch_index := -1
|
|
var _caption: Label
|
|
var _press_position := Vector2.ZERO
|
|
var _last_position := Vector2.ZERO
|
|
var _drag_delta := Vector2.ZERO
|
|
var _long_press_token := 0
|
|
var _long_pressed := false
|
|
var _dragging := false
|
|
var _aiming := false
|
|
const LONG_PRESS_SECONDS := 0.55
|
|
const DRAG_THRESHOLD := 10.0
|
|
|
|
func setup(text: String, color: Color = Color(0.36, 0.64, 0.95, 0.95)) -> void:
|
|
label_text = text
|
|
accent = color
|
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
|
custom_minimum_size = Vector2(56, 56)
|
|
_caption = Label.new()
|
|
_caption.text = label_text
|
|
_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
_caption.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
_caption.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_caption.add_theme_font_size_override("font_size", 13)
|
|
_caption.add_theme_color_override("font_color", Color(0.94, 0.97, 1.0))
|
|
_caption.add_theme_color_override("font_outline_color", Color(0.02, 0.03, 0.05, 0.95))
|
|
_caption.add_theme_constant_override("outline_size", 3)
|
|
add_child(_caption)
|
|
queue_redraw()
|
|
|
|
func set_caption(text: String) -> void:
|
|
label_text = text
|
|
if _caption:
|
|
_caption.text = text
|
|
|
|
func set_idle_modulate(value: Color) -> void:
|
|
idle_modulate = value
|
|
modulate = value if not active else Color(0.82, 0.9, 1.0, value.a)
|
|
|
|
## Enables the long-press/drag gesture used by mobile skill aiming and
|
|
## inventory movement. The ordinary pressed/released signals remain intact
|
|
## for buttons that do not need a gesture.
|
|
func set_gesture_enabled(enabled: bool) -> void:
|
|
set_meta("gesture_enabled", enabled)
|
|
|
|
func was_long_pressed() -> bool:
|
|
return _long_pressed
|
|
|
|
func is_dragging() -> bool:
|
|
return _dragging
|
|
|
|
func drag_total() -> Vector2:
|
|
return _drag_delta
|
|
|
|
func drag_end_position() -> Vector2:
|
|
return _last_position
|
|
|
|
func set_aiming(value: bool) -> void:
|
|
_aiming = value
|
|
queue_redraw()
|
|
|
|
func set_rect_style() -> void:
|
|
circular = false
|
|
queue_redraw()
|
|
|
|
func _ready() -> void:
|
|
set_process_unhandled_input(true)
|
|
queue_redraw()
|
|
|
|
func _gui_input(event: InputEvent) -> void:
|
|
if event is InputEventScreenTouch:
|
|
if event.pressed:
|
|
if _touch_index < 0:
|
|
_touch_index = event.index
|
|
_begin_press(_event_position(event))
|
|
else:
|
|
if event.index == _touch_index:
|
|
_touch_index = -1
|
|
_end_press(_event_position(event))
|
|
accept_event()
|
|
return
|
|
if event is InputEventScreenDrag and event.index == _touch_index:
|
|
_update_drag(_event_position(event))
|
|
accept_event()
|
|
return
|
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
|
if event.pressed:
|
|
_touch_index = -2
|
|
_begin_press(_event_position(event))
|
|
else:
|
|
_touch_index = -1
|
|
_end_press(_event_position(event))
|
|
accept_event()
|
|
return
|
|
if event is InputEventMouseMotion and _touch_index == -2:
|
|
_update_drag(_event_position(event))
|
|
accept_event()
|
|
|
|
func _event_position(event: InputEvent) -> Vector2:
|
|
if event is InputEventMouse:
|
|
return (event as InputEventMouse).position
|
|
if event is InputEventScreenTouch:
|
|
return (event as InputEventScreenTouch).position
|
|
if event is InputEventScreenDrag:
|
|
return (event as InputEventScreenDrag).position
|
|
return Vector2.ZERO
|
|
|
|
func _begin_press(position: Vector2) -> void:
|
|
if active:
|
|
return
|
|
active = true
|
|
_press_position = position
|
|
_last_position = position
|
|
_drag_delta = Vector2.ZERO
|
|
_long_pressed = false
|
|
_dragging = false
|
|
_aiming = false
|
|
_long_press_token += 1
|
|
var token := _long_press_token
|
|
if bool(get_meta("gesture_enabled", false)) and get_tree() != null:
|
|
get_tree().create_timer(LONG_PRESS_SECONDS).timeout.connect(func():
|
|
if token != _long_press_token or not active or _long_pressed:
|
|
return
|
|
_long_pressed = true
|
|
_dragging = true
|
|
long_pressed.emit()
|
|
queue_redraw())
|
|
modulate = Color(0.82, 0.9, 1.0, idle_modulate.a)
|
|
pressed.emit()
|
|
press_state.emit(true)
|
|
queue_redraw()
|
|
|
|
func _update_drag(position: Vector2) -> void:
|
|
if not active:
|
|
return
|
|
var delta := position - _last_position
|
|
_last_position = position
|
|
_drag_delta = position - _press_position
|
|
if not bool(get_meta("gesture_enabled", false)):
|
|
return
|
|
if not _long_pressed:
|
|
if _drag_delta.length() > DRAG_THRESHOLD:
|
|
_long_press_token += 1
|
|
return
|
|
if not _dragging:
|
|
_dragging = true
|
|
drag_started.emit()
|
|
drag_changed.emit(delta)
|
|
queue_redraw()
|
|
|
|
func _end_press(position: Vector2) -> void:
|
|
if not active:
|
|
return
|
|
_update_drag(position)
|
|
_long_press_token += 1
|
|
if _dragging and _long_pressed:
|
|
drag_released.emit(_drag_delta)
|
|
active = false
|
|
modulate = idle_modulate
|
|
released.emit()
|
|
press_state.emit(false)
|
|
queue_redraw()
|
|
|
|
func cancel_press() -> void:
|
|
_long_press_token += 1
|
|
_touch_index = -1
|
|
if active:
|
|
# A cancelled gesture must never look like a completed drag to its
|
|
# receiver. Clear the gesture flags before ending the visual press.
|
|
_dragging = false
|
|
_long_pressed = false
|
|
_aiming = false
|
|
_end_press(_last_position)
|
|
else:
|
|
_long_pressed = false
|
|
_dragging = false
|
|
_aiming = false
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
# A focus loss / window pause can leave a touch-up event outside the
|
|
# control. MobileUiRoot also calls cancel_all() for the full overlay.
|
|
if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index:
|
|
_touch_index = -1
|
|
_end_press(_last_position)
|
|
|
|
func _draw() -> void:
|
|
if not circular:
|
|
var rect_style := StyleBoxFlat.new()
|
|
rect_style.bg_color = Color(0.06, 0.1, 0.16, 0.86)
|
|
rect_style.border_color = accent
|
|
rect_style.set_border_width_all(1)
|
|
rect_style.set_corner_radius_all(6)
|
|
draw_style_box(rect_style, Rect2(Vector2.ZERO, size))
|
|
return
|
|
var center := size * 0.5
|
|
var radius := minf(size.x, size.y) * 0.5 - 3.0
|
|
if radius <= 0.0:
|
|
return
|
|
draw_circle(center, radius, Color(0.025, 0.04, 0.07, 0.68))
|
|
draw_arc(center, radius, 0.0, TAU, 48, accent, 3.0 if active or _aiming else 2.0)
|
|
draw_arc(center, radius - 5.0, 0.0, TAU, 48, Color(accent, 0.22), 1.0)
|
|
if _aiming:
|
|
draw_circle(center, 5.0, Color(1.0, 0.88, 0.52, 0.9))
|
|
draw_arc(center, radius - 12.0, -PI * 0.5, PI * 0.5, 24,
|
|
Color(1.0, 0.88, 0.52, 0.8), 2.0)
|