Creating 3-part rotation with a short pause at each rotation point

Godot Version

4.4.1

Question

I stepped away from a project for a while and just picked it back up, so I’m a little rusty, but I will do my best to explain my problem.

Overall Goal:

I am creating a digital version of the Alethiometer, a divination tool from the His Dark Materials book series. It is a compass with 36 symbols placed along the dial. Their are 4 needles total. The player is able to rotate three of the needles to point to a specific symbol in order to frame a question, then click a button to start the reading. The 4th needle will then spin to three “random” symbols to communicate the answer.

Current Problem:

I was able to implement the three player-controlled needles, as well as the Answer Needle that spins to three random symbols. However, I want the Answer Needle to slow down or pause at each symbol so the player is able to easily register the result. Ideally, I would also like the game to record and store the rotation value so that the player can reference them at a later point, but that is probably a problem for later down the line as their would be multiple steps to implement that - just mentioning it in case it changes how you would solve my current problem.

I’ve tried a couple solutions, but no dice. Most recently, I attempted to use “await” in my process function (although I know that should not be done) to pause the needle once it reaches it’s target_rotation, before running the pick_new_target() function. In practice, all this does is stop the needle at the first target_rotation, and prevent the needle from continuing to the 2nd and 3rd rotations.

Would someone be able to point me in the right direction? Unfortunately, it’s been a while since I looked at this project, so I don’t remember everything I have tried so far.

extends Node2D

var question: bool = false
var stop: bool = false
var step: int = 0

var anchors: Array[Node2D] = []
var anchor_drawables: Array[Array] = []

# Which dial is currently selected? (-1 means "none selected")
var selected_dial: int = -1

# variables for reading results
#var answers: Array = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36"]
#var results: float = $Alethiometer/Answer.position
var answer1
var answer2
var answer3

var target_rotation: float = 0.0
var angular_speed = PI
var rotation_step: float = deg_to_rad(10)
var stop_time = 0

@export var speed_deg_per_sec: float = 90.0 # tweak: 45 = slow, 90 = medium, 180 = fast

var elapsed: float = 0.0
var rng := RandomNumberGenerator.new()

# Highlight style: we use "modulate" and "scale" (no extra art needed).
# NOTE: If a texture is very strongly colored (e.g., pure red needle),
# modulate may be subtle; scale still makes selection obvious.
@export var normal_modulate: Color = Color(1, 1, 1, 1)       # slightly faded
@export var selected_modulate: Color = Color(0.8, 0.8, 0.8, 1) # brighter + warm

@export var normal_scale: Vector2 = Vector2.ONE
@export var selected_scale: Vector2 = Vector2(1.04, 1.04)       # 4% bigger (noticeable but not huge)

# Optional: make the selected dial gently "pulse" so it's easier to spot.
@export var pulse_enabled: bool = true
@export var pulse_speed: float = 6.0  # higher = faster pulsing
@export var pulse_amount: float = 0.02 # 0.02 = +/-2% size change

@onready var timer: Timer = $Timer
@onready var alethiometer: Node = $Alethiometer

func _ready() -> void:
	# Find Anchor1..AnchorN automatically, so adding more dials requires no code changes.
	var i := 1
	while true:
		var anchor_name := "Anchor%d" % i
		if not alethiometer.has_node(anchor_name):
			break

		var anchor := alethiometer.get_node(anchor_name) as Node2D
		anchors.append(anchor)

		# Cache all visible/drawable nodes (Sprite2D, TextureRect, Label, etc.) under this anchor.
		anchor_drawables.append(_find_canvas_items(anchor))

		i += 1

	# Make sure the initial visuals match "nothing selected".
	_apply_highlight() #is this redundant?
	
func _on_timer_timeout():
	print("timeout")
	_pick_new_target()

		
func _input(event: InputEvent) -> void:
	# We use _input() instead of checking input every frame in _process().
	# _input() is event-driven: it runs only when input happens.
	print(selected_dial)
	if selected_dial == -1:
		return
			
		# In _input(), we receive RAW input events (mouse, keys, wheel, etc.)
		# The simplest reliable way to use InputMap actions here is:
		# event.is_action_pressed("action_name")
		#
		# Using "pressed" works well for mouse wheel bindings too (wheel often has no "release" feel).

		
			
	if event.is_action_pressed("clockwise"):
		anchors[selected_dial].rotation += rotation_step
		get_viewport().set_input_as_handled() # optional: prevents other nodes from reacting too
			
	if event.is_action_pressed("counterclock"):
		anchors[selected_dial].rotation += rotation_step
		get_viewport().set_input_as_handled() # optional
	
func start_answer_spin() -> void:
	print("start answer spin")
	# Call this when you want to start the 3-step motion
	question = true
	stop = false
	step = 0
	target_rotation = $Alethiometer/Answer.rotation
	print("starting rotation?: ", target_rotation)
	#_pick_new_target()
	_on_timer_timeout()
	
