Project Delve Part 8: Battle Scene – Wheel Section

Hiya folks, I’m back.

As I mentioned in my previous post, I was starting to feel drained with the work I was putting into this project and was starting to lose motivation. Turns out that’s what we call “burnout” in the biz! But after a few months hiatus I’m feeling rejuvenated and ready to finish this bad boy.


Where was I?

Naturally after opening back up the project for the first time in months I had forgotten what I was working on exactly. Fortunately I keep a Trello board and a markdown file with my to-do list items and thoughts I have had along the way, so that was the first thing I checked. I also revisited my previous post and the initial Project Vision post to see where I was at and remembered that I was in the process of adding skills to the game.

After reading through my last post I feel reasonably comfortable with how I described the process of creating a new skill (at least as far as “action” skills go — I’ll be writing a post about “interrupt” and “passive” skills later), so today we’ll be looking at putting together a more engaging battle scene!


Making a Randomizer

Because Descent is a board game, it uses the simplest randomizer for outcomes that can have a defined number of results: dice. But since this game we’re making uses code we don’t need any physical apparatus, we can have the computer provide a random result for us! So because we’re not tied to “dice,” I don’t want to use any sort of dice in our game (for no reason other than I don’t want to). Thus we need to come up with a different way to visually achieve random results.

One idea I’m fairly fond of is a wheel — think of the big “clicky” wheels at carnivals that eventually land on a certain prize spot. I think it’d be fun to have a spinning wheel as the attack/defense randomizer! Plus it also comes with a few added benefits:

  1. We’re not limited to exactly 6 outcomes. There can be fewer or there can be more!
  2. We’re not limited to a 1/6 chance for every outcome. If you use dice you’re stuck with that. Unless the dice are loaded. In which case… I hope you’re only playing with friends with whom you don’t care about burning bridges.

My first thought was to create a “wheel segment” sprite that could be stitched together with other segments to create a full wheel. This had the drawback of not being able to resize the sections at will if we needed to do some balancing later on, or if we want to allow the players to customize their wheels and allow them to select the size of each section.

My second thought was to create a single wheel and use some sort of image mask to cover up the leftover part of the wheel (the inverse of the section size, thinking in terms of degrees). While researching this I came across people discussing how you could use a texture as a mask for another texture through some fancy sorcery called shaders. I’ve heard of shaders before and in my mind they’ve always been a ominous entity. When I think of shaders I think of incredible visual effects with gorgeous lighting and high-detailed scenery.

An example of a shader built for Minecraft

As it turns out though, a shader is simply a snippet of code that the GPU runs for every pixel on the screen. After reading the Godot documentation on shaders and watching a few YouTube videos (this one was especially helpful), I felt ready to create my own.

Creating the scene

I started out by taking a simple PNG of a circle and adding a few effects to it in Photoshop (though the process will work fine with just a regular circle). I also added a pure Green rgb(0, 255, 0) border around the circle so that it could be selected and colored separately from the rest of the texture.

I then created a new scene which I named WheelSection with a root node of Sprite, and I assigned my circle texture to the Sprite node.

Next I created a simple Control node as the single child of the root and named it “Pivot,” since it would act as the anchor around which the icons would rotate. Everything else in the scene will be a descendant of the Pivot node so that their transforms will inherit the rotation of Pivot. I set the initial rotation of Pivot to be 180 degrees and will update it with a script depending on the percent of the wheel that should be visible.

I next created a BoxContainer node as a child of Pivot that I named “Icons,” which is where we will dynamically add our icons to represent the value of the wheel section.

Finally I created a script to attach to the root which I named wheel_section.gd. This script will take care of dynamically updating our wheel section based on the data provided.

# wheel_section.gd
class_name WheelSection

onready var _pivot = $Pivot
onready var _icon_containers = $Pivot/Icons

export(Resource) var wheel_section_data setget _set_data

export(Texture) var sword_icon
export(Texture) var shield_icon
export(Texture) var lightning_icon
export(Texture) var heart_icon
export(Texture) var miss_icon

