Project Delve Part 8.1: Assembling the Wheel

Continuing on with the previous post, today we’re going to be taking those wheel sections and putting them together in a scene. We’ll also create functions for spinning the wheel and getting the result it “lands on” (I put it in quotes because in the code it’s actually the other way around, but more on that later).


The scene

Let’s make a new 2D scene and name it “Wheel.” As the first child of the root node, make another 2D node. I’m going to name this one “Sections,” as it will be the container for holding all of the WheelSection scenes we instantiate. That’s enough for us to get started on the scene script, so let’s make one and attach it to the root.

This is another script that I’d like to be able to watch update in the editor when I change its properties, so I’m going to make it a tool script. Additionally I’m going to define some exported variables so we can make edits to the script in the inspector.

tool
extends Node2D

# This export isn't strictly necessary, since we could use a preload() function with
# the predefined path to the WheelSection scene, but I'll do it this way for example.
export(PackedScene) var wheel_section
export(Array) var wheel_sections
export(float, .1, 2) var stopping_time := 1.0
export(float, .1, 2) var startup_time := 1.0

Hopefully these variables are pretty self-explanatory with what they relate to, so I’ll continue on. Because this is a tool script, I’d like the code we set here get run in the editor, specifically when editing the wheel_sections variable (which will be an array of WheelSectionData). So I’m going to make a setget for that variable and define a setter function.

export(Array) var wheel_sections setget _set_wheel_sections

func _set_wheel_sections(sections):
	wheel_sections = sections
	_draw_wheel()

The _draw_wheel() function is the key function here — that’s the code I want to have run during edit-time, so let’s go over that.

Drawing the wheel

We need to first check that the wheel_section variable has been assigned. If not, we can’t instantiate the scene and so we’ll return early. We also want to make sure that our current node (i.e. the root) is within the scene tree. If it isn’t, then Godot will give us errors when we try to add child nodes. This part is only needed since this is a tool script and will be running in the editor.

func _draw_wheel():
	if not wheel_section or not is_inside_tree():
		return

Next, we want to erase/remove all children of the “Sections” node because we want to be working with a clean slate when we start instantiating.

# _draw_wheel()
for child in $Sections.get_children():
	child.queue_free()

Now we will get a reference to the edited_scene_root (which should be the same as the node we’re currently attached to, but for completeness’s sake we’ll treat it as though it was separate). We need this reference so that once we instantiate the child scene we can set its owner property to the root. What this does is allow the instantiated scene to appear in the scene tree in the Scene panel to the left of the viewport area. Without setting owner, the scene would still appear in the viewport, but not in the hierarchy.

We also want to keep track of what rotation we need to start these wheel sections at, so we’ll set a current_offset variable to start at 0.

# _draw_wheel()
var root = get_tree().edited_scene_root
var current_offset := 0

Now we can loop through all of the wheel_sections and instantiate them.

# _draw_wheel()
for section_data in wheel_sections:
	# Instantiate the PackedScene
	var scene = wheel_section.instance()

	# Attach the instantiated scene as a child to the $Sections node
	$Sections.add_child(scene)

	# Set the owner property of the scene to the edited_scene_root
	scene.owner = root

	# Assign the wheel_section_data
	# Because the WheelSection scene was also a tool script, the wheel_section_data
	# setget method will also be run within this tool script.
	scene.wheel_section_data = section_data

	# Rotate the scene current_offset degrees
	scene.rotate(deg2rad(current_offset))

	# Increment the current_offset by the size of the wheel_section_data
	current_offset += scene.wheel_section_data.percent_of_wheel * 360.0

And that’s it! Our _draw_wheel() function has been created! Now you should be able to assign an array of WheelSectionData in the scene’s editor and watch the wheel be drawn as you update it! (Don’t forget to also assign the wheel_section scene to the scene we created last time)

The final code for the _draw_wheel() function looks like this.

