-
Project Delve Part 9: Objectives
What’s a game without an objective? Probably something, for sure, but my poorly-conceived question has a point (in my head)! Without any sort of goal, a game will lose its draw over time. Even if they’re self-given goals in a sandbox game, you as the player are always trying to accomplish something.
With that philosophical exercise out of the way, let’s get on with the blog post.
Quests
In Descent, each encounter has an objective for both the heroes and the overlord to accomplish. Whichever side accomplishes their goal first is the winner. Pretty standard stuff. We will need a way of defining these goals and then tracking the relevant game actions that will lead to accomplishing that goal in order to determine who wins the game (and when the game should end, for that matter).
For this post I’m going to keep things pretty simple and not try and overcomplicate quests, like I am wont to do with programming things. There are many actions that could define quest goals but we’ll just focus on a simple one today: kill count. How many monsters/heroes are you required to slay before you win? So let’s bust out a new script.
I’m going to name this script “quest.gd” and have it extend
Resource
so that we can create and save them within the editor and potentially use them for more than just one scenario.# quest.gd extends Resource # Define a signal that we can emit when the quest has been completed signal completed class_name Quest # Only define one quest type for now enum QuestType { kill } # Define the quest type export(QuestType) var type := QuestType.kill # How many of the quest type does the player need to do? export(int) var amount := 0 # Which groups will the targets be a part of? export(Array) var objective_groups := [] # Keep track of how many [kills] the player has made so far var _current_count := 0
Now that we’ve created the Resource script, it should show up in the Resource Creation Dialog. So we’ve defined the outward-facing variables of the Quest script, but as it stands they’re just numbers. They don’t do anything in and of themselves — that’s where scripting comes in.
For our
kill
quest type, we want to be notified whenever a unit dies (we’re not going to worry about how it died or who killed it). When that happens we need to check if the unit is within one of the groups defined byobjective_groups
, and if so, to increase the_current_count
and check for quest completion.func _on_unit_killed(unit: Unit): print("unit was killed: " + unit.name) if type != QuestType.kill: return var valid := false for group in objective_groups: if unit.is_in_group(group): valid = true break if valid: _update_count(1) func _update_count(increase_by: int): _current_count += increase_by _check_objective_fulfilled() func _check_objective_fulfilled(): if _current_count >= amount: emit_signal("completed", self)
And badda-bing badda-boom, there we have it! A simple logic to update the Quest resource and check for completion.
Hooking it up
Of course, we still aren’t using the script anywhere so even if we create a Quest resource and define the fields, the game behavior isn’t going to change. Let’s fix that!
There are a couple of ways we could go about this, but in keeping with the simple-code strategy let’s just make a middle man script that can act as a sort of event emitter hub. We also need to define a generic Game Manager script to keep track of various game states, so I think it makes sense to combine those two for now.
# game_manager.gd extends Node var _hero_quest: Quest var _overlord_quest: Quest func set_hero_quest(quest: Quest): _hero_quest = quest Utils.connect_signal(_hero_quest, "completed", self, "_end_game", [true], CONNECT_ONESHOT) func set_overlord_quest(quest: Quest): _overlord_quest = quest Utils.connect_signal(_overlord_quest, "completed", self, "_end_game", [false], CONNECT_ONESHOT) func _end_game(quest, was_hero_quest: bool): var who = "hero" if was_hero_quest else "overlord" print("Game over, " + who + " player wins!")
We will want this script to be available for all other scripts so we’re going to turn to the tried-and-true AutoLoad feature again! Making this script an AutoLoaded script allows us to access its members and functions within any other script.
So we’ve completed half of this script’s purpose (holding game state), now let’s add some signals to act as a central event relay-er.
The reason we need an event relay is because when scripts want to listen for a certain signal, but that signal is only emitted by dynamic scripts that can be created and destroyed at any time, the would-be listener is unable to determine (easily) when to start and when to stop listening to script signals. By using a middle man event relay we add a level of abstraction to the process, allowing both the emitter and the listener to only care about the single static instance that will always be available.
It might make a little more sense in practice, so let’s do that. First let’s define a signal type in “game_manager.gd” that we know our quest wants to listen for.
signal unit_died(unit)
Then we can hook up both sides in their respective scripts.
# unit.gd func _update_hp(newValue): # ... existing code ... if hp <= 0: GameManager.emit_signal("unit_died", self)
# quest.gd func begin_tracking(): _current_count = 0 Utils.connect_signal(GameManager, "unit_died", self, "_on_unit_killed", [])
And just like that, whenever a unit’s HP drops to 0 or below, any active quests will be notified and will run their
_on_unit_killed()
function. Lastly we need to make sure that the quest’sbegin_tracking()
function gets called, so we can do that in theGameManager
when setting the quests.func set_hero_quest(quest: Quest): _hero_quest = quest Utils.connect_signal(_hero_quest, "completed", self, "_end_game", [true], CONNECT_ONESHOT) # ADD THIS LINE! _hero_quest.begin_tracking() func set_overlord_quest(quest: Quest): _overlord_quest = quest Utils.connect_signal(_overlord_quest, "completed", self, "_end_game", [false], CONNECT_ONESHOT) # ADD THIS LINE! _overlord_quest.begin_tracking()
Game script
One last thing needs to happen before everything is set up and ready to play, and that is calling the
set_hero_quest()
andset_overlord_quest()
functions! Where will those be called?Up until now my main demo scene has been using a script called “demo_scene.gd.” I’m going to replace that with a new script I’ll just call “game.gd” since I anticipate this one to be more permanent. In that script I’ll create two exported variables for the hero quest and overlord quest respectively. Then in the
_ready()
lifecycle hook I’ll call theGameManager
functions to set the quests. To get the game loop started, I’ll kick it off by calling the PhaseManager’sstart_hero_turn()
.extends Node export(Resource) var hero_quest export(Resource) var overlord_quest onready var _phase_manager = $PhaseManager func _ready(): GameManager.set_hero_quest(hero_quest) GameManager.set_overlord_quest(overlord_quest) _phase_manager.call_deferred("start_hero_turn")
This has been a fairly simple example showing how one might go about defining and tracking quest objectives, feel free to expand on the idea! Some things to think about:
- Maybe the heroes & overlord can have more than 1 quest to accomplish each; how would you allow for that?
- What if the quests should be sequential (one has to be completed before another)?
- How would you modify the hero quest to kill a full monster group instead of a single monster?
- Maybe there are optional side quests for each encounter; how would you acquire and accomplish those quests, and what reward would you get?
- Besides kill quests, what other quest types could you add?
The code for this post can be found here.
- Maybe the heroes & overlord can have more than 1 quest to accomplish each; how would you allow for that?
-
Project Delve Part 8.2: Wheel Battle
And here we are at the final of the wheel posts. In this one I’ll walk through how I created a scene to hold both wheels and instantiate the scene when a battle happens.
Scene Setup
As with so many of these posts, we’ll start by creating a new scene (I’ve named it “WheelBattle”). We’ll pick a
CanvasLayer
for the root node type, since we want this scene to be an overlay. It will be positioned in canvas space rather than in world space which allows it to be appropriately scaled depending on the screen size of the window the game is running in.Directly under the root node, add a
CanvasModulate
node. That node will tint each of its children a specified color, which we can use to tint the alpha channel of all child nodes at the same time. This will come in handy for fading in/out the scene as a transition.Then there are 4 nodes we’ll add as children to the
CanvasModulate
node: one for the background color, one for the screen separator (optional), and one for the attacking/defending wheels respectively. Because I want to have a fun dynamic intro animation whenever the scene pops up, I’m going to give each of these items their ownTween
child. This will allow me to tween their properties independently of one another.Finally, the last thing I want to add is a representation of which unit is attacking and which unit is defending. The easiest way to do that is to just use their sprite, so I’ll create a spot for those sprites to sit as well.
The final scene tree will look something like this:
Animating
I’ll try not to spend too long on this section since it’s not as important to this post. I won’t be providing the exact code within this post, but I’ll cover the overview of what I imagine will happen.
Firstly, I imagine that there will be a background that fades in on top of the game. It will remain a little transparent so you can still see the game through the overlay.
Then the screen separator will appear. I think a mask reveal from left to right would be fun, so I’ll write a simple shader to allow me to specify the amount of the image that will be visible from 0-1. My separator will go from the top left to the bottom right — the attacking unit & wheel will be on the left side of the separator, with the defending unit & wheel on the right.
Then the two wheels will animate in at the same time from offscreen towards the center. The attack wheel will come from the left side and the defense wheel from the right.
Finally the two unit sprites will fade in and the battle scene will be fully visible.
Here’s an example of what my intro animation looks like:
I encourage you to think up how you’d animate these items! Part of the reason I didn’t provide code for this part was because I’d like you to think about how you’d go about doing this. We’ve learned about animating with
Tween
s in the previous post, and shaders in the post before that. This scene will use both!The exit animation will simply be a fade out, which we can easily do by tweening the
CanvasModulate
‘scolor
property’s alpha.
Battle Manager
And we’re back again with another manager script! Like the others, we want to be able to access this manager script from anywhere in the code, so I’m going to make it an Autoloaded script (you can refresh yourself on what that is in this post).
There are a few variables we want to keep track of within this script. Firstly, we want a reference to the scene we just created so that we can instantiate it whenever we need to.
var _wheel_battle_scene = preload("res://scenes/WheelBattle.tscn")
We should also probably define some signals so that other processes can listen for the results of the battle, in case some game effect needs to happen.
# I recently discovered you can tell Godot how many arguments to expect from # a signal. It doesn't do anything, but it is nice for reference. signal battle_started(attacker, defender) signal battle_ended(attack_result, defend_result)
We’ll want a reference to the attacking and defending unit, and also the result of the battle.
var _attacker: Unit var _defender: Unit var _attack_result: WheelSectionData var _defend_result: WheelSectionData
And lastly, I don’t know if we will need these or not, but I added them in anyway because I’m bad at following best coding practices and love to over-engineer things — two arrays that will contain hooks (callbacks) we can call before and after the battle. This may be unnecessary since we have the signals defined, but the advantage that these hooks can provide is that we can ensure all of them get run before the battle code continues. Signals can’t ensure that will be the case since they’re asynchronous.
var _before_spin_hooks := [] var _after_spin_hooks := []
Script functionality
We need some way to initialize our variables, so let’s create an initializer function.
func init_battle(attacker: Unit, defender: Unit): _attacker = attacker _defender = defender _attack_result = null _defend_result = null emit_signal("battle_started", _attacker, _defender)
You’ll notice that we’re emitting the
battle_started
signal in the initializer function. I decided to do it this way so that our actual battle execution function will only have to focus on the battle.Let’s also create a cleanup function that we can call after the battle results are calculated.
func cleanup_battle(): emit_signal("battle_ended", _attack_result, _defend_result) _before_spin_hooks.clear() _after_spin_hooks.clear()
Now let’s define the primary function that we will call. This call should take care of instantiating the battle scene, spinning the wheels, getting the results, and removing the battle scene.
func _battle_flow(): var battle = _wheel_battle_scene.instance() # Add instantiated scene to current scene get_tree().current_scene.add_child(battle) battle.set_attacker(_attacker, _attacker.get_attack_wheel_sections()) battle.set_defender(_defender, _defender.get_defense_wheel_sections()) # Animate the scene in yield(battle.animate_in(1.25), "completed") # Wait a moment after animation completes yield(get_tree().create_timer(.5), "timeout") # Spin the wheels for X seconds and get the results [atk_result, def_result] var wheel_results = yield(battle.spin_wheels_for_duration(.75), "completed") _attack_result = wheel_results[0] _defend_result = wheel_results[1] # Wait a moment after spin completes yield(get_tree().create_timer(.75), "timeout") # Fade the scene out yield(battle.fade_out(), "completed") # Remove battle scene from current scene get_tree().current_scene.remove_child(battle)
I added some pauses during the flow so that the player(s) will be able to register what’s happening, instead of just running through the steps one by one; starting and finishing the battle before even realizing that something happened. Adding pauses also adds to the suspense of the result (though not too many pauses or else it just feels drawn out). Feel free to adjust these timings to your liking.
You may have noticed that I made this function private. That’s because I want to call those hook functions before and after this function gets run, so I’ll expose a different method that should be called which does all of that and returns the battle results.
func do_battle(): yield(_run_before_spin_hooks(), "completed") yield(_battle_flow(), "completed") yield(_run_after_spin_hooks(), "completed") return [_attack_result, _defend_result]
Calling the hooks
The hook function arrays could be empty, so we can’t just assume the
_run_before_spin_hooks()
and_run_after_spin_hooks()
are going to beyield
able unless we account for that case. We’ll add the following code to the start of both of those hook-calling methods.if <hook_functions_array>.empty(): yield(get_tree(), "idle_frame") return
Otherwise we can call the functions one by one.
# _run_before_spin_hooks() for cb in _before_spin_hooks: yield(Utils.yield_for_result(cb.call_funcv([_attacker, _defender])), "completed") # _run_after_spin_hooks() for cb in _after_spin_hooks: var updated_results = yield( Utils.yield_for_result(cb.call_funcv([_attack_result, _defend_result])), "completed" ) # We will allow these hooks to modify the result of the wheel spins _attack_result = updated_results[0] _defend_result = updated_results[1]
The
Utils.yield_for_result()
is a helper function I created in myUtils
singleton script. All it does is ensure that the function result is aGDScriptFunctionState
object. If not, it yields for one frame before returning, ensuring that any function wrapped within it can be yielded.# utils.gd func yield_for_result(result): if result is GDScriptFunctionState: result = yield(result, "completed") else: yield(get_tree(), "idle_frame") return result
Finally, one last piece to add to this manager script is the ability to register the hooks.
func register_before_spin_callback(cb: FuncRef): _before_spin_hooks.append(cb) func register_after_spin_callback(cb: FuncRef): _after_spin_hooks.append(cb)
Using the Battle Manager
Now that we’ve completed all of the work of building the battle system, all that remains to do is plug it into the existing code! Fortunately this is a very simple matter, since our attack action code has already been set up previously. All we need to do is replace the logic of how the damage is calculated with the following:
# unit_actions.gd -> do_attack_action() BattleManager.init_battle(unit, target_unit) var results = yield(BattleManager.do_battle(), "completed") BattleManager.cleanup_battle() if results[0].miss: print("Attack missed!") else: var dmg = results[0].attack_points - results[1].defense_points if dmg > 0: dmg = target_unit.take_damage(dmg) print(target_unit.name + " took " + str(dmg) + " damage from " + unit.name) else: print(target_unit.name + " took no damage")
Now let’s see this all in action!
I apologize if this post seemed somewhat rushed, I’ve been eager to get on with finishing up the project and this whole Project Delve series. There are other things I’d like to write about in the future too!
As with the previous two posts, the code for this section can be found here.
-
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 thewheel_sections
variable (which will be an array ofWheelSectionData
). So I’m going to make asetget
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 atool
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 itsowner
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 settingowner
, 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 at0
.# _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 ofWheelSectionData
in the scene’s editor and watch the wheel be drawn as you update it! (Don’t forget to also assign thewheel_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
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.- Wheel start spin (controlled by
Tween
node) - Wheel continue spin (controlled by
_process()
function) - 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 theTween
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, theTween
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 therand_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 therand_value
less than the currentpercent_of_wheel
summed with the previouspercent_of_wheel
s.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 therandomize()
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!
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)
- Wheel start spin (controlled by
-
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:
- We’re not limited to exactly 6 outcomes. There can be fewer or there can be more!
- 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 ofSprite
, and I assigned my circle texture to theSprite
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 newGridContainer
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 theTextureRect
s 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 itwheel_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 somesetget
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.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 ourShaderMaterial
is part of aCanvasItem
node, theshader_type
will becanvas_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 someuniform
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
, andCOLOR
.TEXTURE
refers to the full texture attached to the material (i.e. our circle sprite)UV
refers to the coordinates of the current pixel thefragment()
function is operating onCOLOR
refers to the color value of the current pixel thefragment()
function is operating on
Modifying
COLOR
is how we will update the color value of the pixel. Thetexture()
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 returnfalse
. 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 provideduv
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 ourend_angle
. If it is, we can draw it!To find the angle of our point we will use the
atan()
function, which takes anx
andy
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
andy
value of ourend_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!)Then we can round off the function by comparing the
point_ang
to thecutoff_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); } }
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!
The code up to this point in the project can be found here.
-
Project Delve Part 7: Skills
Sorry it’s been a minute, I’ve been pretty busy the last few weeks and haven’t spent much time on the project. Also to be fully transparent, I’ve started to lose that initial drive I had for the project. That’s not to say I don’t want to complete it — I do! But I want to be completely honest about my process here so I thought that it’d be a good idea to point that out. This is also the primary reason I started this blog, so that when I start to get into a productivity slump or lose motivation I have a sort of “external incentive” to persevere. So with that out of the way, let’s dive into what we’re going to cover today: Skills!
The Process
So, I’m a pretty big fan of Object Oriented Programming and inheritance. Thus my initial plan to create skills was to create some sort of super class that would act as an interface for individual skills to inherit from.
However, after pondering how to best structure the super class for nearly a week (without actually coding or diagraming anything) I realized the best strategy for architecting the skills classes would be from the bottom-up instead of from the top-down. That is, code a skill or two and determine from the concrete code the parts that could be abstracted, thus helping me to template future skill implementations.
The skill
I picked a basic skill from the Knight class called Oath of Honor. It’s one of the skills a knight character will start with so I thought it made as much sense as anything to begin there. After reading the skill description again to refresh my memory I determined that there are two parts to this particular skill:
- Determining if the skill can be used
- Using the skill
I think that this can probably be extrapolated to most other active skills (as opposed to passive skills, which I’ll cover later), so already we’ve found something that can be abstracted! Let’s create the super class now with those two methods and just plan on making more modifications as we go along.
# skill.gd extends Node class_name Skill func can_use() -> bool: pass func use(): pass
Now that we have a rough template to work from, let’s create the Oath of Honor skill script.
# sk_oath_of_honor.gd extends Skill func can_use() -> bool: pass func use(): pass
Defining can_use()
Nearly all skills will have a stamina cost associated with them, and if the unit does not have the requisite amount of stamina they cannot use the skill. So let’s define a basic function to check for that.
# skill.gd func _has_enough_stamina(): return hero.stamina >= stamina_cost
…Oh, but wait. We don’t have a reference to the hero that’s using the skill. Let’s plop that on the
Skill
class real quick. We can worry about assigning it later.var hero: Unit
We also need a reference to how much the skill will cost, so let’s add that too.
var stamina_cost: int
Now in the
sk_oath_of_honor.gd
script let’s call that function inside thecan_use()
function.# sk_oath_of_honor.gd func can_use() -> bool: if not _has_enough_stamina(): return false # Do additional checks here... return true
The additional checks
Reading the skill description for Oath of Honor, the prerequisites for using this skill (besides its mana cost) are:
- An ally hero is within 3 spaces of the current hero
- That ally has a monster adjacent to them
- There is an empty space adjacent to that monster
In order to find these things we’re going to need an easy way to query the dungeon grid, which brings up the next problem to figure out. I can foresee there being many uses for querying unit positions and dungeon proximities, so I think we should make a new singleton script that has the sole purpose of taking these kind of queries.
DungeonManager
Because I’ve been fond of making so many managers lately, what’s one more? Let’s create a new script called
dungeon_manager.gd
and add it to the list of AutoLoaded scripts so that it’s globally available. This script will need a reference to the current dungeon object, so let’s add a variable to hold that and a function to set it.# dungeon_manager.gd extends Node # Not setting the type of this because it would # create a circular dependency... strike 4,001 against GDScript var _active_dungeon func set_active_dungeon(dungeon): _active_dungeon = dungeon
We’ll be adding functions to this class as we find need for them. For the Oath of Honor skill we need to know those three prerequisites I outlined earlier, so let’s make functions that can help us find what we need. I want to try and keep the script generalized so that it can be used for more things than just unit position querying, so I’m going to name the functions to reflect that.
# Returns the nodes within groups specified by node_groups array # that are within tile_radius number of tiles of the world_point func get_nodes_within_grid_radius(world_point: Vector2, tile_radius: int, node_groups: PoolStringArray): pass # Will get a list of all potentially empty spaces adjacent to the # tile and then remove any space occupied by a node in obstacle_groups func get_empty_grid_spaces_adjacent_to(world_point: Vector2, obstacle_groups: PoolStringArray = []): pass # Just a proxy function for the dungeon's method func get_grid_coordinates_of_node(node: Node): return _active_dungeon.get_grid_position(node.position) # Yes, these functions need to be renamed. It's confusing # to have to remember which function to use on what object # especially when GDScript has such poor intellisense support. func grid_to_world_position(grid_coordinate: Vector2, center_of_tile := false): return _active_dungeon.map_to_world_point(grid_coordinate, center_of_tile)
Before filling out the implementation of these functions, let’s return to the Oath of Honor script and use these methods as though they work flawlessly (obvi).
Implementing the Rest of the Skill
We’re going to need a list of valid tiles that the hero can move to when they use this skill. We’re also going to need to calculate those same spaces for determining whether the skill can be used, so it makes sense to only run the calculations for finding those spaces once and store the result. For that let’s make a function to do those calculations.
# sk_oath_of_honor.gd var _valid_grid_spaces := [] func _get_valid_grid_spaces(): # Get a list of all heroes in range of the skill var allies_in_range = DungeonManager.get_nodes_within_grid_radius(hero.position, 3, ["heroes"]) for ally in allies_in_range: # Get a list of the monsters next to the hero in range var monsters_next_to_ally = DungeonManager.get_nodes_within_grid_radius(ally.position, 1, ["monsters"]) if monsters_next_to_ally.size() > 0: # Get a list of empty spaces next to ally var ally_spaces = DungeonManager.get_empty_grid_spaces_adjacent_to(ally.position, ["units"]) # Get a list of empty spaces next to monster var monster_spaces := [] for monster in monsters_next_to_ally: monster_spaces.append_array( DungeonManager.get_empty_grid_spaces_adjacent_to(monster.position, ["units"]) ) # Take intersection of the two lists var intersection := [] for grid_space in ally_spaces: if monster_spaces.has(grid_space): intersection.append(grid_space) # Add result of the intersection to _valid_grid_spaces _valid_grid_spaces.append_array(intersection)
Now in the
can_use()
function we just have to run this function and return whether the size of the_valid_grid_spaces
array is greater than0
, indicating that there’s a valid space to move to.func can_use() -> bool: if not _has_enough_stamina(): return false return _ensure_valid_grid_spaces() func _ensure_valid_grid_spaces(): if _valid_grid_spaces.size() > 0: return true _get_valid_grid_spaces() return _valid_grid_spaces.size() > 0
Defining use()
Now the second part comes for creating the skill: using it! We still don’t have all systems properly in place to fully finish the skill, but we have enough at this point where we can throw together something that works.
func use(): # Calling this function mainly ensures that the # _valid_grid_spaces array has been populated if not _ensure_valid_grid_spaces() return # TODO: Select one of the valid spaces # Just use first in list for now var selected_grid_space = _valid_grid_spaces[0] # Subtract stamina hero.stamina -= stamina_cost # Move unit to selected space hero.position = DungeonManager.grid_to_world_position(selected_grid_space, true) # Attack monster adjacent to ally # TODO: perform attack... print("Attack...") return
Finishing DungeonManager functionality
Alright, like I promised let’s go back to the
DungeonManager
and fill out the function stubs we added. The logic for getting nodes within a tile radius is fairly straightforward. First we’ll get the list of nodes in the scene we care about and then check whether the position of the node is within the given radius.func get_nodes_within_grid_radius(world_point: Vector2, tile_radius: int, node_groups: PoolStringArray): var within_radius := [] for node_group in node_groups: var nodes = get_tree().get_nodes_in_group(node_group) for node in nodes: if ( not within_radius.has(node) and _active_dungeon.tile_distance_to(world_point, node.position) <= tile_radius ): within_radius.append(node) return within_radius
The steps for getting the empty grid spaces adjacent to a grid tile are a little more complex. First we’ll add a new function to the
Dungeon
class calledwalkable_tile_exists_at()
, which will simply check for whether a tile exists at the given point and no grid obstacle exists at that point.# dungeon.gd func walkable_tile_exists_at(world_point: Vector2): var grid_pos = get_grid_position(world_point) return floors.get_cellv(grid_pos) != TileMap.INVALID_CELL and obstacles.get_cellv(grid_pos) == TileMap.INVALID_CELL
Now we just need to run this check for each adjacent tile space and add the results that return
true
to an array.func get_empty_grid_spaces_adjacent_to(world_point: Vector2, obstacle_groups: PoolStringArray = []): var empty_spaces := [] # The _get_neighbors() function simply returns the 8 points that surround # the world_point, adding the cell_size so we can get the grid coordinate # at the neighbor point for neighbor_point in _get_neighbors(world_point, _active_dungeon.floors.cell_size): if _active_dungeon.walkable_tile_exists_at(neighbor_point): empty_spaces.append(_active_dungeon.get_grid_position(neighbor_point))
The next thing we need to do in the function is remove spaces where an obstacle exists (like units or interactables).
for group in obstacle_groups: var obstacles = get_grid_coordinates_of_group(group) for obstacle in obstacles.keys(): empty_tiles.erase(obstacle) return empty_tiles func get_grid_coordinates_of_group(group: String) -> Dictionary: var coordinates := {} var nodes = get_tree().get_nodes_in_group(group) for node in nodes: coordinates[_active_dungeon.get_grid_position(node.position)] = node return coordinates
Testing it out
Now the fun part! Will it work? Let’s find out!
Note that I had to jerry-rig a few things together before testing it out here, such as adding the skill to the list of available skills for the knight. Also, you can’t tell very well due to my bad unit HUD implementation, but the stamina bar for the knight goes down by 1.
SelectionManager Update
The skill card says that the Knight is supposed to move to the “closest available empty space” next to the monster, but I would prefer if the player got to choose which space to jump to. So that’s what I’ll do! I love making the rules!
Let’s add a new function in the
SelectionManager
to listen for a grid tile to be clicked.# selection_manager.gd # Create a new signal to emit when grid tile is clicked signal grid_tile_selected func select_grid_tile(valid_grid_coordinates = null): Utils.connect_signal(_active_dungeon, "grid_tile_clicked", self, "_clicked_tile", [valid_grid_coordinates]) # This function needs the first three arguments because that's what # the grid_tile_clicked signal emits with in the dungeon script, even # though we don't need all of them here. func _clicked_tile(event, loc, pathfinder, valid_grid_coordinates): var grid_coordinate = _active_dungeon.get_grid_position(loc) if valid_grid_coordinates and valid_grid_coordinates.size() > 0 and not valid_grid_coordinates.has(grid_coordinate): # If the selected tile was not valid, we simply return and # wait for a valid tile to be selected return Utils.disconnect_signal(_active_dungeon, "grid_tile_clicked", self, "_clicked_tile") emit_signal("grid_tile_selected", grid_coordinate)
Then in the Oath of Honor skill we can swap the auto-selecting of the first valid grid space with the user-selected space.
# Select one of the valid spaces # var selected_grid_space = _valid_grid_spaces[0] SelectionManager.select_grid_tile(_valid_grid_spaces) var selected_grid_space = yield(SelectionManager, "grid_tile_selected")
Testing the selection logic
Now let’s run the code again and see if it works as expected!
I created a tile highlighting effect for better demonstrating, but in hindsight I think it’d be better to highlight all available tiles when the skill is selected instead of only when you hover over a tile.
Last Step (For Now)
Okay, so I’ve been a little coy with showing you demonstrations of the code running without telling you exactly how I finished the setup. I just wanted to cover this topic last since I didn’t originally think of this architecture pattern when first creating the skill, so in a way this post sort of follows my own journey of discovery as I found better ways to do certain things as I worked on it.
Because skills are tied to classes and not to characters (in the way Descent is designed, anyway) it made the most sense to me to have the skills be their own
Resource
s, just like how units are their ownResource
s. (Remember, aResource
class in Godot is basically just a data class, akin to Unity’sScriptableObject
)The problem, though, is that skills are a combination of both data and functionality. I want to be able to separate the two so that we can store a reference to the skill’s data and access its functionality later during run-time. Separating things this way also helps with decoupling the classes and interdependencies on one another, and we won’t have to define a new resource type for each different skill we create. I also like this idea because it allows for swapping functionality during run-time if we want to do that somewhere down the line. I also have this idea in my head of defining all of the data for the game in an XML/JSON file format that can be loaded in dynamically, which would only be capable of storing data, not functionality. But it could store a reference to a script class!
…It’s hard to articulate thoughts sometimes, ya know?
Anyway, I got this idea from the game Rimworld, which I made a few mods for. They split their classes this way as well, allowing you to define your data in one place and implement it in another.
The data that units will store will not be
Skill
s, they’ll beSkillDef
s. EachSkillDef
will have all the skill information like name, description, stamina cost, experience cost, etc. But it will also contain a path reference to the script file that will implement it, which will be used dynamically during the game.Here’s how I defined the
SkillDef
resource class.extends Resource class_name SkillDef export(String) var skill_script_path := "res://scripts/skills/skill.gd" export(String) var skill_name export(String) var skill_description export(bool) var is_action export(bool) var is_interrupt export(int) var stamina_cost export(int) var experience_cost # Because the node is created on the fly, we also # need to make sure to free it when we're done with it. # Otherwise they will start to take up game memory. func get_skill(hero_ref) -> Node2D: var skill_node := Node2D.new() skill_node.name = skill_name # This is where we dynamically load the skill's functionality skill_node.script = load(skill_script_path) skill_node.hero = hero_ref skill_node.skill_def = self return skill_node
Then I’ll add the skill resource onto the Nite character’s
skills
array (which you’ll need to define) and suddenly it’s available to be used!
And I think that just about covers it! Again, there’s some new stuff I added that I didn’t write about (things like code refactoring and clean-up) but the main purpose of this post was to start to understand how to build
Skill
s for units to use. Hopefully with a little more work we’ll be able to crank these out like hotcakes!The code for this lesson and project up to this point can be found here.
-
Project Delve Part 6.2: Overlord and Monster Phases
Hiya again, I’m back with the next steps for making the Turn System! (Well, I suppose the actual turn system itself is already completed with the functioning state machine, but I want to fully flesh out the selecting and performing of each of the unit’s turns)
Today the focus is going to be on the Overlord and monster phases. The code for the
MonsterActionPhase
will look almost identical to the code for theHeroActionPhase
, since monsters are people too.…Okay, maybe not. But the code will still remain very similar. So similar in fact that I wonder whether I should combine the
HeroActionPhase
and theMonsterActionPhase
into one base class. That would probably be the wise thing to do, but since when have I been wise? I’m going to not worry about that particular good-practice right now and just continue on, since I’m eager to keep up my momentum.
MonsterActionPhase
I’ll start by copying all of the contents of the
hero_action_phase.gd
script and pasting it into themonster_action_phase.gd
script. Then I’ll rename a few variables and change theclass_name
value back toMonsterActionPhase
so that the top of the script now looks like this.extends State class_name MonsterActionPhase var monster: Unit var action_points: int var leftover_movement: int func _init(sm: StateMachine, unit: Unit).(sm, "MonsterActionPhase"): monster = unit action_points = 2 leftover_movement = 0
Now, the monsters don’t get as many action choices as the heroes do. Specifically, they aren’t able to do the Stand Up action or the Revive action, since when a monster is defeated they’re removed from the game. They also aren’t able to do the Rest action since they don’t have stamina points.
The other major difference between a hero’s turn and a monster’s turn is that monsters are only allowed to spend one action point on an attack during their turn, whereas heroes are allowed to use both action points on attacking, if they so choose.
So with all of that in mind, let’s modify the
_get_available_actions()
function to remove the unavailable action types, as well as make an additional check when determining whether to allow the Attack action.var has_attacked: bool func _init(sm: StateMachine, unit: Unit).(sm, "MonsterActionPhase"): # ... has_attacked = false # ... func _get_available_actions(): var available = [UnitActions.Actions.end_turn] if action_points <= 0: if leftover_movement > 0 and UnitActions.can_do_move_action(monster): available.append(UnitActions.Actions.move_extra) else: if not has_attacked and UnitActions.can_do_attack_action(monster, null): available.append(UnitActions.Actions.attack) if UnitActions.can_do_move_action(monster): available.append(UnitActions.Actions.move) if UnitActions.can_do_interact_action(monster): available.append(UnitActions.Actions.interact) if UnitActions.can_do_skill_action(monster): available.append(UnitActions.Actions.skill)
As a side note, I moved the
Actions
enum to theUnitActions
singleton since both the heroes and monsters will be using it.Then all we need to do is modify the
_action_selected()
function to remove the actions that cannot be selected! We can also set thehas_attacked
variable in theUnitActions.Actions.attack
block.func _action_selected(action): # ... match action: # ... UnitActions.Actions.Attack: has_attacked = yield(UnitActions.do_attack_action(monster, "heroes"), "completed")
And that pretty much covers the modifications we need to make to the
MonsterActionPhase
!
GUI
In the last post we made a
GUIManager
with aHeroTurnGUI
scene. Because the monster group phase will behave very similarly to the hero group phase I think we can repurpose that GUI to serve both the heroes and monsters! The only differences will be which actions are available, which we already covered in the last section.Firstly, since this will now be used by both the heroes and the monsters I think it makes sense to rename it to something less hero-centric. I’ve opted to rename it to
UnitTurnGUI
, and I’ll accordingly change the function name inGUIManager
to beget_unit_turn_gui()
instead ofget_hero_turn_gui()
, as well as remove the “gui_type” metadata and instead just assign the scene to a group “unit_gui”.The other change I thought to make for the
UnitTurnGUI
is to change the column count of the button row to match the number of visible buttons. That way we won’t get overflowing buttons like we saw in the demo last week.The function now looks like this:
func enable_buttons(action_list: Array, hide_disabled_buttons = false): var count = 0 for action in _btn_map.keys(): var btn = _btn_map[action] if action_list.has(action): btn.disabled = false btn.visible = true else: btn.disabled = true btn.visible = ( false if ( hide_disabled_buttons or action == UnitActions.Actions.stand or action == UnitActions.Actions.move_extra ) else true ) if btn.visible: count += 1 _button_grid.columns = count
Unit activation order
Another key thing we’ll need to be able to do is select which units we want to activate when. Currently we’re just activating the units in the order they’re in within the scene tree, but part of the strategy of the game will be determining the order of operations for your units’ activations.
I think that selecting the unit’s avatar image might work, so let’s try that out.
Let’s create a new script and name it something like
avatar_selection_gui.gd
. This GUI will hold the list ofCharacterAvatar
scenes previously held by theUnitTurnGUI
, so we can remove the reference to that fromUnitTurnGUI
and add it to theAvatarSelectionGUI
. There are two ways to go about doing this, one would be to sub-class theUnitTurnGUI
to theAvatarSelectionGUI
. The other would be to create a new scene with theAvatarSelectionGUI
as the scene root node. I like the second approach more since it keeps the UI responsibilities separate, so that’s what I’ll do.We’re going to add three new functions to the
AvatarSelectionGUI
, one to enable selection, one to disable selection, and one to set which avatars should be shown in grayscale (indicating they’ve already been activated).Let’s start with the
enable_avatar_selection()
anddisable_avatar_selection()
functions.func enable_avatar_selection(disabled_options = []): for i in range(_avatar_list.avatars.size()): if disabled_options.has(_unit_list[i]): # If the avatar is disabled we don't want # to connect the following callbacks. continue var avatar = _avatar_list.avatars[i] # The "clicked" signal is a custom signal on character_avatar.gd # The mouse_entered and mouse_exited are signals provided by the Control node avatar.connect("clicked", self, "_on_clicked_avatar", [avatar]) avatar.connect("mouse_entered", self, "_on_hover_over_avatar", [avatar, true]) avatar.connect("mouse_exited", self, "_on_hover_over_avatar", [avatar, false]) func disable_avatar_selection(): for avatar in _avatar_list.avatars: avatar.disconnect("clicked", self, "_on_clicked_avatar") avatar.disconnect("mouse_entered", self, "_on_hover_over_avatar") avatar.disconnect("mouse_exited", self, "_on_hover_over_avatar")
The
_on_clicked_avatar()
function will emit a custom defined signal,avatar_clicked
, along with the unit the avatar represents. How do we know which unit the avatar represents? I’m glad you asked! In theset_avatar_list()
function that we brought over from theUnitTurnGUI
we’re given a list of units. From those units we instantiate all the avatar scenes we need. All we need to do is add an extra line of code in that function to link the avatar with the unit it represents.avatar.set_meta("linked_unit", unit)
Then in the
_on_clicked_avatar()
function, we just need to put the following.emit_signal("avatar_clicked", avatar.get_meta("linked_unit"))
The other function about hover enter and exit will set the border outline to white or black, depending on whether it’s entering or exiting.
func _on_hover_over_avatar(avatar, entering): if entering: avatar.border_color = Color.white else: avatar.border_color = Color.black
As for the grayscaling, I just added another option to the
CharacterAvatar
shader indicating whether it should be grayscaled or not, and thegrayscale_avatars()
function will set this option on the desired avatars.And that pretty much covers it!
Testing it out
The last thing to do is replace the current
_select_next_hero()
/_select_next_monster()
functions with using this functionality instead of using the next unit in the list. This is as simple as connecting to theavatar_clicked
function on theAvatarSelectionGUI
and starting the selected unit’s turn.# For simplicity I'm using the term "unit" rather than "hero" # or "monster" but the function will behave the same for both. func _select_next_unit(): var gui = GUIManager.get_avatar_selection_gui() gui.set_avatar_list(units) gui.set_header_text("Select unit...") gui.grayscale_avatars(_have_finished_turn) gui.connect("avatar_clicked", self, "_start_unit_turn", [], CONNECT_ONESHOT) gui.enable_avatar_selection(_have_finished_turn) func _start_unit_turn(unit): GUIManager.get_avatar_selection_gui().disable_avatar_selection() # Change state to unit turn start phase ...
Want to play “see how many bugs you can spot?” We’re now able to select which unit we want to activate, and you can see the columns get resized when there’s an extra action available. The attack action is also only available once per turn for monsters. Sweet! We’ve verified our code works!
Monster Groups
Now, if all we had to do were select between any monster on the screen then our work would be done! (Minus one or twelve bug fixes) However, the Overlord’s monster phase works by selecting a monster group, and then activating all monsters within that group before moving on to the next group. That means that we need yet another layer of selection logic.
Fortunately, because we separated the
AvatarSelectionGUI
from theUnitTurnGUI
we only need to worry about the first in theOverlordMonsterPhase
. However, we’ll need to tweak theAvatarSelectionGUI
slightly in order to accommodate non-unit nodes (since theOverlordMonsterPhase
will be concerned with nodes that have units as their children, rather than the units themselves).Fortunately this won’t be too much of a hassle. We’ll just add a second parameter option to the
set_avatar_list()
function,is_unit_group
. This will affect the function in the following way.func set_avatar_list(units, is_unit_group = false): _unit_list = units var avatars = [] for unit in units: if not unit or (is_unit_group and unit.get_children().size() == 0): continue var avatar = _avatar_scene.instance() var meta_key if is_unit_group: # We're assuming that all children of the unit group node # are of type "Unit" avatar.character_sprite = unit.get_children()[0].unit_data.sprite meta_key = "linked_units" else: avatar.character_sprite = unit.unit_data.sprite meta_key = "linked_unit" avatar.set_meta(meta_key, unit) avatars.append(avatar) _avatar_list.set_avatar_list(avatars, true)
Then when in the callbacks like the
_on_clicked_avatar()
or_on_hover_over_avatar()
we just need to check for which meta key is defined before using the appropriate one.if avatar.has_meta("linked_units"): ...
All Done!
And that’s it! Obviously I didn’t provide every new line of code I wrote, but I hope that I explained my process well enough for people to follow along and fill in the spaces themselves. Here’s a quick demo of checking that the unit selection process is working seamlessly with the state machine.
The code for all the posts up to this point can be found here.
-
Project Delve Part 6.1: Hero Action Phase
Alrighty, there’s a lot to cover in this one so let’s jump right into this!
Breaking Up the UnitActions Singleton
Up until this point I’ve been throwing a lot of functionality into the
unit_actions.gd
script, but the time has come to delegate responsibility. From now on, theUnitActions
singleton will only be in charge of- Checking whether an action can be performed
- Performing that action
Almost all of the code that was in that class will be split into two additional classes:
DrawManager
andSelectionManager
. I’ll get to those in a minute, but first let’s stub the functions theUnitActions
class will implement. The way I’ve done this is by defining acan_do_<ActionName>_action()
anddo_<ActionName>_action()
method for each possible action a hero can perform. The “can_do” functions will make checks (outlined in the blue boxes in the flowchart a few sections down) and return abool
value indicating whether the action is valid. The “do” functions will actually perform the actions and return when the full action has been completed, thus they will need to be coroutine functions that utilize theyield
keyword.As an example, I’ll demonstrate a simple implementation of the Rest action.
func can_do_rest_action(unit) -> bool: return true func do_rest_action(unit): unit.rest() yield(get_tree(), "idle_frame")
For now, feel free to just stub out the rest of the action methods and we’ll return to them later.
DrawManager
The
DrawManager
class will be responsible for making common custom draw calls on the screen. For the functionality we’ve made so far, this includes drawing the path a unit will take when it moves, and drawing a target and line of sight when making an attack. Like theUnitActions
class, we’ll add this to the list of auto-loaded scripts so that it can be accessed from anywhere.The purpose of these manager classes is to expose the abstraction and hide the implementation. All that outside classes need to know about the
DrawManager
is that it can make draw calls in the way they need it to. So let’s start by defining the public-facing functions in theDrawManager
.# draw_manager.gd func enable_path_drawing(): pass func disable_path_drawing(): pass func enable_target_drawing(): pass func disable_target_drawing(): pass
Let’s cut and paste the drawing functionality from the
unit_actions.gd
script into thedraw_manager.gd
script. These will primarily be the functions_highlight_path()
and_highlight_target_point()
, which are callbacks for thegrid_tile_hovered
signal emitted by theDungeon
. That means we’re going to need a reference to the current dungeon in theDrawManager
class, so let’s add one more public method to the script.func set_active_dungeon(dungeon): _active_dungeon = dungeon
I’ll walk you through what I did for the
enable_target_drawing()
method and let you figure out theenable_path_drawing()
on your own using the same principles I do here.The things the drawing function needs in order to properly draw are:
- The position of the origin point
- The target group (null if targeting an empty space is allowed)
- Whether line of sight is required
If the target group is
null
then we just need to connect to thegrid_tile_hovered
signal on the_active_dungeon
. Otherwise we’ll get all the nodes in the target group and connect to theirentered
signal, assuming the group is composed ofMouseListener
derived objects.func enable_target_drawing(origin_position: Vector2, target_group = null, needs_LoS = true): if not target_group: _active_dungeon.connect("grid_tile_hovered", self, "_draw_target_to_point", [origin_position, needs_LoS]) else: var group = get_tree().get_nodes_in_group(target_group) for node in group: node.connect("entered", self, "_draw_target_to_group_member", [origin_position, needs_LoS])
For the disable function we basically just need to do the same thing, but use
disconnect()
instead ofconnect()
.func disable_target_drawing(target_group = null): if _active_dungeon.is_connected("grid_tile_hovered", self, "_draw_target_to_point"): _active_dungeon.disconnect("grid_tile_hovered", self, "_draw_target_to_point") if target_group: var group = get_tree().get_nodes_in_group(target_group) for node in group: if node.is_connected("entered", self, "_draw_target_to_group_member"): node.disconnect("entered", self, "_draw_target_to_group_member") _active_dungeon.clear_drawings()
(the functions I’m connecting to are derived from the functions we brought over from the
UnitActions
class)Now see if you can implement the other two public functions on your own! (If you get stuck or want to “check your work” you can compare it to my code, which I link to at the end of this post. Not saying that my code is the best per-se, but it works!)
SelectionManager
The
SelectionManager
class will only expose one function for now, which will be the_wait_until_unit_selected()
function fromUnitActions
. Since there are many things we’ll want to select within the game, though, I’m going to make it more generic. I also want to be able to control which things should be selectable, so I’m going to add an additional parametertarget_group
like I did in theDrawManager
.# selection_manager.gd signal group_member_selected # target_group can be either a string, or an array # of nodes. If it's a string, the group is the result # of calling get_nodes_in_group() with the string. If # the target_group is null, it defaults to "interactable". # If the target_group is empty, null will be returned. func wait_until_group_member_selected(target_group, highlight_color = null, fade = false, fade_frequency = 0): var group = _get_group(target_group) if group.empty(): return null for node in group: node.connect("clicked", self, "_select_group_member", [node, target_group]) node.connect("entered", self, "_toggle_highlightable", [node, true, highlight_color, fade, fade_frequency]) node.connect("exited", self, "_toggle_highlightable", [node, false]) var member = yield(self, "group_member_selected") return member func _toggle_highlightable(event, highlightable, highlighted, color = null, fade = false, fade_frequency = 0): highlightable.toggle_highlight(highlighted, color, fade, fade_frequency) func _select_group_member(event, member, target_group): # This function just disconnects the signals like we did in the draw manager _disconnect_group_signals(target_group) emit_signal("group_member_selected", member)
I also moved the
toggle_highlight()
function from theUnit
class to a superclass I calledHighlightable
, which will allow any node that uses or extends that class to show the same outline effect as the units.
Hero Action Phase
Phew! Now that we’ve got all of that behind us, we can finally start work on the
HeroActionPhase
! (Sorry if my explanations were a little patchy, there was a lot to cover and if I pasted all of the code I wrote along with explanations, it would make this post way too long.)Just like how in my previous post I created a flowchart diagram for the flow of the game, I thought it’d be a good idea to do the same thing for the
HeroActionPhase
. I find that there’s something very helpful about being able to visualize a workflow. Building it helps me organize my thoughts and think about how to handle certain states. Here’s what I came up with for theHeroActionPhase
.Dissecting this a bit, this diagram is a high-level outline of what a hero’s turn will look like. We will start out by checking whether the hero is knocked down. If so, there’s only one action they can perform, which is to stand up. Otherwise we will have a list of actions for the hero to choose from (displayed in the orange boxes).
Almost all of the actions have a prerequisite that must be met to determine whether they can be performed, which I’ve outlined on the left in the A-E boxes. The actual checks will likely turn out to be much more nuanced than I’ve written here in the diagram, but they’ll serve as a good starting point for us to build on as we find what those nuanced things are.
The small text in the top left grouped by the curly braces is just a copy-paste of the rules describing the actions a hero can take on their turn, found on page 7 of the Descent Rulebook.
Setting up the phase flow
Let’s go to the
hero_action_phase.gd
script now. There will be three main variables we need to keep track of during this phase:- The current hero
- The number of remaining action points
- Any leftover movement
We can initialize these in the constructor, with the hero being passed in as an argument.
# hero_action_phase.gd var hero: Unit var action_points: int var leftover_movement: int func _init(sm: StateMachine, unit: Unit).(sm, "HeroActionPhase"): hero = unit action_points = 2 leftover_movement = 0
When we enter this state we’ll want to present the user with the list of actions they can take, so that means we’ll need to use those checks we created in the
UnitActions
singleton. We can worry about how the user will select the actions in a little bit, so let’s start with what we know.# This enum is for convenience so we can keep track of which # actions we can/do use enum Actions { move, rest, skill, attack, interact, revive, stand, move_extra, end_turn } func enter_state(): .enter_state() _select_action() func _select_action(): # This function will call _get_available_actions() to # tell the player which actions they can pick, and # wait for them to pick one. pass func _action_selected(action): # This function will be called when one of the actions are # selected and will call the UnitActions.do... function # matching the selected action. pass func _get_available_actions(): # This function will call all of the UnitActions.can_do... # functions and store a list of the actions that are available. pass
Get available actions
Let’s work on the
_get_available_actions()
method first. As I said in the comment, this method will be called by the_select_action()
function to get a list of whichActions
can be taken. The functionality for these checks has been put in theUnitActions
singleton so all we need to worry about is calling them from this script, which keeps it simple and clean!func _get_available_actions(): # The end turn action will always be available # if for some reason the player wants to end their # turn early. var available = [Actions.end_turn] # First we'll check whether the hero needs to # stand up, like outlined in the flowchart. if UnitActions.can_do_stand_up_action(hero): available = [Actions.stand] # If they can't perform the stand up action, then we # need to check the remaining action points. elif action_points <= 0: # Check for any leftover movement if leftover_movement > 0 and UnitActions.can_do_move_action(hero): # I decided to make the leftover movement # its own action so that we can easily # differentiate it from the regular move # action when we're performing the action. available.append(Actions.move_extra) # If the hero still has remaining action points then # we will check all of the other action availability. else: if UnitActions.can_do_attack_action(hero): available.append(Actions.attack) if UnitActions.can_do_move_action(hero): available.append(Actions.move) if UnitActions.can_do_rest_action(hero): available.append(Actions.rest) if UnitActions.can_do_interact_action(hero): available.append(Actions.interact) if UnitActions.can_do_skill_action(hero): available.append(Actions.skill) if UnitActions.can_do_revive_action(hero): available.append(Actions.revive) return available
Action selected
Now let’s take a look at the
_action_selected()
function. This function will call on theUnitActions
singleton to perform the action, wait for it to finish, and then call_select_action()
again to start the cycle over until the player chooses theActions.end_turn
action.func _action_selected(action): # We'll want to keep track of how much ability points # are spent during the hero's action, since some actions # (like standing up) will take all actions and if the player # cancels an action we don't want to count it as an action taken. var ap_used := 1 match action: Actions.end_turn: # If it's the end turn action we can just update # the state and return early _change_state(HeroEndPhase.new(_parent)) return Actions.stand: yield(UnitActions.do_stand_up_action(hero), "completed") ap_used = 2 Actions.move: # The do_move_action() function will return -1 if the # action was cancelled var cost = yield(UnitActions.do_move_action(hero, true), "completed") if cost == -1: ap_used = 0 else: leftover_movement = hero.unit_data.speed - cost Actions.move_extra: var cost = yield(UnitActions.do_move_action(hero, false, leftover_movement), "completed") if cost == -1: ap_used = 0 else: leftover_movement -= cost Actions.rest: # Performing a rest action will always take the # remaining action points yield(UnitActions.do_rest_action(hero), "completed") ap_used = 2 Actions.skill: # Eventually we'll come back to this, where null # will be replaced by the chosen skill yield(UnitActions.do_skill_action(hero, null), "completed") Actions.attack: # "monsters" is the target group we want to affect yield(UnitActions.do_attack_action(hero, "monsters"), "completed") Actions.interact: yield(UnitActions.do_interact_action(hero), "completed") Actions.revive: yield(UnitActions.do_revive_action(hero), "completed") action_points -= ap_used _select_action()
Select action
Finally let’s figure out how we want the player to select their action. For now I think a simple button-based UI will suffice, but because I always prefer working with things that look good over things that look mangy I wanted to do a mock-up first.
I like the idea of having the hero avatars in the top corner, with the current one being highlighted in some way. Fortunately, making a simple layout is pretty easy in Godot. After messing around with a few options, here’s what I ended up settling on.
Hero turn GUI
I attached a script to the base node and named it
hero_turn_gui.gd
. In the script I get a reference to all of the buttons in the scene and create a map from theHeroActionPhase.Actions
enum to their corresponding button. I also define abutton_pressed
signal in the script that we’ll be able to connect to in theHeroActionPhase
, and a function to enable/disable buttons based on the result of_get_available_actions()
. Here’s what all that looks like in the script.# hero_turn_gui.gd extends CanvasLayer class_name HeroTurnGUI signal button_pressed onready var _btn_move = $Backdrop/ButtonGrid/Move onready var _btn_move_extra = $Backdrop/ButtonGrid/MoveExtra onready var _btn_attack = $Backdrop/ButtonGrid/Attack onready var _btn_skill = $Backdrop/ButtonGrid/Skill onready var _btn_interact = $Backdrop/ButtonGrid/Interact onready var _btn_rest = $Backdrop/ButtonGrid/Rest onready var _btn_revive = $Backdrop/ButtonGrid/Revive onready var _btn_stand = $Backdrop/ButtonGrid/StandUp onready var _btn_end = $Backdrop/ButtonGrid/EndTurn onready var _hero_name = $HeroName var _btn_map: Dictionary func _ready(): # The purpose of this line will be # explained in the next section set_meta("gui_type", "hero") _btn_map = { HeroActionPhase.Actions.move: _btn_move, HeroActionPhase.Actions.move_extra: _btn_move_extra, HeroActionPhase.Actions.attack: _btn_attack, HeroActionPhase.Actions.skill: _btn_skill, HeroActionPhase.Actions.interact: _btn_interact, HeroActionPhase.Actions.rest: _btn_rest, HeroActionPhase.Actions.revive: _btn_revive, HeroActionPhase.Actions.stand: _btn_stand, HeroActionPhase.Actions.end_turn: _btn_end, } # This is just a simple way to forward on the button # pressed signal to listeners of this script for key in _btn_map.keys(): _btn_map[key].connect("pressed", self, "emit_signal", ["button_pressed", key]) func enable_buttons(action_list: Array): for action in _btn_map.keys(): var btn = _btn_map[action] if action_list.has(action): btn.disabled = false else: btn.disabled = true
GUI Manager
Now in order to access this script we’ll need a reference to it somehow. Let’s create another auto-loaded script and call it
gui_manager.gd
. This script will only have one function (for now) that will return theHeroTurnGUI
if it’s found in the scene.# gui_manager.gd extends Node # Gets the Hero GUI in the current scene. If not # present, this returns null. func get_hero_gui(): var guis = _get_gui_type("hero") if guis.empty(): return null return guis[0] func _get_gui_type(type: String) -> Array: var guis = [] # I put the HeroTurnGUI scene in a group called "gui" var nodes = get_tree().get_nodes_in_group("gui") for node in nodes: # Because I specified the gui_type metadata in # the HeroTurnGUI, this will be able to get the # exact script we're looking for. if node.get_meta("gui_type") == type: guis.append(node) return guis
Select action (…again)
Home stretch guys! Now that everything else is in place we can fill out the
_select_action()
function back inhero_action_phase.gd
. We just need to get a reference to theHeroTurnGUI
in the scene, enable the buttons that should be enabled, and listen for thebutton_pressed
signal.func _select_action(): var hero_gui = GUIManager.get_hero_gui() hero_gui.enable_buttons(_get_available_actions()) hero_gui.connect("button_pressed", self, "action_selected", [], CONNECT_ONESHOT)
Bringing It All Together
And finally here we are at the end of this long post! I’ll note that even though I didn’t mention it during this post, I did a lot of trial and error testing while writing this code to make sure it all worked as expected, and I highly recommend you do the same as you (if you) follow along with me. That way you don’t have to go back and track down why a certain aspect isn’t working as expected after you’ve finished everything and instead know exactly what changed since your last test, which helps immensely in debugging.
So here’s a little demo of how the code looks so far! (I added a little bit of animation of the GUI so it felt a little more alive)
The code for this post can be found here.
-
Project Delve Part 6: Turn System
Alright, now we’re getting into the real juicy stuff — making systems for our game to use! If you’re unfamiliar with the term systems in this context, a short explanation is this: Systems are pieces of code that are used for higher-level functionality in the program. They’re responsible for tying various related parts of the program together. Think of an office building: these generally have electricity, air conditioning, heating, plumbing, security, etc. Each of these things can be considered a system within the overall building, each affecting parts of the building in their own way.
The system we’ll be focusing on in all of the Part 6 posts is the Turn System (yes, this is going to be a multi-post topic). How will the game know whose turn it is, what they can do, what happens after that, and what triggers it? Let’s dive in.
Game Flow
The first thing to establish when figuring out a game’s flow are its states, and what moves the game through the states (transitions). This is known as the State Machine pattern. The State Machine pattern is a very popular game programming pattern because games are full of states and transitions. Actions the user can and can’t take depend on the state of the game, and states change when a transition happens.
As an example, take a generic FPS game. For the sake of the example I’ll keep it simple. The player may have two states:
- Player has ammo
- Player is out of ammo
While the player is in the first state (has ammunition), they can fire their weapon. If they are in the second state (out of ammunition), they cannot fire their weapon. The transition between S1 and S2 would be triggered by the amount of ammo a player has left: if the player’s ammunition is equal to zero, transition to S2. The transition between S2 and S1 would be triggered by an action: if the reload action is taken while in S2, transition to S1.
Diagraming the states and transitions
Using an online flowcharting tool, I created the following diagram based on the Descent rulebook.
The game always starts with the heroes. After the heroes all take their turn, it’s the overlord’s turn. The overlord starts their turn by “drawing a card” (something that I may or may not keep in this game, we’ll see), and then activates each monster group. When a monster group is activated, each monster in that group takes its turn. After all the monster groups have activated, the overlord’s turn is over and it’s back to the heroes turn.
One thing that I didn’t diagram correctly is that the check for whether any of the objectives have been completed only happens after the overlord’s turn, when in reality it should be happening at the end of each individual hero turn and each individual monster turn, but that’s the only caveat.
Coding The State Machine
I’ll start by saying that while I have been enjoying using Godot, GDScript is not a contributor to that enjoyment. It’s true that Godot technically supports C# scripting (which I much prefer) but I tried it briefly and it turned out to be more trouble than it was worth to use. I like that GDScript is optimized directly for the game engine, but overall it hasn’t been my favorite language to work with.
Rant aside though, one of the reasons I dislike GDScript is due to its poor object-oriented structure. It’s very much a scripting type language (as the name would suggest) and as such it doesn’t make it easy to create and use regular generic objects or create a standalone system architecture. So I struggled for a while to figure out how to make a simple state machine that fit with the GDScript paradigm instead of fighting against it, and I think I found a workable solution.
Let’s start by creating the
StateMachine
class.# state_machine.gd extends Node class_name StateMachine # Passes with parameters: new state name signal changed_state # The current state var current func change_state(new_state): current = new_state emit_signal("changed_state", new_state.name)
This works, but doesn’t do much at the moment. It only holds a reference to a variable that could be anything, and the state itself doesn’t know whether or not it’s active. This is important to know because we likely will want to alter the game in some way when the state gets changed. We could of course have the states connect to the
changed_state
signal and check if the new current state is itself, but that would get very messy very quickly. Instead, let’s create aState
class that has anenter_state()
andexit_state()
method. Then whenchange_state()
is called on theStateMachine
, we can just callexit_state()
on the current state andenter_state()
on the new state.# state.gd extends Node class_name State signal state_entered signal state_exited func enter_state(): emit_signal("state_entered") func exit_state(): emit_signal("state_exited") # After exiting the state we can remove it from the scene tree. # Otherwise we'll have memory leaks as unused objects stack up. queue_free()
# state_machine.gd func change_state(new_state): if current != null: current.exit_state() current = new_state if current != null: current.enter_state() emit_signal("changed_state", current.name) else: emit_signal("changed_state", null)
And just like that we have a simple state machine with states that know when they’ve been entered and exited!
Expanding the functionality
Because some of our states need to keep track of their own sub-states, we need to give them their own state machines to use. It would also be convenient to have a reference to the
StateMachine
owning the state from within the state itself too. That way when a state knows it should update, it can call thechange_state()
function on its parent state machine to move to the next state. Let’s add these options to theState
class. (Have I said “state” enough?)var _parent: StateMachine var _state_machine: StateMachine func _init(owning_state_machine: StateMachine): _parent = owning_state_machine _state_machine = StateMachine.new() # We want to add the state machine as a child to this Node so that # when it's freed, the state machine will be freed as well. add_child(state_machine) func _change_state(next_state: State): _parent.change_state(next_state)
To test that this is working, let’s create a new scene and add a script onto the root
Node
that extendsStateMachine
, as well as two inner classes that extend theState
class (these don’t need to be attached to any nodes since they’ll be created and destroyed dynamically). Add a method to theStateMachine
-derived class that callschange_state()
with a new instance of one of the state classes we just created. From that state class, inside theenter_state()
method, we’re going to call_change_state()
with a new instance of the second state class. In each of theenter_state()
andexit_state()
methods, add aprint()
statement indicating we just entered or exited the state.In code it’ll all look something like this.
# my_state_machine.gd extends StateMachine func test_state_machine(): print("Starting state machine") change_state(State1.new(self)) class State1: extends State func enter_state(): # Putting a period before the function name will call the # super-class's method .enter_state() print("Entered state 1") # Passing a reference to the parent state machine to the next state _change_state(State2.new(_parent)) func exit_state(): .exit_state() print("Exited state 1") class State2: extends State func enter_state(): .enter_state() print("Entered state 2")
Now all that’s left to do is add a button to the scene and connect its
pressed
signal to thetest_state_machine()
method! Before running the code, let’s first think about what we expect the output to look like. First, theprint()
method in thetest_state_machine()
method will run. Then we would expect to see “Entered state 1” as theenter_state()
method is called on the state. We would then expect to see “Exited state 1” and then “Entered state 2.” Let’s test it out and see!
Implementing The Game Flow
Now that we can see that our state machine works as expected, let’s scaffold up the game flow based on the diagram at the start of the post. Due to the number of state classes I’m not going to post the contents of every script, but I will point out a few key things.
Purple state blocks
First of all, we have the purple state blocks. These states will be the ones that make use of their own state machines to create sub-states. These “super” states will want to monitor when their sub-states change so that when the
EndPhase
state is entered, we can make the appropriate check for whether we should initiate another round or move on to the next phase. I’ll demonstrate this with theHeroGroupPhase
state.# hero_group_phase.gd extends State class_name HeroGroupPhase # The syntax here of _init().(): is just calling the super method of _init # (like calling the super constructor) func _init(sm: StateMachine).(sm, "HeroGroupPhase"): _state_machine.connect("changed_state", self, "_on_sub_state_machine_change_state") func enter_state(): .enter_state() print("Hero group turn started!") # Make the parent state machine of the sub-state this class's # state machine _state_machine.change_state(HeroStartPhase.new(_state_machine)) func _on_sub_state_machine_change_state(new_state_name: String): if new_state_name == "HeroEndPhase": print("Have all heroes been activated?") # !! Check if any heroes still need to take their turn !! _change_state(OverlordPhase.new(_parent))
When the
HeroGroupPhase
state is entered, it starts the sub-state machine of the hero turn. When the state machine changes states, theHeroGroupPhase
state checks the new state. If it’s theHeroEndPhase
state we make our check and then proceed according to the flowchart. (In practice these sub-states will receive which hero’s turn they’re acting on, among other things, but since I don’t have that coded out just yet I’m leaving it be for now.)
Orange state blocks
The orange state blocks represent states that will immediately change themselves to the next state. They all look almost identical to this.
extends State func _init(sm: StateMachine).(sm, "OrangeStateType"): pass func enter_state(): .enter_state() _change_state(NextState.new(_parent))
“But why make a state that is only transitory and has no self-contained functionality?” I hear you ask. Well the reason for these “empty” states is so that any actions that trigger “at the beginning of the hero’s turn” or “at the end of the hero’s turn” can listen for these states to be entered. We may not end up needing them because I may find a better way to do the same thing, but it works for now!
Green state blocks
Green state blocks are where the actual gameplay will happen. These states will not auto-advance to the next state until certain conditions are met. Because they are much more complex, I’ll be explaining these in more depth in the following post.
The Result
After implementing each of the states in my diagram, this is how the flow of the state machine looks now.
So that’s all for today. In the next post, like I mentioned in the above section, we’ll be diving deeper into what will happen in the “functional” states. Primarily we’ll be focused on the
HeroActionPhase
.The code for this lesson can be found here.
-
Project Delve Part 5: Line of Sight
In Descent and many other games, units often must have what’s known as “line of sight” to their targets when performing various actions such as attacking, buffing an ally, teleporting, and more. Sometimes games differentiate between “line of sight” and “line of targeting,” the difference being between what a unit can see versus what a unit can target. However, for our game these are going to be the same for simplicity’s sake.
At the end of this post we’ll have a functional line-of-sight calculator, as well as a visual representation of what a unit can see and what it cannot.
The Script
We’ll start by adding a new
Node2D
to the Dungeon scene called “LineOfSight” and create a new script to attach to it. I named itline_of_sight.gd
(super original, right?). Our script is going to have only a single public method:can_see()
. This follows the single-responsibility principle, which states that functions, modules, and classes (or in our case scripts) should be responsible for only a single part of the larger program. I’m not always the best at following all of the principles I learned in school in every piece of code I write, but the goal is to always be striving to improve.The basic idea of the function is this: take in two points and try to draw an (invisible) uninterrupted line between both of them (also known as a raycast). If the line is not blocked by anything we define to be a vision-blocking item, the target point is considered to be in the line of sight of the origin. Let’s start by defining the function signature.
func can_see(world_point_origin: Vector2, world_point_target: Vector2): pass
The simplest way to do the check is to use Godot’s raycaster.
get_world_2d().direct_space_state.intersect_ray(pointA, pointB)
The
intersect_ray()
function returns a dictionary object containing the information of the first intersection the ray makes frompointA
topointB
. If there’s no obstruction, the dictionary will be empty. This is the state we’ll want to check for to make sure there’s line of sight frompointA
topointB
.Our simple function should look like this now.
func can_see(world_point_origin: Vector2, world_point_target: Vector2): var result = get_world_2d().direct_space_state.intersect_ray(world_point_origin, world_point_target) return result.empty()
The only caveat with using a raycaster is that it interacts with the physics system, which our game does not use. In order for the ray to collide with something, that “something” needs to have a collider attached, so we’ll need to set up colliders on our tileset. Those instructions can be found on the Godot docs.
Once you’ve set that up, select the Obstacles node in the Dungeon scene and enable the “Show collision” property in the inspector. You should see something like this if you’ve set it all up properly.
Now if we write some quick code in the
dungeon.gd
script we can check that ourcan_see()
function is working like it should. If the two points are within line of sight of each other, the line color will be green. If not, it will be red.
Refining The Function
So now that we have a basic function to calculate line of sight, let’s look at the rules for how Descent determines line of sight.
In order for a figure to have line of sight to a space, a player must be able to trace an uninterrupted, straight line from any corner of that figure’s space to any corner of the target space.
Rulebook, pg. 12, “Line of Sight”This approach to line of sight is known as the corner-to-corner method. The other most commonly used method (when dealing with a grid system) is the center-to-center approach. I like the corner-to-corner method because it allows for a much larger field of vision than center-to-center.
With this in mind, let’s update the function. We now want to do the same raycast from each of the origin point’s corners to each of the target point’s corners. If any of the raycasts are successful (meaning they didn’t collide with anything) then we can exit the function early without needing to do any more checks.
First of all, we need to ensure that the world point we pass to the
can_see()
function is the top-left corner of the tile that was clicked on. This is fairly simple since the origin point of our tiles are set to the top left, so we just need to get that point and then addVector2.RIGHT * tile_size
,Vector2.DOWN * tile_size
, andVector2.ONE * tile_size
to it and we’ll have the four corners of the tile.Let’s start by getting the position of the top left corner of the tile in world coordinates. Because the LineOfSight node is on the Dungeon, I think it makes sense for the
dungeon.gd
script to have a method that exposes thecan_see()
function, just so we don’t have to rely on the structure of the Dungeon scene tree to get theline_of_sight.gd
script.# dungeon.gd func has_line_of_sight_to(from_world_point: Vector2, to_world_point: Vector2): # First we need the map coordinates for the given world coordinates. var origin_map_point = floors.world_to_map(from_world_point) var target_map_point = floors.world_to_map(to_world_point) # Then we need to convert those map coordinates back to # world coordinates. This will give us the top-left corner # of the tiles in world coordinates. var origin_world_point = floors.map_to_world(origin_map_point) var target_world_point = floors.map_to_world(target_map_point) # Then we just return the result of the can_see() function return $LineOfSight.can_see(origin_world_point, target_world_point)
Now let’s fix the
can_see()
function to check each corner of the origin point and target point.# line_of_sight.gd func can_see(world_point_origin: Vector2, world_point_target: Vector2): for from_world_point in _get_tile_corners(world_point_origin): for to_world_point in _get_tile_corners(world_point_target): if from_world_point == to_world_point or _has_LoS(from_world_point, to_world_point): return true return false func _has_LoS(from_world_point: Vector2, to_world_point: Vector2): var result = get_world_2d().direct_space_state.intersect_ray(from_world_point, to_world_point) return result.empty() func _get_tile_corners(point): return PoolVector2Array( [ point, # top left point + Vector2.RIGHT * _tile_size, # top right point + Vector2.DOWN * _tile_size, # bottom left point + Vector2.ONE * _tile_size # bottom right ] )
The last variable we need is the
tile_size
, which the tilemap can give us in thedungeon.gd
script. So when I said we’d only be surfacing a single function in theline_of_sight.gd
script… I lied. Oops! But because this function will supplement thecan_see()
function I think it still follows the single-responsibility principle, so we’re in the clear.# line_of_sight.gd var _tile_size: Vector2 # Make sure to call this function from the dungeon.gd # script at some point before calling can_see() func set_tile_size(size: Vector2): _tile_size = size
Here’s a visual representation of how the algorithm behaves now.
What’s Left?
So it seems like we’re pretty much done, right? Well, almost. Our function currently only considers the tilemap obstacles for blocking line of sight (because that’s the only thing we’ve set up colliders for so far). But in the game, there are more things that can block line of sight than a static obstacle — units, for instance. Units of different types (meaning hero or monster) block line of sight for the opposite type, so we still need to account for that in our calculations.
As I said at the start of the post, I’d also like to have a visual indicator of what a unit can or cannot see. I originally thought it’d be cool to have all the tiles that are within line of sight of a unit highlighted, but running this function for every possible combination of tiles on the grid could get expensive, especially when you consider that the grid will most likely be much larger than our little demo dungeon here. I think instead I’ll settle for calculating line of sight only when needed, and only on hovered tiles (kind of like what’s happening when displaying the movement of a unit).
This is a general idea of how targeting could work. Ultimately I’ll want to update the artwork, but this will suffice for now And of course lastly there’s bound to be bugs or inconsistencies that will come to light with further use. I actually discovered one bug while recording the above gif! It seems like when the line of sight would pass on the diagonal edge of an obstacle tile (but not through it) it counts it as being blocked. This is a case that’s specifically called out in the rulebook as being allowed, and I think it should be as well, so I’ll need to figure out the best way to handle that use case.
The code for this post can be found here.
-
Project Delve Part 4.3: Various Enhancements
So for this post, unlike the posts I’ve made before, I’m not going to be walking you through step-by-step how I created one thing or another (though I will explain the general concept). This is because I’d like to move on to Part 5 in the series posts but there were some things I felt needed to be addressed first, both incomplete things and quality of life things. There were quite a few changes I made that would take a lot of time explaining each one in detail when they felt more or less unrelated to the overall structure of the project. So with that said, let’s jump in!
Pathfinding Misunderstanding
One thing I do want to explain fully, though, is a “bug” I thought I had found while testing out the pathfinding script. (See the issue I posted on the Godot Github repo here.) In short, I thought that the
AStar.get_x_path()
functions were incorrectly calculating the overall weight of the path, and would not always return the path with the shortest weight.Because Godot is open-source, I was able to look through the engine’s source code and found the implementation of their pathfinding algorithm. I realized I had incorrectly assumed the algorithm was based on a grid-like structure, but when I looked at the source code I saw that the distance between points was calculated based on Euclidean distance, and not by grid-tile neighbors.
This is important because, if you remember from your geometry class, the Pythagorean Theorem states that the third side of a right triangle is equal to the square root of the sum of the square of the other two sides. Or, in other words, a2 + b2 = c2.
So while the distance between two neighboring orthogonal points on our grid is
1
, the distance between to neighboring diagonal points on the grid is1.41
. Because of this false assumption I was working under that the distance between any neighboring points would be1 * <weight of the point>
, there was a problem when the diagonal point had a weight greater than1
(e.g. a water tile).Of course this makes sense when you think about all the various applications of
AStar
that needed to be accounted for, but I wasn’t sure how to solve the issue. Fortunately a helpful person answered my issue with the solution: extend theAStar
class and override the_compute_cost()
function. After doing that the pathfinding worked as expected!# pathfinder.gd onready var _a_star = GridAStar.new() class GridAStar: extends AStar2D func _compute_cost(from_id, to_id): var pointA = get_point_position(from_id) var pointB = get_point_position(to_id) return floor(pointA.distance_to(pointB))
Caching Paths
Also having to do with the pathfinder, I made an optimization on the
CharacterController
class that caches all calculated paths, based on the destination point, until the character moves. This is going to be helpful because we won’t need to ask thePathfinder
to repeatedly calculate the path to the same spot from the same spot if we’ve already done it before. Additionally I’m also caching the cost associated with each calculated path for the same reason. I made a simpleDictionary
object in theCharacterController
for these caches, and a simple_key()
function for the cache key.func _key(loc: Vector2, pathfinder: Pathfinder) -> String: return str(pathfinder.convert_to_map_point(loc))
Stamina
I factored in stamina to a unit’s movement. A unit is able to move up to its speed without using stamina, and then one stamina for each additional movement point. A unit only has a finite amount of stamina which they can only recover through resting or other special actions.
You may also notice the yellow/orange bars underneath the unit’s health bar. I thought it would be a good way to represent the remaining stamina points a unit has. The fun part about making the stamina bar was that I did it through a shader script! I do plan on devoting a full post to the usage of shaders sometime in the future, but the short explanation of what a shader is, for those who may be unfamiliar (like I was before doing this!), is that a shader is able to manipulate pixels in a texture in various ways.
There’s a really helpful and interesting video on the topic of shaders in Godot on YouTube that I think covers the topic very well for beginners like me. You can find that video here.
Path Highlighting
Something that I find very helpful in tactical games is the indication of a route a unit will take when they move. I wanted something like that in this game, so I made it! Currently it’s very rudimentary and unpolished, but it gets the job done.
Godot makes it very easy to draw on the screen through code. (See the docs for more info.) All I needed to do was calculate the path to the final destination through the
Pathfinder
and then draw a line segment from point to point until reaching the end. Simple! The tricky part was displaying an arrow to cap the line when it reached the destination. I made a simple arrow head image using the online pixel art tool Pixil, then imported it into the project. I made it white so that I could change its color to be whatever I wanted in the code, and I also made the image size much bigger than 16×16 so that I could scale it down and not have it be so hard-edged on the diagonal parts.I added a new node onto the
Dungeon
scene calledPathDrawer
and attached a new script to it by the same name, then surfaced two public functions:draw_path()
anderase_path()
, one that sets the internal_path
array and one that clears it. Then I overrode the_draw()
function to draw a line from point to point like I explained above, but to draw the arrow I needed to do some transformations to the image before drawing it.Because the default origin point of an image is the top left corner, I needed to offset the image by the full height of the texture and half of the width (since I drew the arrow from the bottom middle pointing up). I also needed to rotate it based on the direction of the final two line points and scale it by the appropriate factor so that it would be sized correctly relative to the line width and tiles. Ultimately that code came out looking like this:
var scale = 8.0 var scale_size = Vector2(1.0 / scale, 1.0 / scale) var arrow_size = arrow_point.get_size() var final_loc = _path[-1] var offset = last_dir * arrow_size offset += last_dir.rotated(-PI / 2.0) * arrow_size / 2.0 offset -= last_dir * arrow_size / 2.0 draw_set_transform(final_loc + offset * scale_size, last_dir.angle() + PI / 2.0, scale_size) draw_texture(arrow_point, Vector2.ZERO, path_color)
And this was the end result. Much more clarity to movement!
Camera
Another thing that has been bugging me for a while is how zoomed out the scene seemed when I would play it. (You can’t tell from the gifs I post because I crop them, but the grid size was very small compared to the window size.)
I definitely don’t want that to be the size of the screen when this is all playable! So I created a
Camera2D
node as a child to theDungeon
scene. Unfortunately the out-of-the-box camera node for Godot doesn’t come with a lot of helper functions that the camera in Unity comes with, so I had to implement the one I needed myself.When using a camera, you have to do a few calculations to get the point in world-space relative to the point on your screen (screen-space). Unless the camera is positioned at the origin of the world, clicking somewhere on your screen won’t translate to its point in the world.
After some trial and error and reading up on transformation matrices, I realized all I needed was a simple calculation to convert the point in screen-space to the correct point in world-space. Fortunately we’re working with a 2D orthogonal camera, which makes the transformation much simpler. I wrote a script that extends
Camera2D
and added the calculation function to it. In the end, this was the whole script.extends Camera2D class_name DungeonCamera func screen_to_world_point(point: Vector2) -> Vector2: return point * zoom + position
Now whenever I need to get the world-coordinates of a point on the screen I can just run that point through this function and be on my merry way!
Unit Selection Indication
The final thing I implemented was a way to tell if you were hovering over a unit, or if they were being targeted in some way. For this I felt like showing an outline around the character sprite would be a good solution, and figured the best way to do this would be to use another shader. However, I wasn’t sure where to start in creating a shader to do that so I did a quick search on the ol’ Googs and found this shader that did exactly what I needed! So giving credit where credit is due, thank you Juulpower for your contribution to a good cause.
After applying this shader to unit sprites I made a few modifications to it so that I could enable and disable it at will, as well as make it flash for effect. It added the clarity I felt the game needed when indicating selection!
Results
Putting all of these things together, I feel like the state of our game is much more clear on what is happening! It’s still not perfect of course, but it’s progress in the right direction!
Here’s a quick render of the state of the game as it currently stands.
Because I didn’t give a detailed rundown of all the individual changes I made, I recommend taking a look at the code up to this point. That can be found here. I’ll be back soon with the next part of the project: determining line of sight!