7const DEFAULT_OPTIONS := {
15 "fill_parent_height": false,
17 "analog_position":
Vector2(0.16, 0.76),
18 "analog_radius": 66.0,
19 "analog_knob_radius": 25.0,
20 "analog_deadzone": 0.12,
21 "analog_axis":
"free",
22 "repositionable": true,
24 "action_button_size":
Vector2(58, 54),
25 "action_margin": 14.0,
27 {
"id":
"up",
"label":
"Up",
"action":
"move_up"},
28 {
"id":
"down",
"label":
"Down",
"action":
"move_down"}
32signal analog_position_changed(position:Vector2)
33signal action_pressed(action_id:String)
36 signal vector_changed(value:Vector2)
37 signal reposition_dragged(relative:Vector2)
38 var radius:float = 66.0
39 var knob_radius:float = 25.0
40 var deadzone:float = 0.12
41 var opacity:float = 0.42
42 var repositionable:bool = true
43 var axis_mode:String =
"free"
44 var value := Vector2.ZERO
45 var active_pointer := -1
46 var repositioning := false
48 func configure(config:Dictionary) -> void:
49 radius = maxf(36.0,
float(config.get(
"analog_radius", radius)))
50 knob_radius = clampf(
float(config.get(
"analog_knob_radius", knob_radius)), 14.0, radius * 0.55)
51 deadzone = clampf(
float(config.get(
"analog_deadzone", deadzone)), 0.0, 0.75)
52 opacity = clampf(
float(config.get(
"opacity", opacity)), 0.08, 0.85)
53 repositionable =
bool(config.get(
"repositionable", true))
54 axis_mode = str(config.get(
"analog_axis",
"free"))
55 if axis_mode
not in [
"free",
"vertical",
"horizontal"]:
57 custom_minimum_size = Vector2.ONE * (radius * 2.0 + 56.0)
58 size = custom_minimum_size
59 mouse_filter = Control.MOUSE_FILTER_STOP
60 focus_mode = Control.FOCUS_NONE
63 func _center() -> Vector2:
64 return Vector2.ONE * (radius + 28.0)
66 func _handle_center() -> Vector2:
67 if axis_mode ==
"vertical":
68 return _center() +
Vector2(radius * 0.54 + 18.0, -radius - 9.0)
69 if axis_mode ==
"horizontal":
70 return _center() +
Vector2(radius + 9.0, -radius * 0.54 - 18.0)
71 return _center() +
Vector2(radius * 0.92, -radius * 0.92)
74 var center := _center()
75 if axis_mode ==
"free":
76 draw_circle(center, radius, Color(0.04, 0.08, 0.11, opacity))
77 draw_circle(center, radius, Color(0.82, 0.90, 0.92, minf(0.72, opacity + 0.20)), false, 3.0)
78 draw_circle(center, radius * deadzone, Color(0.65, 0.76, 0.78, opacity * 0.45), false, 1.0)
80 var half_width := radius * 0.48
81 var track_rect := Rect2()
82 if axis_mode ==
"vertical":
83 track_rect = Rect2(center -
Vector2(half_width, radius),
Vector2(half_width * 2.0, radius * 2.0))
85 track_rect = Rect2(center -
Vector2(radius, half_width),
Vector2(radius * 2.0, half_width * 2.0))
86 var track := StyleBoxFlat.new()
87 track.bg_color = Color(0.04, 0.08, 0.11, opacity)
88 track.border_color = Color(0.82, 0.90, 0.92, minf(0.72, opacity + 0.20))
89 track.set_border_width_all(3)
90 track.set_corner_radius_all(
int(half_width))
91 draw_style_box(track, track_rect)
92 var knob_center := center + value * (radius - knob_radius * 0.55)
93 draw_circle(knob_center, knob_radius, Color(0.65, 0.82, 0.84, minf(0.88, opacity + 0.30)))
94 draw_circle(knob_center, knob_radius, Color(1, 1, 1, minf(0.9, opacity + 0.35)), false, 2.0)
96 var handle := _handle_center()
97 draw_circle(handle, 14.0, Color(0.08, 0.13, 0.18, minf(0.86, opacity + 0.28)))
99 draw_circle(handle + offset, 1.7, Color(1, 1, 1, 0.82))
101 func _gui_input(event:InputEvent) -> void:
102 if event
is InputEventScreenTouch:
103 if event.pressed
and active_pointer < 0:
104 active_pointer = event.index
105 _begin_pointer(event.position)
106 elif not event.pressed
and event.index == active_pointer:
109 elif event
is InputEventScreenDrag
and event.index == active_pointer:
110 _update_pointer(event.position, event.relative)
112 elif event
is InputEventMouseButton
and event.button_index == MOUSE_BUTTON_LEFT:
115 _begin_pointer(event.position)
119 elif event
is InputEventMouseMotion
and active_pointer == 0
and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
120 _update_pointer(event.position, event.relative)
123 func _begin_pointer(local_position:Vector2) -> void:
124 repositioning = repositionable
and local_position.distance_to(_handle_center()) <= 20.0
125 if not repositioning:
126 _set_value_from_position(local_position)
128 func _update_pointer(local_position:Vector2, relative:Vector2) -> void:
130 reposition_dragged.emit(relative)
132 _set_value_from_position(local_position)
134 func _end_pointer() -> void:
136 repositioning = false
137 _set_value(Vector2.ZERO)
139 func _set_value_from_position(local_position:Vector2) -> void:
140 var raw := (local_position - _center()) / radius
141 if axis_mode ==
"vertical":
143 elif axis_mode ==
"horizontal":
145 var magnitude := minf(1.0, raw.length())
146 if magnitude <= deadzone:
147 _set_value(Vector2.ZERO)
149 var scaled_magnitude := (magnitude - deadzone) / maxf(0.001, 1.0 - deadzone)
150 _set_value(raw.normalized() * scaled_magnitude)
152 func _set_value(next_value:Vector2) -> void:
153 if axis_mode ==
"vertical":
155 elif axis_mode ==
"horizontal":
157 value = next_value.limit_length(1.0)
158 vector_changed.emit(value)
162var options := DEFAULT_OPTIONS.duplicate(true)
165var queued_presses := []
166var analog_pad:AnalogPad
167var analog_vector := Vector2.ZERO
168var overlays_created:int = 0
169var visibility_changes:int = 0
172func attach(parent:Control, next_options:Dictionary = {}) -> Control:
173 if overlay != null
and is_instance_valid(overlay)
and overlay.get_parent() != null:
174 overlay.get_parent().remove_child(overlay)
175 overlay = create_overlay(next_options)
176 parent.add_child(overlay)
180func create_overlay(next_options:Dictionary = {}) -> Control:
181 options = DEFAULT_OPTIONS.duplicate(true)
182 for key
in next_options.keys():
183 options[key] = next_options[key]
186 queued_presses.clear()
187 var root = Control.new()
188 overlays_created += 1
189 root.name =
"OnscreenControls"
192 root.mouse_filter = Control.MOUSE_FILTER_IGNORE
193 root.set_anchors_preset(Control.PRESET_FULL_RECT)
194 root.visible = bool(options.get(
"visible", true))
197 if str(options.get(
"layout",
"vertical")) ==
"analog":
198 analog_pad = AnalogPad.new()
199 analog_pad.configure(options)
200 analog_pad.vector_changed.connect(func(value): analog_vector = value)
201 analog_pad.reposition_dragged.connect(_move_analog_pad)
203 elif str(options.get(
"layout",
"vertical")) ==
"dpad":
204 pad = GridContainer.new()
205 pad.set(
"columns", 3)
207 pad = VBoxContainer.new()
209 pad.mouse_filter = Control.MOUSE_FILTER_PASS
210 pad.add_theme_constant_override(
"separation",
int(options.get(
"gap", 10.0)))
212 _apply_pad_layout(pad)
214 if str(options.get(
"layout",
"vertical")) ==
"analog":
216 elif str(options.get(
"layout",
"vertical")) ==
"dpad":
219 for control
in options.get(
"controls", []):
220 var button = _make_button(control)
221 pad.add_child(button)
222 _build_action_bar(root)
226func set_visible(enabled:bool) -> void:
227 if is_visible() == enabled
and bool(options.get(
"visible", true)) == enabled:
229 options[
"visible"] = enabled
230 visibility_changes += 1
231 if overlay != null
and is_instance_valid(overlay):
232 overlay.visible = enabled
235 queued_presses.clear()
236 analog_vector = Vector2.ZERO
237 if analog_pad != null
and is_instance_valid(analog_pad):
238 analog_pad._end_pointer()
241func is_visible() -> bool:
242 if overlay == null
or not is_instance_valid(overlay):
243 return bool(options.get(
"visible", true))
244 return overlay.visible
247func get_axis(negative_id:String =
"up", positive_id:String =
"down") -> float:
248 if str(options.get(
"layout",
"vertical")) ==
"analog":
249 if negative_id ==
"left" and positive_id ==
"right":
250 return analog_vector.x
251 if negative_id ==
"up" and positive_id ==
"down":
252 return analog_vector.y
254 if bool(pressed.get(negative_id, false)):
256 if bool(pressed.get(positive_id, false)):
261func get_vector(left_id:String =
"left", right_id:String =
"right", up_id:String =
"up", down_id:String =
"down") -> Vector2:
262 if str(options.get(
"layout",
"vertical")) ==
"analog":
265 get_axis(left_id, right_id),
266 get_axis(up_id, down_id)
272func consume_vector(left_id:String =
"left", right_id:String =
"right", up_id:String =
"up", down_id:String =
"down") -> Vector2:
273 while not queued_presses.is_empty():
274 var control_id = str(queued_presses.pop_front())
275 if control_id == left_id:
277 if control_id == right_id:
279 if control_id == up_id:
281 if control_id == down_id:
286func get_state() -> Dictionary:
288 "visible": bool(options.get(
"visible", true)),
289 "side": str(options.get(
"side",
"left")),
290 "anchor_y":
float(options.get(
"anchor_y", 0.5)),
291 "margin":
float(options.get(
"margin", 14.0)),
292 "button_size": options.get(
"button_size",
Vector2(72, 64)),
293 "gap":
float(options.get(
"gap", 10.0)),
294 "opacity":
float(options.get(
"opacity", 0.34)),
295 "fill_parent_height": bool(options.get(
"fill_parent_height", false)),
296 "layout": str(options.get(
"layout",
"vertical")),
297 "analog_position": options.get(
"analog_position",
Vector2(0.16, 0.76)),
298 "analog_radius":
float(options.get(
"analog_radius", 66.0)),
299 "analog_deadzone":
float(options.get(
"analog_deadzone", 0.12)),
300 "analog_axis": str(options.get(
"analog_axis",
"free")),
301 "actions": options.get(
"actions", []),
302 "controls": options.get(
"controls", [])
307func get_stats() -> Dictionary:
309 "overlays_created": overlays_created,
310 "buttons": buttons.size(),
311 "analog": 1
if analog_pad != null
and is_instance_valid(analog_pad)
else 0,
312 "analog_strength": analog_vector.length(),
313 "queued_presses": queued_presses.size(),
314 "visibility_changes": visibility_changes,
315 "visible": is_visible()
319func get_cache_stats() -> Dictionary:
323func apply_state(state:Dictionary) -> void:
324 for key
in state.keys():
325 options[key] = state[key]
326 if overlay != null
and is_instance_valid(overlay):
327 overlay.visible = bool(options.get(
"visible", true))
328 var pad = overlay.get_node_or_null(
"Pad")
331 pad.configure(options)
332 _apply_pad_layout(pad)
333 for child
in pad.get_children():
338func set_layout(next_options:Dictionary) -> void:
339 apply_state(next_options)
342func _make_button(control:Dictionary) -> Button:
343 var control_id = str(control.get(
"id",
"control"))
344 pressed[control_id] = false
345 var button = Button.new()
346 button.name =
"%sButton" % control_id.capitalize()
347 button.text = str(control.get(
"label", control_id.capitalize()))
348 button.focus_mode = Control.FOCUS_NONE
349 button.custom_minimum_size = options.get(
"button_size",
Vector2(72, 64))
350 if bool(options.get(
"fill_parent_height", false)):
351 button.custom_minimum_size.y = 0
352 button.size_flags_vertical = Control.SIZE_EXPAND_FILL
353 button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
354 button.mouse_filter = Control.MOUSE_FILTER_STOP
355 buttons[control_id] = button
356 button.button_down.connect(func():
357 pressed[control_id] = true
358 queued_presses.append(control_id)
360 button.button_up.connect(func(): pressed[control_id] = false)
361 button.visibility_changed.connect(func():
362 if not button.visible:
363 pressed[control_id] = false
365 _style_button(button)
369func _build_action_bar(root:Control) -> void:
370 var actions:Array = options.get(
"actions", [])
371 if actions.is_empty():
373 var bar := HBoxContainer.new()
374 bar.name =
"ActionBar"
375 bar.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
376 bar.add_theme_constant_override(
"separation",
int(options.get(
"gap", 10.0)))
377 bar.mouse_filter = Control.MOUSE_FILTER_PASS
379 var button_size:Vector2 = options.get(
"action_button_size",
Vector2(58, 54))
380 var gap :=
float(options.get(
"gap", 10.0))
381 var width:float = button_size.x *
float(actions.size()) + gap *
float(maxi(0, actions.size() - 1))
382 var margin :=
float(options.get(
"action_margin", 14.0))
383 bar.offset_left = -width - margin
384 bar.offset_right = -margin
385 bar.offset_top = -button_size.y - margin
386 bar.offset_bottom = -margin
387 for action
in actions:
388 if not action
is Dictionary:
390 var action_id := str(action.get(
"id",
"action"))
391 var button := Button.new()
392 button.name =
"%sAction" % action_id.capitalize()
393 button.text = str(action.get(
"label", action_id.capitalize()))
394 button.tooltip_text = str(action.get(
"tooltip", button.text))
395 button.custom_minimum_size = button_size
396 button.focus_mode = Control.FOCUS_NONE
397 button.mouse_filter = Control.MOUSE_FILTER_STOP
398 var icon_path := str(action.get(
"icon",
""))
399 if icon_path !=
"" and ResourceLoader.exists(icon_path):
400 button.icon = load(icon_path)
402 button.expand_icon = true
403 button.pressed.connect(func(): action_pressed.emit(action_id))
404 _style_button(button)
405 bar.add_child(button)
408func _build_dpad(pad:Control) -> void:
409 var controls_by_id := {}
410 for control
in options.get(
"controls", []):
411 controls_by_id[str(control.get(
"id",
""))] = control
413 {}, controls_by_id.get(
"up", {
"id":
"up",
"label":
"Up"}), {},
414 controls_by_id.get(
"left", {
"id":
"left",
"label":
"Left"}), {}, controls_by_id.get(
"right", {
"id":
"right",
"label":
"Right"}),
415 {}, controls_by_id.get(
"down", {
"id":
"down",
"label":
"Down"}), {}
419 var spacer = Control.new()
420 spacer.custom_minimum_size = options.get(
"button_size",
Vector2(72, 64))
421 pad.add_child(spacer)
423 pad.add_child(_make_button(cell))
426func _apply_pad_layout(pad:Control) -> void:
427 if str(options.get(
"layout",
"vertical")) ==
"analog":
428 var position_value:Vector2 = options.get(
"analog_position",
Vector2(0.16, 0.76))
429 pad.anchor_left = clampf(position_value.x, 0.0, 1.0)
430 pad.anchor_right = pad.anchor_left
431 pad.anchor_top = clampf(position_value.y, 0.0, 1.0)
432 pad.anchor_bottom = pad.anchor_top
433 pad.offset_left = -pad.custom_minimum_size.x * 0.5
434 pad.offset_right = pad.custom_minimum_size.x * 0.5
435 pad.offset_top = -pad.custom_minimum_size.y * 0.5
436 pad.offset_bottom = pad.custom_minimum_size.y * 0.5
438 var button_size = options.get(
"button_size",
Vector2(72, 64))
439 var controls_count = max(1, options.get(
"controls", []).size())
440 var is_dpad = str(options.get(
"layout",
"vertical")) ==
"dpad"
442 var height =
float(button_size.y) * controls_count +
float(options.get(
"gap", 10.0)) * max(0, controls_count - 1)
444 width =
float(button_size.x) * 3.0 +
float(options.get(
"gap", 10.0)) * 2.0
445 height =
float(button_size.y) * 3.0 +
float(options.get(
"gap", 10.0)) * 2.0
446 var margin =
float(options.get(
"margin", 14.0))
447 var anchor_y = clamp(
float(options.get(
"anchor_y", 0.5)), 0.0, 1.0)
448 if bool(options.get(
"fill_parent_height", false)):
449 pad.custom_minimum_size =
Vector2(width, 0)
451 pad.anchor_bottom = 1.0
452 pad.offset_top = margin
453 pad.offset_bottom = -margin
455 pad.custom_minimum_size =
Vector2(width, height)
456 pad.anchor_top = anchor_y
457 pad.anchor_bottom = anchor_y
458 pad.offset_top = -height * 0.5
459 pad.offset_bottom = height * 0.5
460 if str(options.get(
"side",
"left")) ==
"right":
461 pad.anchor_left = 1.0
462 pad.anchor_right = 1.0
463 pad.offset_left = -width - margin
464 pad.offset_right = -margin
466 pad.anchor_left = 0.0
467 pad.anchor_right = 0.0
468 pad.offset_left = margin
469 pad.offset_right = margin + width
472func _move_analog_pad(relative:Vector2) -> void:
473 if overlay == null
or overlay.size.x <= 0.0
or overlay.size.y <= 0.0:
475 var position_value:Vector2 = options.get(
"analog_position",
Vector2(0.16, 0.76))
476 position_value +=
Vector2(relative.x / overlay.size.x, relative.y / overlay.size.y)
477 var radius_fraction :=
Vector2(
478 analog_pad.custom_minimum_size.x * 0.5 / overlay.size.x,
479 analog_pad.custom_minimum_size.y * 0.5 / overlay.size.y
481 position_value.x = clampf(position_value.x, radius_fraction.x, 1.0 - radius_fraction.x)
482 position_value.y = clampf(position_value.y, radius_fraction.y, 1.0 - radius_fraction.y)
483 options[
"analog_position"] = position_value
484 _apply_pad_layout(analog_pad)
485 analog_position_changed.emit(position_value)
488func _style_button(button:Button) -> void:
489 var opacity = clamp(
float(options.get(
"opacity", 0.34)), 0.05, 0.85)
490 button.self_modulate = Color(1, 1, 1, opacity)
491 var normal = _make_style(Color(0.08, 0.13, 0.18, 1.0), Color(1.0, 1.0, 1.0, 0.55))
492 var hover = _make_style(Color(0.12, 0.20, 0.28, 1.0), Color(1.0, 1.0, 1.0, 0.7))
493 var pressed_style = _make_style(Color(0.22, 0.42, 0.58, 1.0), Color(1.0, 1.0, 1.0, 0.85))
494 button.add_theme_stylebox_override(
"normal", normal)
495 button.add_theme_stylebox_override(
"hover", hover)
496 button.add_theme_stylebox_override(
"pressed", pressed_style)
497 button.add_theme_stylebox_override(
"focus", hover)
498 button.add_theme_stylebox_override(
"disabled", normal)
499 button.add_theme_color_override(
"font_color", Color(1, 1, 1, 0.92))
500 button.add_theme_color_override(
"font_hover_color", Color.WHITE)
501 button.add_theme_color_override(
"font_pressed_color", Color.WHITE)
502 button.add_theme_font_size_override(
"font_size", 18)
505func _make_style(fill:Color, border:Color) -> StyleBoxFlat:
506 var style = StyleBoxFlat.new()
507 style.bg_color = fill
508 style.border_color = border
509 style.draw_center = true
510 style.set_border_width_all(1)
511 style.set_corner_radius_all(8)
512 style.content_margin_left = 4
513 style.content_margin_right = 4
514 style.content_margin_top = 4
515 style.content_margin_bottom = 4