func _draw_wheel():
	if not wheel_section or not is_inside_tree():
		return

	for child in $Sections.get_children():
		child.queue_free()


	var root = get_tree().edited_scene_root
	var current_offset := 0
	for section_data in wheel_sections:
		var scene = wheel_section.instance()
		$Sections.add_child(scene)
		scene.owner = root
		scene.wheel_section_data = section_data
		scene.rotate(deg2rad(current_offset))
		current_offset += scene.wheel_section_data.percent_of_wheel * 360.0
finalized wheel drawn
How your wheel might look when the _draw_wheel() function has been run

Spinning the wheel

Now that we have the wheel assembly taken care of, we need to make it functional. There are two main things we want to be able to do with the wheel from other scripts: spin and stop. Additionally we want to receive the WheelSectionData of whatever segment of the wheel we landed on. So let’s start by adding a few things to our scene and script.

First, add a new Tween node to the scene (anywhere in the scene works — I just added it as a child of the root). This handy node lets you do tweening animations fairly easily.

Next we want to have a few script variables to help us keep track of where the wheel is in its process of spinning, so let’s define those.

var _wheel_started := false
var _wheel_spinning := false
var _startup_final_rot := 360
var _prev_deg := 0
var _deg_delta

I think the only two variables here that may need some explanation are the _startup_final_rot and _deg_delta. The _startup_final_rot variable is basically just the final rotation of where the wheel should be at the end of the “startup” phase of wheel spinning. The wheel spin animation will be broken up into three phases.

  1. Wheel start spin (controlled by Tween node)
  2. Wheel continue spin (controlled by _process() function)
  3. Wheel end spin (controlled by Tween node)

The _deg_delta variable will keep the wheel spinning at the same rate as it was at the end of the first phase.

We also want to define a signal so that any script can listen for when the wheel stops and get the result.

signal wheel_stopped

Now let’s create the spin_wheel() function. This will be the method we can call from outside scripts to begin the spin process.

func spin_wheel():
	if _wheel_started:
		# If we've already started spinning the wheel, we don't want to
		# re-do the startup animation
		return
	_wheel_started = true

	# Because the rotation of a node isn't bound between 0 and 360, the
	# actual_rot variable will hold the node's full rotation, while current_rot
	# will hold the rotation between 0 and 360
	var actual_rot = $Sections.rotation_degrees
	var current_rot = int(actual_rot) % 360 + (actual_rot - int(actual_rot))


	# We will set the final rotation to be 1 full rotation past the current rotation
	# in the counter-clockwise direction (for a clockwise spin switch - to +)
	_startup_final_rot = current_rot - 360

Now that we’ve initialized the variables, we can start the tweening process to startup the wheel.

	$Tween.interpolate_property(
		$Sections,
		"rotation_degrees",
		current_rot,
		_startup_final_rot,
		startup_time,
		Tween.TRANS_QUINT,
		Tween.EASE_IN
	)
	$Tween.start()

	yield(get_tree().create_timer(startup_time), "timeout")

	_wheel_spinning = true

If you now were to run this function, you’d see that the wheel will do one full rotation in the given startup_time, but will abruptly stop after that time. That is when the _process() function will take over.

Continuing to spin

func _process(delta):
	# These checks make sure that we can spin the wheel. The Engine.editor_hint 
	# prevents this function running while in the editor (_process() runs every
	# frame so that wouldn't be good).
	if Engine.editor_hint or not is_inside_tree() or not _wheel_spinning:
		return

	# Otherwise, every frame rotate the wheel another _deg_delta
	$Sections.rotate(deg2rad(_deg_delta))

The observant among you may have realized that we haven’t ever set _deg_delta, and may be wondering how we do that. First off, way to think through it! Let’s address that now.

Back in the start_wheel() function when we use the Tween node to begin the wheel spinning, we can set the delta. Because “delta” just means “the amount of change,” we want to be able to measure the amount of rotational change that happens between two frames. Lucky for us, the Tween node emits a signal at each frame with some information about the interpolation currently happening called “tween_step”. We will hook into this function and calculate the amount of change that happens between each frame until the tween ends.

