RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
onscreen_control_manager.gd
Go to the documentation of this file.
1extends RefCounted
2
3
6
7const DEFAULT_OPTIONS := {
8 "visible": true,
9 "side": "left",
10 "anchor_y": 0.5,
11 "margin": 14.0,
12 "button_size": Vector2(72, 64),
13 "gap": 10.0,
14 "opacity": 0.34,
15 "fill_parent_height": false,
16 "layout": "vertical",
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,
23 "actions": [],
24 "action_button_size": Vector2(58, 54),
25 "action_margin": 14.0,
26 "controls": [
27 {"id": "up", "label": "Up", "action": "move_up"},
28 {"id": "down", "label": "Down", "action": "move_down"}
29 ]
30}
31
32signal analog_position_changed(position:Vector2)
33signal action_pressed(action_id:String)
34
35class AnalogPad extends Control:
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
47
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"]:
56 axis_mode = "free"
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
61 queue_redraw()
62
63 func _center() -> Vector2:
64 return Vector2.ONE * (radius + 28.0)
65
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)
72
73 func _draw() -> void:
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)
79 else:
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))
84 else:
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)
95 if repositionable:
96 var handle := _handle_center()
97 draw_circle(handle, 14.0, Color(0.08, 0.13, 0.18, minf(0.86, opacity + 0.28)))
98 for offset in [Vector2(-4,-4), Vector2(4,-4), Vector2(-4,4), Vector2(4,4)]:
99 draw_circle(handle + offset, 1.7, Color(1, 1, 1, 0.82))
100
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:
107 _end_pointer()
108 accept_event()
109 elif event is InputEventScreenDrag and event.index == active_pointer:
110 _update_pointer(event.position, event.relative)
111 accept_event()
112 elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
113 if event.pressed:
114 active_pointer = 0
115 _begin_pointer(event.position)
116 else:
117 _end_pointer()
118 accept_event()
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)
121 accept_event()
122
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)
127
128 func _update_pointer(local_position:Vector2, relative:Vector2) -> void:
129 if repositioning:
130 reposition_dragged.emit(relative)
131 else:
132 _set_value_from_position(local_position)
133
134 func _end_pointer() -> void:
135 active_pointer = -1
136 repositioning = false
137 _set_value(Vector2.ZERO)
138
139 func _set_value_from_position(local_position:Vector2) -> void:
140 var raw := (local_position - _center()) / radius
141 if axis_mode == "vertical":
142 raw.x = 0.0
143 elif axis_mode == "horizontal":
144 raw.y = 0.0
145 var magnitude := minf(1.0, raw.length())
146 if magnitude <= deadzone:
147 _set_value(Vector2.ZERO)
148 return
149 var scaled_magnitude := (magnitude - deadzone) / maxf(0.001, 1.0 - deadzone)
150 _set_value(raw.normalized() * scaled_magnitude)
151
152 func _set_value(next_value:Vector2) -> void:
153 if axis_mode == "vertical":
154 next_value.x = 0.0
155 elif axis_mode == "horizontal":
156 next_value.y = 0.0
157 value = next_value.limit_length(1.0)
158 vector_changed.emit(value)
159 queue_redraw()
160
161var overlay:Control
162var options := DEFAULT_OPTIONS.duplicate(true)
163var pressed := {}
164var buttons := {}
165var queued_presses := []
166var analog_pad:AnalogPad
167var analog_vector := Vector2.ZERO
168var overlays_created:int = 0
169var visibility_changes:int = 0
170
171
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)
177 return overlay
178
179
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]
184 pressed.clear()
185 buttons.clear()
186 queued_presses.clear()
187 var root = Control.new()
188 overlays_created += 1
189 root.name = "OnscreenControls"
190 # Empty overlay space must remain transparent to underlying sliders and UI.
191 # The analog pad and action buttons still stop their own pointer events.
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))
195
196 var pad:Control
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)
202 pad = analog_pad
203 elif str(options.get("layout", "vertical")) == "dpad":
204 pad = GridContainer.new()
205 pad.set("columns", 3)
206 else:
207 pad = VBoxContainer.new()
208 pad.name = "Pad"
209 pad.mouse_filter = Control.MOUSE_FILTER_PASS
210 pad.add_theme_constant_override("separation", int(options.get("gap", 10.0)))
211 root.add_child(pad)
212 _apply_pad_layout(pad)
213
214 if str(options.get("layout", "vertical")) == "analog":
215 pass
216 elif str(options.get("layout", "vertical")) == "dpad":
217 _build_dpad(pad)
218 else:
219 for control in options.get("controls", []):
220 var button = _make_button(control)
221 pad.add_child(button)
222 _build_action_bar(root)
223 return root
224
225
226func set_visible(enabled:bool) -> void:
227 if is_visible() == enabled and bool(options.get("visible", true)) == enabled:
228 return
229 options["visible"] = enabled
230 visibility_changes += 1
231 if overlay != null and is_instance_valid(overlay):
232 overlay.visible = enabled
233 if not enabled:
234 pressed.clear()
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()
239
240
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
245
246
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
253 var axis := 0.0
254 if bool(pressed.get(negative_id, false)):
255 axis -= 1.0
256 if bool(pressed.get(positive_id, false)):
257 axis += 1.0
258 return axis
259
260
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":
263 return analog_vector
264 return Vector2(
265 get_axis(left_id, right_id),
266 get_axis(up_id, down_id)
267 )
268
269
270
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:
276 return Vector2.LEFT
277 if control_id == right_id:
278 return Vector2.RIGHT
279 if control_id == up_id:
280 return Vector2.UP
281 if control_id == down_id:
282 return Vector2.DOWN
283 return Vector2.ZERO
284
285
286func get_state() -> Dictionary:
287 return {
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", [])
303 }
304
305
306
307func get_stats() -> Dictionary:
308 return {
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()
316 }
317
318
319func get_cache_stats() -> Dictionary:
320 return get_stats()
321
322
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")
329 if pad != null:
330 if pad is AnalogPad:
331 pad.configure(options)
332 _apply_pad_layout(pad)
333 for child in pad.get_children():
334 if child is Button:
335 _style_button(child)
336
337
338func set_layout(next_options:Dictionary) -> void:
339 apply_state(next_options)
340
341
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)
359 )
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
364 )
365 _style_button(button)
366 return button
367
368
369func _build_action_bar(root:Control) -> void:
370 var actions:Array = options.get("actions", [])
371 if actions.is_empty():
372 return
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
378 root.add_child(bar)
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:
389 continue
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)
401 button.text = ""
402 button.expand_icon = true
403 button.pressed.connect(func(): action_pressed.emit(action_id))
404 _style_button(button)
405 bar.add_child(button)
406
407
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
412 var cells = [
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"}), {}
416 ]
417 for cell in cells:
418 if cell.is_empty():
419 var spacer = Control.new()
420 spacer.custom_minimum_size = options.get("button_size", Vector2(72, 64))
421 pad.add_child(spacer)
422 else:
423 pad.add_child(_make_button(cell))
424
425
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
437 return
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"
441 var width = float(button_size.x)
442 var height = float(button_size.y) * controls_count + float(options.get("gap", 10.0)) * max(0, controls_count - 1)
443 if is_dpad:
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)
450 pad.anchor_top = 0.0
451 pad.anchor_bottom = 1.0
452 pad.offset_top = margin
453 pad.offset_bottom = -margin
454 else:
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
465 else:
466 pad.anchor_left = 0.0
467 pad.anchor_right = 0.0
468 pad.offset_left = margin
469 pad.offset_right = margin + width
470
471
472func _move_analog_pad(relative:Vector2) -> void:
473 if overlay == null or overlay.size.x <= 0.0 or overlay.size.y <= 0.0:
474 return
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
480 )
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)
486
487
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)
503
504
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
516 return style