func _pick_new_target() -> void:
	timer.start()
	print("pick new target")
	# Direction pattern: +, -, + (based on step 0,1,2)
	var direction:= 1 if step % 2 == 0 else -1
	print("direction: ", direction)
	
	# Choose how far to rotate this step (not absolute 0..360!)
	# Picking a delta makes "direction" meaningful and predictable.
	var delta_deg:= rng.randi_range(10, 360) # tweak range as you like
	var delta_rad:= deg_to_rad(delta_deg) * direction
	
	# Target is current rotation + signed delta
	target_rotation = $Alethiometer/Answer.rotation + delta_rad
	
	
func _process(delta):	
	#logic for stopping answer needle##
	if question == true:
		var speed:= deg_to_rad(speed_deg_per_sec)
		# Move current rotation toward the target at a constant angular speed
		$Alethiometer/Answer.rotation = move_toward($Alethiometer/Answer.rotation, target_rotation, speed * delta)
		# Slow down as needle approaches target
		#$Alethiometer/Answer.rotation = ease(target_rotation, 0.9)
		
		# If we've basically reached the target, go to next step
		if is_equal_approx($Alethiometer/Answer.rotation, target_rotation):
		#if $Alethiometer/Answer.rotation == target_rotation:
			print("target reached")
			step += 1
			if step == 1:
				answer1 = target_rotation
				print("answer: ", answer1)
				print("step: ", step)
				_on_timer_timeout()
					
				##await get_tree().create_timer(1).timeout
				#await is messing up original conditional statement (if rotation == target rotation), so the if statement keeps running through to the next "step" before await can complete and pick_new_target() can run? Can we solve this by running this outside process?
					#_pick_new_target()
			if step == 2:
				answer2 = target_rotation
				print("answer: ", answer2)
				print("step: ", step)
				_on_timer_timeout()
				##await get_tree().create_timer(1).timeout
				#_pick_new_target()
			if step == 3:
				answer3 = target_rotation
				print("answer: ", answer3)
				print("step: ", step)
				_on_timer_timeout()
				##await get_tree().create_timer(1).timeout
				#_pick_new_target()
			if step >= 3:
				question = false
				stop = true
				print("step: ", step, "stopping at ", rad_to_deg($Alethiometer/Answer.rotation))
			#else:
				#_pick_new_target()
				
	# Optional pulse animation for the selected dial.
	# This runs every frame because animation needs continuous updates.
	if not pulse_enabled:
		return
	if selected_dial == -1:
		return
		
	# Start from the selected base scale, then apply a tiny sine-wave pulse.
	var t := Time.get_ticks_msec() / 1000.0
	var pulse := 1.0 + sin(t * pulse_speed) * pulse_amount
	anchors[selected_dial].scale = selected_scale * pulse
		
func _on_dial_pressed(index: int) -> void:
	# Called by any dial button:
	# Dial1 -> index 0, Dial2 -> index 1, etc.
	selected_dial = index
	_apply_highlight()
	print(anchors)
	print("index: ", index)
	print("dial pressed")
	print("selected dial from pressed signal: ", selected_dial)

func _apply_highlight() -> void:
	# Update ALL dials so the selected one stands out.
	
	print("apply highlight to ", selected_dial)
	for i in range(anchors.size()):
		var is_selected := (i == selected_dial)
		
		# Base scale (pulse may animate on top of this later)
		anchors[i].scale = selected_scale if is_selected else normal_scale
		# Tint all drawable children (sprites, texture rects, labels, etc.)
		var tint :Color = selected_modulate if is_selected else normal_modulate
		for item in anchor_drawables[i]:
			(item as CanvasItem).modulate = tint
		
func _find_canvas_items(root: Node) -> Array:
	# Returns all CanvasItem descendants under root.
	# CanvasItem = anything that can be drawn (Sprite2D, TextureRect, Label, etc.)
	var result: Array = []
	_collect_canvas_items_recursive(root, result)
	return result

func _collect_canvas_items_recursive(n: Node, out: Array) -> void:
	# Recursively walk the tree and gather CanvasItems.
	for child in n.get_children():
		if child is CanvasItem:
			out.append(child)
		_collect_canvas_items_recursive(child, out)
	
func _on_start_button_pressed():
	#set_dials_to_false()
	selected_dial = -1
	_apply_highlight()
	start_answer_spin()
	question = true
	
func _on_result_button_pressed():
	print("Answer: ", answer1, " ", answer2, " ", answer3)

Use tweens.

Thanks! This worked for me, but I don’t understand why. Could you help me understand?

I replaced the following:

$Alethiometer/Answer.rotation = move_toward($Alethiometer/Answer.rotation, target_rotation, speed * delta)

with a tween, as you suggested:

tween.tween_property($Alethiometer/Answer, "rotation", target_rotation, speed)

I got the desired behavior, with the needle pausing at each rotation point. But, I don’t understand why the tween doesn’t result in the same behavior as my original code. Essentially, am I not giving the program the same instruction? Curious to know so I can learn better and apply my knowledge better.

Thanks!

Not quite the same.

Also note that you can do the whole sequence of 3 rotations and pauses with a single tween object by chaining tween_property() and tween_interval() calls. Doing it like that will greatly simplify your code and also eliminate any need for nasty awaits.

Oh, great news! I will play around with that tomorow, thanks!