How to disable input to parts of screen UI during game tutorial to force interaction

Godot Version

4.1.2

Question

How to disable input to parts of screen UI during game tutorial to force interaction

I want to highlight a screen section of some ~20 UI elements to force the player to interact within it to introduce the game mechanics to the player as in the following sketch:

on screen is an x number of UI elements such as buttons and sliders

In my game tutorial i would like to disable input in all control nodes descendants outside of the highlight area for a slider + some buttons and force the player to assign a certain value in it with the highlighted area moving to other zones as the tutorial progresses

Coming from Unreal, i would usually assign an UI element across the entire screen with blocking input, the highlighted area would be a second UI element on top with the input filter on pass turning off the big UI blocker to ignore as the cursor enters the zone and turning it back on when it leaves, however in godot the pass filter works only for parent nodes

You can put any transparent Control (like TextureRect for example) on top of your elements to consume user input, but leave one “uncovered”.

A quick way would be to hold an array referencing ui nodes, iterate over it to set_mouse_filter(Control.MOUSE_FILTER_IGNORE) and only allow the relevant one for the current state of the tutorial.

Code for a root node attached script
@onready var groups : Array = [[$Slider1, $Button1], 
							   [$Slider2, $Button2]]
@onready var ui_nodes : Array = groups.reduce(func (accum, group): return accum + group, [])

var active_group : int = 0

func _ready():
	update_filter()

func update_filter():
	# Disable all
	for control_node in ui_nodes:
		control_node.set_mouse_filter(Control.MOUSE_FILTER_IGNORE)
	# Re-enable relevant ones
	for control_node in groups[active_group]:
		control_node.set_mouse_filter(Control.MOUSE_FILTER_STOP)
	# Arbitrarilly connect next tutorial step
	connect_states()

func connect_states():
	var slider = groups[active_group][0]
	var button = groups[active_group][1]

    # Next tutorial is reached if slider value == 50 or button is pressed
	slider.value_changed.connect(func(new_value): if new_value == 50: next_tutorial_state())
	button.pressed.connect(func(): next_tutorial_state())

func next_tutorial_state():
    # Increment active_group within bounds
	active_group = min(groups.size()-1, active_group+1)
	update_filter()

But according to what you’re looking for, as Exerion suggests, you’d need to have a Control node covering the entire viewport but manually punch holes in it for visuals and whether to let the input event propagate up the node tree. A good understanding of Inputs might help but that would look something like so:

Code for a 'Control' node placed last in node tree attached script
extends Control

const opaque_color := Color(.1,.1,.1,.8)

@onready var groups : Array = [[$"../Slider1", $"../Button1"], 
							   [$"../Slider2", $"../Button2"]]

var allow := false
var active_group : int = 0
var focus_rect : Rect2

func _ready():
	# Set control to full viewport size (can be done in editor instead)
	set_anchors_preset(Control.PRESET_FULL_RECT)
	# Propagate mouse input by default (can also be done in editor instead)
	set_mouse_filter(Control.MOUSE_FILTER_IGNORE)
	# Init tutorial state
	update_filter()

func _input(event): # override
	# Update whether to allow or not input propagation based on focusing rectangle
	if event is InputEventMouse:
		allow = focus_rect.has_point(event.position)

	# Tag input as already handled if disallowed
	if !allow:
		get_viewport().set_input_as_handled()

func update_filter():
	update_focus_rect()
	connect_states()
	queue_redraw()

func connect_states():
	var slider = groups[active_group][0]
	var button = groups[active_group][1]

	# Next tutorial is reached if slider value == 50 or button is pressed
	slider.value_changed.connect(func(new_value): if new_value == 50: next_tutorial_state())
	button.pressed.connect(func(): next_tutorial_state())

func next_tutorial_state():
	# Increment active_group within bounds
	active_group = min(groups.size()-1, active_group+1)
	update_filter()

func update_focus_rect():
	# Initialize a new focused rectangle based on the first ui node in group
	focus_rect = Rect2(groups[active_group][0].position, groups[active_group][0].size)
	# Grow the rectangle to fit all ui nodes within group
	for ui_node in groups[active_group].slice(1):
		focus_rect.position.x = min(focus_rect.position.x, ui_node.position.x)
		focus_rect.position.y = min(focus_rect.position.y, ui_node.position.y)
		focus_rect.end.x = max(focus_rect.size.x, ui_node.position.x + ui_node.size.x)
		focus_rect.end.y = max(focus_rect.size.y, ui_node.position.y + ui_node.size.y)
	# Add some margins
	focus_rect.position -= Vector2.ONE * 20
	focus_rect.end += Vector2.ONE * 40

func _draw():
	# Draw opaque screen around focus rectangle
	var    up := Rect2(                                    Vector2.ZERO,                Vector2(size.x, focus_rect.position.y))
	var left  := Rect2(             Vector2(0.0, focus_rect.position.y),     Vector2(focus_rect.position.x, focus_rect.size.y))
	var right := Rect2(Vector2(focus_rect.end.x, focus_rect.position.y), Vector2(size.x - focus_rect.end.x, focus_rect.size.y))
	var down  := Rect2(                  Vector2(0.0, focus_rect.end.y),            Vector2(size.x, size.y - focus_rect.end.y))
	for rect in [up, left, right, down]:
		draw_rect(rect, opaque_color, true)

There’s a small caveat though, if you started moving a slider or pressing a button and go outside the “allowed area”, nodes will not register “released” mouse button state and will stay in a “pressed” state when going back to it.
Ultimately you can make a mix to keep best of both worlds.

The scripts can be easily tested on any scene with 2 HSliders and 2 Buttons as named in the scripts.

1 Like