# spin_wheel()
...

# Before $Tween.start() is called, make sure to connect the signal to our function
$Tween.connect("tween_step", self, "_set_delta")

...

# After the yield statement of waiting for the timeout, disconnect the function
$Tween.disconnect("tween_step", self, "_set_delta")

...

The _set_delta() function will take 4 arguments (the number emitted from the “tween_step” signal), but the only one we really care about is the 4th, which is the value at the current frame. All we need to do is set the _deg_delta to the difference between the current value, and the value at the previous frame. Then we update the “previous” value to be the current value in anticipation of the next _set_delta() call.

func _set_delta(obj, key, elapsed, current_deg):
	# If current_deg is the _startup_final_rot, we are on the last frame of
	# the tween, and do not want to update the _deg_delta
	if current_deg == _startup_final_rot:
		return
	_deg_delta = current_deg - _prev_deg
	_prev_deg = current_deg

Stopping the wheel

Now that we have the wheel set up to start its spin and continue spinning at the same speed, we need a way to stop the wheel and determine which section it landed on. However, as I eluded to in the intro to this post, we’re actually going to do it an easier way. Instead of worrying about how the final angle lines up and doing math to figure out which section that lies in based on the angle of our “wheel section pointer” and the angle of the wheel (trust me, doing it this way is a nightmare — I’ve done it before on a project I did in the past), we’re going to first randomly select a value between 0 and 1 and find out which section that would fall under. Then we just make the wheel stop on the angle associated with the random value! Let’s see it in practice.

func stop_wheel():
	if not _wheel_spinning:
		# Like before, if we've already stopped the wheel, we don't want to
		# restart the stop animation
		return
	_wheel_spinning = false

	var actual_rot = $Sections.rotation_degrees
	var rand_value = rand_range(0, 1.0)

	# Get current rotation based off 0 degrees
	var ending_rot = actual_rot - int(actual_rot) % 360

	# Set end value to within 360 degrees. (Again, if a clockwise spin is desired,
	# change the -= to += on this and the next line)
	ending_rot -= rand_value * 360

	# Spin 2 extra times before stopping
	ending_rot -= 360 * 2

	$Tween.interpolate_property(
		_sections_container,
		"rotation_degrees",
		actual_rot,
		ending_rot,
		stopping_time,
		Tween.TRANS_SINE,
		Tween.EASE_OUT
	)
	$Tween.start()

	yield(get_tree().create_timer(stopping_time), "timeout")

	_wheel_started = false

Now that the wheel animation has stopped, we can emit the “wheel_stopped” signal with the WheelSectionData at the rand_value.

emit_signal("wheel_stopped", get_section_at(rand_value))

The get_section_at() function is pretty simple, we just return the first section we come across that has the rand_value less than the current percent_of_wheel summed with the previous percent_of_wheels.

func get_section_at(percent):
	var offset := 0.0
	for section in wheel_sections:
		if percent <= section.percent_of_wheel + offset:
			return section
		offset += section.percent_of_wheel

And just like that, the script is complete!

…Well, nearly complete. We would like the rand_range() to give us less predictable random values, so we need to call the randomize() function in the script’s _ready() function.

We also want to make sure that the wheel gets drawn when it first loads up, so we’ll add the _draw_wheel() function to the _ready() hook as well.

func _ready():
	randomize()
	_draw_wheel()

Testing it out!

Now that we’ve got the script fully fleshed out, let’s try loading up this bad boy and seeing if it works!

demonstration of wheel spinning and stopping
I also added a small ColorRect node at the top of the wheel to indicate which section the wheel needs to land under.

There will be one more post in this section before we move on, which is putting these battle wheels into a battle system, and using it when our units attack one another. Until then, au reviour!

The code up to this point in the project can be found here. (Same as previous post)

Leave a Reply

Up ↑

Discover more from The Walrus Game Project

Subscribe now to keep reading and get access to the full archive.

Continue reading