# This function ensures that when the wheel_section_data gets changed (described in the next section)
# we automatically update the scene values.
func _set_data(section_data):
	if wheel_section_data:
		wheel_section_data.disconnect("changed", self, "_update_wheel")
	wheel_section_data = section_data
	if wheel_section_data:
		wheel_section_data.connect("changed", self, "_update_wheel")
		if is_inside_tree():
			_update_wheel()

# We will break up the update into 3 steps, as shown here
func _update_wheel():
	if not wheel_section_data or not is_inside_tree():
		return

	_update_icons()
	_update_positioning()
	_update_visibility()

Icons

Updating the icons will be the most complex part, relatively speaking, so let’s start there.

As an explanation for what we want to do, when we read the wheel_section_data (explained in the next section) there will be different values for different types (attack, defense, special, etc.). I’d like to display each of these icons in a grid pattern, grouped with similar icons. Thus, the top-level _update_icons() function will look like this.

func _update_icons():
	if not _icon_containers:
		_icon_containers = $Pivot/Icons

	# For simplicity's sake, we will delete any existing icon groups and recreate them
	for child in _icon_containers.get_children():
		child.queue_free()

	if wheel_section_data.miss:
		_add_icon_grid(1, miss_icon, "Miss")
	else:
		_add_icon_grid(wheel_section_data.heal_points, heart_icon, "Heal")
		_add_icon_grid(wheel_section_data.attack_points, sword_icon, "Attack")
		_add_icon_grid(wheel_section_data.special_points, lightning_icon, "Special")
		_add_icon_grid(wheel_section_data.defense_points, shield_icon, "Defense")

For the _add_icon_grid() function, we will be creating a new GridContainer node and adjusting the amount of columns it has based on the value that gets passed in.

func _add_icon_grid(amount, texture, name):
	if amount <= 0:
		# We skip creating any element if there are no icons to add
		return

	var max_columns = 4
	var icon_grid := GridContainer.new()
	_icon_containers.add_child(icon_grid)
	icon_grid.name = name
	icon_grid.size_flags_horizontal = Control.SIZE_SHRINK_CENTER

	if amount < max_columns:
		icon_grid.columns = amount
	else:
		if amount / 2 < max_columns:
			icon_grid.columns = amount / 2
		else:
			icon_grid.columns = max_columns

	# This ensures we will see the updated scene elements in the editor's scene inspector
	icon_grid.owner = get_tree().edited_scene_root
	_add_icons(icon_grid, amount, texture, name)

Finally, the _add_icons() function takes care of creating the TextureRects and setting their properties to the provided values.

func _add_icons(container, amount, texture, node_prefix = "TexRect"):
	for i in range(0, amount):
		var tex_rect := TextureRect.new()
		tex_rect.texture = texture
		tex_rect.name = node_prefix + "_" + str(i + 1)
		tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
		container.add_child(tex_rect)
		
		# Like before, this updates the editor's scene inspector
		tex_rect.owner = get_tree().edited_scene_root

Positioning

Next, in order to position the icons in an appropriate spot, we will update the rotation of the Pivot node.

func _update_positioning():
	if not _pivot:
		_pivot = $Pivot

	var default_rotation = PI  # 180 degrees
	_pivot.set_rotation(default_rotation * wheel_section_data.percent_of_wheel)

Visibility

Lastly, we will set a shader parameter that will set how much of the circle image is visible.

func _update_visibility():
	material.set_shader_param("visible_percent", wheel_section_data.percent_of_wheel)

Creating the data

Because we want to be able to have each instance of this scene have different properties, and because we want these properties to be defined purely as data, we will be making a new Resource script. I’ll call it wheel_section_data.gd.

# wheel_section_data.gd
extends Resource

class_name WheelSectionData

export(float, 0, 1) var percent_of_wheel := 1.0
export(int) var attack_points := 0
export(int) var defense_points := 0
export(int) var special_points := 0
export(int) var heal_points := 0
export(int, 0, 6) var range_points := 0
export(bool) var miss := false

This script will work just fine for holding the data we care about, but to make it a little easier for development, let’s make this a tool script (meaning it will run in the Godot editor) and add some setget functions to notify when a value of the resource changes.

tool
extends Resource

class_name WheelSectionData

export(float, 0, 1) var percent_of_wheel := 1.0 setget _set_percent
export(int) var attack_points := 0 setget _set_atk
export(int) var defense_points := 0 setget _set_def
export(int) var special_points := 0 setget _set_spec
export(int) var heal_points := 0 setget _set_heal
export(int, 0, 6) var range_points := 0 setget _set_range
export(bool) var miss := false setget _set_miss


func _set_percent(val):
	percent_of_wheel = val
	emit_changed()


func _set_atk(val):
	attack_points = val
	emit_changed()


func _set_def(val):
	defense_points = val
	emit_changed()


func _set_spec(val):
	special_points = val
	emit_changed()


func _set_heal(val):
	heal_points = val
	emit_changed()


func _set_range(val):
	range_points = val
	emit_changed()


func _set_miss(val):
	miss = val
	emit_changed()

Now when a value on the resource is changed, the wheel_section.gd script will register that it needs to call its _update_wheel() function because of the signal we connected to it! Once you provide sprites for each of the wheel icons you can test this out for yourself (though you’ll probably get an error about the material not having a shader parameter “visible_percent”). So let’s fix that now.

demonstrating icons dynamically being added

Creating the shader

Start by clicking the root of the scene (which should be the Sprite node) and finding the “Material” dropdown under the “CanvasItem” section in the Inspector. Expand that section, click on the value dropdown, and select “New ShaderMaterial.” Then there will be a new field that shows up directly below “Material” called “Shader.” Open up its value dropdown and select “New Shader.” This will open up a shader script editor section in Godot where you can code the shader.

As described in the docs and video I linked to earlier, each shader script must begin with the shader_type keyword with a certain type following it. Because our ShaderMaterial is part of a CanvasItem node, the shader_type will be canvas_item.

shader_type canvas_item;

As for the things we want to be able to control outside of the shader (like our export variables in the gdscripts) we need to define some uniform variables.

const float PI = 3.1415926535;

uniform vec4 wheel_color : hint_color = vec4(.33, .61, .97, 1);
uniform vec4 border_color : hint_color = vec4(1, 1, 1, 1);
uniform float visible_percent : hint_range(0, 1) = 1.0;

Because we want to modify the texture’s color value, we need to define the fragment() function. Within this function we’re provided with a few “global” variables: TEXTURE, UV, and COLOR.

  • TEXTURE refers to the full texture attached to the material (i.e. our circle sprite)
  • UV refers to the coordinates of the current pixel the fragment() function is operating on
  • COLOR refers to the color value of the current pixel the fragment() function is operating on

Modifying COLOR is how we will update the color value of the pixel. The texture() function is a global function that takes a texture and coordinates and returns the color at that coordinate. The following fragment function will assign the color of the current pixel to the color of the texture at the current pixel’s coordinates. (In other words, does nothing to modify the result)

void fragment() {
	COLOR = texture(TEXTURE, UV);
}

Try experimenting with modifying COLOR in this function and watch how the sprite changes in the editor! For example, this would set the blue channel of the pixel to be 1 (scale is 0-1) for each pixel on the texture.

COLOR.b = 1;

With this knowledge alone we can do some pretty fun stuff, but let’s stick to just hiding the pixels we don’t want to see so we end up with a slice of the circle. For that we will simply set COLOR.a (the alpha channel) to 0 on any pixel we don’t want shown, so it will be fully transparent.

…but how can we tell which pixels we need to do that for versus the ones that should be shown? Let’s create a function to call that will tell us whether we should draw the pixel or not.

bool should_draw(vec2 uv) {
	if (visible_percent == 1.0) return true;
	if (visible_percent == 0.0) return false;
}

void fragment() {
	if (!should_draw(UV)) {
		COLOR.a = 0.0;
		return;
	}
	
	COLOR = texture(TEXTURE, UV);
}

These two use cases are easy to determine — if 100% of the wheel is visible, we can always return true. Likewise, if 0% of the wheel is visible we can always return false. But what about in the other instances? For this we’ll need to do a bit of trigonometry, since we want to hide the circle radially instead of linearly.

First let’s determine which angles define the “start” and “end” of the area we want to be shown. (We won’t actually use the start angle in the calculations since it’ll be 0, but I’m including it here for… reasons.)

float start_angle = 0.0;
float end_angle = visible_percent * PI;

Easy enough.

Now let’s ensure that anything outside of the radius of 1 will be hidden (this ensures the resulting sprite will always be cropped to a circular shape).

float radius = 1.0;
vec2 local_uv = uv - vec2(0.5, 0.5);
if (distance(vec2(0.0, 0.0), local_uv) > radius) {
	return false;
}

We’re subtracting the vec2(0.5, 0.5) from the provided uv coordinates since we want to check from the center of the texture instead of the top left (which is the default).

Finally let’s whip out those dusty old trig textbooks and find the chapter(s) on calculating the angle of a point.

What we want to discover is whether a point within a circle lies between two different angles. We’ve already tested that it is within the radius of the circle, so now we just need to calculate the angle of the point and determine whether that’s greater than our start_angle and less than our end_angle. If it is, we can draw it!

To find the angle of our point we will use the atan() function, which takes an x and y coordinate and returns the angle of the point.

float point_ang = atan(local_uv.x, local_uv.y);

We’re using the result local_uv since we want the origin at the center.

float cutoff_ang = atan(cos(end_angle), sin(end_angle)) * 2.0;

To determine the angle of the cutoff point, we need to take the arctangent of the x and y value of our end_angle, and then multiply that by 2, since… that’s what works. (I spent a long time experimenting with different ways to get the correct angle, and even longer trying to understand why that worked, but I ultimately don’t know and if anyone can explain to me why this works I’d love to hear it!)

code doesn't work meme

Then we can round off the function by comparing the point_ang to the cutoff_ang.

return point_ang >= cutoff_ang;

All together the shader script looks like this.

shader_type canvas_item;

const float PI = 3.1415926535;
uniform vec4 wheel_color : hint_color = vec4(.33, .61, .97, 1);
uniform vec4 border_color : hint_color = vec4(1, 1, 1, 1);
uniform float visible_percent : hint_range(0, 1) = 1.0;

bool is_border(vec4 color) {
	if (color.r != 0.0 || color.b != 0.0) return false;
	return color.g >= 0.98 && color.a == 1.0;
}

bool should_draw(vec2 uv) {
	if (visible_percent == 1.0) return true;
	if (visible_percent == 0.0) return false;
	
	float start_angle = 0.0;
	float end_angle = visible_percent * PI;
	
	// center of circle at (0.5, 0.5)
	vec2 local_uv = uv - vec2(0.5, 0.5);
	if (distance(vec2(0.0, 0.0), local_uv) > 1.0) {
		return false;
	}
	
	float cutoff_ang = atan(cos(end_angle), sin(end_angle)) * 2.0;
	float point_ang = atan(local_uv.x, local_uv.y);
	
	return point_ang >= cutoff_ang;
}

void fragment() {
	if (!should_draw(UV)) {
		COLOR.a = 0.0;
		return;
	}
	
	COLOR = texture(TEXTURE, UV);
	if (is_border(COLOR)) {
		COLOR = border_color;
	} else if (COLOR.a == 1.0) {
		COLOR *= vec4(wheel_color.r, wheel_color.g, wheel_color.b, 1.0);
	} else {
		COLOR = vec4(border_color.r, border_color.g, border_color.b, COLOR.a);
	}
}
visualizing the wheel shader

Wheel Section

So there we have it! A working shader for our wheel section object! This post was a lot longer than I originally anticipated, so the next post will be how we can fit this into a full wheel and from there, make a full Battle Scene. It may be one more post, it may be two. We’ll just have to see!

full demo

The code up to this point in the project can be found here.

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