Yet Another Roguelike Tutorial, Part 9
Publish date: Sep 3, 2023Tags: godot4 tutorial roguelike
Part 9: Ranged Scrolls and Targeting
Welcome back to the roguelike tutorial series. This tutorial will continue from where the last one left off. You can find the previous tutorial here: https://selinadev.github.io/12-rogueliketutorial-08/
Last time we started handling items, and filled our dungeon with healing potions. This time we will expand our arsenal with some more offensive consumables, by implementing some magic scrolls. We will create three types of scrolls. The first one will be a lightning strike hitting our closest enemy. This will be the easiest to implement, as the player won’t need to select a target. Next we create a scroll of confusion, causing a single enemy to stumble around aimlessly for a few turns. This will teach us how to have the player select a single target, and also how to temporarily replace an entity’s AI. And the last scroll will be a fire ball, hitting everything in an area (including the player). For that we will extend our targeting by a system to show how big the area is, and then we will hit multiple entities at once.
Let’s start with a very simple prerequisite, as we want a color for messages regarding status effects (we will only have confusion as status effect in this tutorial). So open colors.gd and add one color for status effects:
const PLAYER_ATTACK = Color("e0e0e0")
const ENEMY_ATTACK = Color("ffc0c0")
const STATUS_EFFECT_APPLIED = Color("3fff3f")
Both the lightning scroll and the fireball scroll will need to know how far an entity is from a given position, so let’s create a function for that in entity.gd:
func distance(other_position: Vector2i) -> int:
var relative: Vector2i = other_position - grid_position
return maxi(abs(relative.x), abs(relative.y))
Here we first calculate the relative vector. Then we return the bigger of the two vector components. This will give us the Chebyshev distance, just like we used it for movement (meaning the lightning scroll has a square targeting range, rather than a circular one). We will use this in the lightning damage consumable we’ll shortly write. But, as you probably expect by now, first we need a definition resource for that. So let’s create a new script extending ConsumableComponentDefinition
at res://src/Entities/Actors/Components/ComponentDefinitions/lightning_damage_consumable_component_definition.gd. The definition consists of the following two variables:
class_name LightningDamageConsumableComponentDefinition
extends ConsumableComponentDefinition
@export var damage: int = 0
@export var maximum_range: int = 0
We have a damage the lightning effect can deal, and a maximum damage. Now let’s get to the consumable itself. Create a new script extending ConsumableComponent
at res://src/Entities/Actors/Components/lightning_damage_consumable_component.gd. Here’s the top of the script:
class_name LightningDamageConsumableComponent
extends ConsumableComponent
var damage: int = 0
var maximum_range: int = 0
func _init(definition: LightningDamageConsumableComponentDefinition) -> void:
damage = definition.damage
maximum_range = definition.maximum_range
We simply have the same variables again, and set them via the definition that we expect to be passed as an argument during creation of this component. The magic happens in the activate()
function:
func activate(action: ItemAction) -> bool:
var consumer: Entity = action.entity
var target: Entity = null
var closest_distance: float = maximum_range + 1
var map_data: MapData = consumer.map_data
for actor in map_data.get_actors():
if actor != consumer and map_data.get_tile(actor.grid_position).is_in_view:
var distance: float = consumer.distance(actor.grid_position)
if distance < closest_distance:
target = actor
closest_distance = distance
if target:
MessageLog.send_message("A lightning bolt strikes %s with a loud thunder, for %d damage!" % [target.get_entity_name(), damage], Color.WHITE)
target.fighter_component.take_damage(damage)
consume(consumer)
return true
MessageLog.send_message("No enemy is close enough to strike.", GameColors.IMPOSSIBLE)
return false
First, we define a few variables we’ll need. We create one for the consumer, i.e., the entity performing the action (which should always be the player). We also get the map data, which we will need repeatedly. We initialize a target
variable and a closest_distance
. Starting out, we don’t have a target, and the closes distance is one step away from the furthest point we can reach. Next, we loop over all actors. The first if
filters out the consumer (so you don’t strike yourself), as well as entities outside the field of view (so you don’t zap an orc through a wall). After that we get and check the distance between that entity and the consumer. If it is less then the smallest distance we have seen so far, we remember that distance and remember the actor as our new target. If we have a target after the loop we know it’s the closest one, so we zap it. We create a message detailing the effect, then deal some damage to the target. Then we consume the item and return true. However, if we don’t have a target we notify the player about that and return false.
With the new component complete we need to make sure it’s attached during creation of its entity. We will have quite a few consumables, so in order to handle them we create a new function in entity.gd:
func _handle_consumable(consumable_definition: ConsumableComponentDefinition) -> void:
if consumable_definition is HealingConsumableComponentDefinition:
consumable_component = HealingConsumableComponent.new(consumable_definition)
elif consumable_definition is LightningDamageConsumableComponentDefinition:
consumable_component = LightningDamageConsumableComponent.new(consumable_definition)
if consumable_component:
add_child(consumable_component)
This function checks which type of consumable should be instantiated and attaches it to the entity. We will expand this function two more times over the course of this tutorial. You might recognize the part with the HealingConsumable
, as we had that in set_entity_type()
. We need to modify that function to call _handle_consumable()
:
if entity_definition.fighter_definition:
fighter_component = FighterComponent.new(entity_definition.fighter_definition)
add_child(fighter_component)
if entity_definition.consumable_definition:
_handle_consumable(entity_definition.consumable_definition)
if entity_definition.inventory_capacity > 0:
inventory_component = InventoryComponent.new(entity_definition.inventory_capacity)
add_child(inventory_component)
Now the lightning component will correctly be attached to an item, but we still need an item that uses it. Create a new EntityDefinition
resource at res://assets/definitions/entities/items/lightning_scroll_definition.tres. Set the Name to “Lightning Scroll”, and insert a nice scroll Texture. We use yellow ("#ffff00") as our Color. Uncheck Is Blocking and change the Type to Item. Then, create a new LightningDamageConsumableComponentDefinition
in the Consumable Definition. Set its Damage to 20 and the Maximum Range to 5.
The new scroll technically would work in the game now, we just need to spawn it. In dungeon_generator.gd, first append the entity_types
:
const entity_types = {
"orc": preload("res://assets/definitions/entities/actors/entity_definition_orc.tres"),
"troll": preload("res://assets/definitions/entities/actors/entity_definition_troll.tres"),
"health_potion": preload("res://assets/definitions/entities/items/health_potion_definition.tres"),
"lightning_scroll": preload("res://assets/definitions/entities/items/lightning_scroll_definition.tres"),
}
Then, in the items part of _place_entities()
, at the end change the part under if can_place
as follows:
if can_place:
var item_chance: float = _rng.randf()
var new_entity: Entity
if item_chance < 0.7:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.health_potion)
else:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.lightning_scroll)
dungeon.entities.append(new_entity)
This is similar to how we spawned both orcs and trolls. We now have a 70% chance to spawn a health potion, and the remaining 30% for a lightning scroll. If you run the game now, you might already find one, and if an enemy dares to go near you, you can use it to zap that enemy.
Now we move to the more interesting part of actually targeting an enemy. We need a UI element that shows the focussed tile. We’ll create a pulsating semi-transparent rectangle for that. This reticle will remain in the center of the screen, and we will have the camera follow it as long as it’s active. This behavior means that we will deviate from the python tutorial a bit, as that tutorial also allows selecting with the mouse, which does not make much sense however if the reticle remains in the center.
So, create a new scene with a Node2D
as root node. Rename that node to Reticle, then save the scene at res://src/GUI/Reticle/reticle.tscn. Add the following three nodes to it as children: a Polygon2D
, an AnimationPlayer
, and a Camera2D
.
Select the Reticle and in the inspector under Ordering set the Z Index to 10 and uncheck Z as Relative. We use the z-index for ordering our different types of entities, and if we don’t account for that here the reticle would be drawn behind the entities. The 10 is just an arbitrary (high) number to give us room if we expand our layering.
Select the Polygon2D and in the inspector under Data > Polygon create a new PackedVector2Array
. Increase its Size to 4, then fill the created points with the following values: (0, 0), (16, 0), (16, 16), (0, 16). As we have 16x16 pixel grid that means the polygon is now a square covering exactly one tile.
Next we set up the animation. Select the AnimationPlayer and in the Animation pane popping up create a new animation called “pulse”. Select the Polygon2D node and click on the little key icon next to the Color field. If Godot asks, you don’t need a reset track. Open the color selector and set the alpha value to 127, i.e., a half transparent white. Then click the key again to make that color the key for our key frame. Then move the slider in the Animation pane to 1 s, and create another key frame for Color. Then move the slider to 0.5 s. This time change the color to a fully transparent color (i.e., set the alpha to 0), and create a key frame for that as well. A few more touches in the Animation editor: at the far right of the color track you see three icons next to a trash can icon. Select the middle one and change the interpolation from Linear to Cubic. At the top right there is a loop icon. Click that once so it turns blue. And as a last step click the autoplay icon. You can find it between the name of the current animation (reading pulse right now) and the button that says Edit. Now the reticle square will automatically pulse between half transparent and fully transparent as soon as we use it, without the need for any coding.
In the Camera2D we need to change two things, just so it matches our main camera. Set the Zoom to (2, 2), and set the Process Mode to Physics. Now we can create the script. Create a new script (extending Node2D
) on Reticle and save it at res://src/GUI/Reticle/reticle.gd. Let’s go through it bit by bit. Here’s the start of the script:
class_name Reticle
extends Node2D
signal position_selected(grid_position)
const directions = {
"move_up": Vector2i.UP,
"move_down": Vector2i.DOWN,
"move_left": Vector2i.LEFT,
"move_right": Vector2i.RIGHT,
"move_up_left": Vector2i.UP + Vector2i.LEFT,
"move_up_right": Vector2i.UP + Vector2i.RIGHT,
"move_down_left": Vector2i.DOWN + Vector2i.LEFT,
"move_down_right": Vector2i.DOWN + Vector2i.RIGHT,
}
var grid_position: Vector2i:
set(value):
grid_position = value
position = Grid.grid_to_world(grid_position)
var map_data: MapData
@onready var camera: Camera2D = $Camera2D
You see we have a signal. We will use this signal within this class to communicate between functions when we are done selecting a position. After that we have two blocks that probably look familiar, because the directions
are copied over from the main game input handler, and the grid_position
is copied from the entity class. We will have a similar system of moving, and the easiest way to do that (especially with the relatively small scope of this game) is to just duplicate that code. Lastly, below that you also see a reference to the map_data
as well as to the camera
.
func _ready() -> void:
hide()
set_physics_process(false)
Next, the _ready()
function hides the reticle, because it shouldn’t be there initially, and then also disables the physics processing, which is where we will do the moving around, thereby preventing the reticle from being invisibly moved while it is not needed.
func select_position(player: Entity, radius: int) -> Vector2i:
map_data = player.map_data
grid_position = player.grid_position
var player_camera: Camera2D = get_viewport().get_camera_2d()
camera.make_current()
show()
await get_tree().physics_frame
set_physics_process.call_deferred(true)
var selected_position: Vector2i = await position_selected
set_physics_process(false)
player_camera.make_current()
hide()
return selected_position
This is the function we will call from the outside to activate the reticle and get the selected position back. You see we take in the player
, as well as a radius
. We won’t need the latter right now, but we know we will need it later, to indicate the area of the fireball. Including it in the function signature now saves us a bit of refactoring later.
In the function we first set the map_data
. We could check if the map_data
already holds a valid reference, and only set it the first time we activate the reticle, because all the other times we will just store the same map_data
object again. However, it works like that anyway, and the checking mechanism would probably be more overhead anyway. We then also set the position of the reticle to the position of the player.
Next comes the actual setup. We store the player_camera
by getting the currently active camera. After that we make the reticles camera the current one. As the position of the player and the reticle are identical right now, players shouldn’t notice any change, but later we can move the reticle independently of the player. Then we show the reticle, making it visible. We also await the next physics frame, again to make sure we don’t have any interfering inputs. Then we defer a call to activate the reticle’s physics processing. After that we wait for the position_selected
signal, and use it to set the corresponding variable. All the handling of the reticle in action happens elsewhere, we simply whait until that’s done and we receive the result. After we have it, and once this function continues to execute, we disable physics processing again, reactivate the player camera, and hide the reticle. After that, we return the selected position. So what does happen during physics processing?
func _physics_process(delta: float) -> void:
var offset := Vector2i.ZERO
for direction in directions:
if Input.is_action_just_pressed(direction):
offset += directions[direction]
grid_position += offset
if Input.is_action_just_pressed("ui_accept"):
position_selected.emit(grid_position)
if Input.is_action_just_pressed("ui_back"):
position_selected.emit(Vector2i(-1, -1))
This is very similar to how we handle movement in the main game event handler. We go throug all the directions, and if one of the movement actions is just pressed, we move the reticle one tile in the corresponding direction. We then check two other actions. If the accept action is pressed (meaning mostly the enter key), we emit position_selected
with the current grid position. If the back action (which we mapped to the escape key) is pressed we emit position_selected
with (-1, -1). The way we have coded our map only positive coordinates are valid, so here we use (-1, -1) to indicate returning from the position selection without a valid target. Once that’s emitted the select_position
function will resume and handle the rest, as laid out above.
With the reticle and it’s code complete (for now, we will expand it later), we can now move to integrate it into the rest of the game. Back in the game scene add the reticle scene to the Map node. There’s one little thing we do need to add to game.gd right away. The way Godot adds the cameras would make the reticle camera the active one, meaning we’d have no way to get to the player’s camera. So we need to make that the current camera. So expand the _ready()
function by one line:
func _ready() -> void:
player = Entity.new(null, Vector2i.ZERO, player_definition)
player_created.emit(player)
remove_child(camera)
player.add_child(camera)
map.generate(player)
map.update_fov(player.grid_position)
MessageLog.send_message.bind(
"Hello and welcome, adventurer, to yet another dungeon!",
GameColors.WELCOME_TEXT
).call_deferred()
camera.make_current.call_deferred()
Here the last line makes the player camera the one we use when the game starts, just as we had it before. Next we integrate the reticle in main_game_input_handler.gd
. For that we first need a reference to the reticle. At a new exported variable at the top of the script:
const inventory_menu_scene = preload("res://src/GUI/InventorMenu/inventory_menu.tscn")
@export var reticle: Reticle
This exports a reticle node. Now select MainGameInputHandler in the editor, and (after saving the script) you should see the exported Reticle field in the inspector. Click on Assign…, then select the Reticle node in the tree (thanks to proper typing it should be the only one selectable). With a proper reference to the reticle node, we can now create a function that uses it:
func get_grid_position(player: Entity, radius: int) -> Vector2i:
get_parent().transition_to(InputHandler.InputHandlers.DUMMY)
var selected_position: Vector2i = await reticle.select_position(player, radius)
await get_tree().physics_frame
get_parent().call_deferred("transition_to", InputHandler.InputHandlers.MAIN_GAME)
return selected_position
This works very similar to how we got an item from the item selection menu. We move to the dummy input handler, then await the position from the reticle. We move back to the main game input handler, and finally return the selected position.
Integrating that with our items will still be a bit of work, so to have a nice milestone in between let’s create a look around mode. For that we first need a new action, so go into Project > Project Settings > Input Map. Add a new action called “look”, and then bind the divide key (/) on the num pad to it, as well as the s key (for see, as l is already taken by the move keys, and v for visuals is taken up by the history). Then add the following section to get_action()
:
if Input.is_action_just_pressed("look"):
await get_grid_position(player, 0)
If we encounter the look action, we await a position. We simply want to allow the player to look around, but we don’t need the returned position, so we do nothing with it. However, get_grid_position()
will still take care of interrupting the game until we press either the accept or the back action. You should be able to try that out now. Jump into the game and press v to have a look around.
Now, so far we have simply activated items that were selected from the inventory. We will now fit the position selection step in there as well. However, we already have two items, and they don’t need to have a position selected. So we need a way to decide wheter or not we go into position selection mode or not. Later on we will also need to know the radius of areas for effects that require that. So we will simply introduce a function on consumables that can tell us the radius, with -1 indicating that we won’t need targeting. Add the following function to consumable_component.gd:
func get_targeting_radius() -> int:
return -1
As this is the default inherited by all our existing consumable implementations, this will mark all the existing consumables as not requiring a target. No let’s refactor main_game_input_handler.gd a bit. We already have get_item()
and get_grid_position()
as building blocks for our item handling. However, the way we use them would get a bit convoluted if just handled in the if clause within get_action()
, so we modify that as follows:
if Input.is_action_just_pressed("activate"):
action = await activate_item(player)
We will create a function activate_item()
that returns an item action (or null
), and handles all the details of how to get there. Let’s look at that function:
func activate_item(player: Entity) -> Action:
var selected_item: Entity = await get_item("Select an item to use", player.inventory_component, true)
if selected_item == null:
return null
var target_radius: int = -1
if selected_item.consumable_component != null:
target_radius = selected_item.consumable_component.get_targeting_radius()
if target_radius == -1:
return ItemAction.new(player, selected_item)
var target_position: Vector2i = await get_grid_position(player, target_radius)
if target_position == Vector2i(-1, -1):
return null
return ItemAction.new(player, selected_item, target_position)
We pass the player, as we need access to it. First, we use get_item()
to get the item, as we did before. If the item is null
, we directly return null
, because we don’t have any item to handle further. Then we set a target radius to -1. We check if we actually have a proper consumable component (looking forward to when we implement equipment components that don’t have such a component but still are items), and if so ask it for the targeting radius. If the radius is -1, we simply return a new ItemAction
with that item, just as we did previously. However, if we a non-negative target radius, we use get_grid_position()
to acquire a target position. At that point the player still can abort, so if we get the (-1, -1) vector back we return null
. Otherwise we have both a proper item and a proper target position, and feed both that into a new ItemAction
, which we return.
This works for the most part, but has one issue. The way get_item()
and get_grid_position()
change input handlers would result in us changing back to the main game input handler while we would be in targeting mode, resulting in us moving both the player and the reticle at the same time. The solution I present here is not very clean and more of a hack, but I do want to spare you from a bigger refactor. So what we will do is to modify get_item()
so it only returns to the main game input handler when the selected item does not require a target. However, we only need to do this if we actually are activating an item. Otherwise we would lock the game when we drop an item that requires targeting. This is where the extra true
argument in the call to get_item()
in the code above comes from. Here’s the new code for get_item()
:
func get_item(window_title: String, inventory: InventoryComponent, evaluate_for_next_step: bool = false) -> Entity:
var inventory_menu: InventoryMenu = inventory_menu_scene.instantiate()
add_child(inventory_menu)
inventory_menu.build(window_title, inventory)
get_parent().transition_to(InputHandler.InputHandlers.DUMMY)
var selected_item: Entity = await inventory_menu.item_selected
if not evaluate_for_next_step or (selected_item and selected_item.consumable_component and selected_item.consumable_component.get_targeting_radius() == -1):
await get_tree().physics_frame
get_parent().call_deferred("transition_to", InputHandler.InputHandlers.MAIN_GAME)
return selected_item
With item handling out of the way we can look into how we get enemies confused. For the enemy to stumble around aimlessly, we can modify it’s AI. We will create a new AI component which will temporarily replace the enemy AI. So Create a new script extending BaseAIComponent
at res://src/Entities/Actors/Components/confused_enemy_ai_component.gd. Here is the script:
class_name ConfusedEnemyAIComponent
extends BaseAIComponent
var previous_ai: BaseAIComponent
var turns_remaining: int
func _ready() -> void:
previous_ai = entity.ai_component
entity.ai_component = self
func _init(turns_remaining: int) -> void:
self.turns_remaining = turns_remaining
func perform() -> void:
if turns_remaining <= 0:
MessageLog.send_message("The %s is no longer confused." % entity.get_entity_name(), Color.WHITE)
entity.ai_component = previous_ai
queue_free()
else:
var direction: Vector2i = [
Vector2i(-1, -1),
Vector2i( 0, -1),
Vector2i( 1, -1),
Vector2i(-1, 0),
Vector2i( 1, 0),
Vector2i(-1, 1),
Vector2i( 0, 1),
Vector2i( 1, 1),
].pick_random()
turns_remaining -= 1
return BumpAction.new(entity, direction.x, direction.y).perform()
We have two class variables, one for the number of turns the enemy will remain confused, and another for storing the previously used AI, so we can switch back to that once the confusion is over.
In the _ready()
function we first store the current ai_component
of the parent entity in the previous_ai
variable, then we set that component slot to this AI component (it would have been pretty clever to actually handle attatching components like this for all components, but I only realized that at this point).
The _init()
function simply sets the number of turns, allowing us to create a new instance of this AI with a specific number of turns the enemy will spend confused. The interesting stuff then happens in the perform()
function. First, we check if we have run out of confusion turns. If so, we create an appropriate message, restore the parent entity’s ai_component
to the previos one that we have cached, and then we queue_free()
this one.
If we do have turns remaining, we pick a random direction from an array of vectors of all directions. Then decrement the number of remaining turns, and lastly we perform a BumpAction
with that random direction. That’s all the intelligence an enemy needs when they are confused.
To create an item that can make enemies confused we need a corresponding consumable component. And you can bet that we will start with a definition for that. So create a new script extending ConsumableComponentDefinition
at res://src/Entities/Actors/Components/ComponentDefinitions/confusion_consumable_component_definition.gd. This definition simply stores the number of turns:
class_name ConfusionConsumableComponentDefinition
extends ConsumableComponentDefinition
@export var number_of_turns: int
To create the actual component create a new script extending ConsumableComponent
at res://src/Entities/Actors/Components/confusion_consumable_component.gd. Here’s the top of that script:
class_name ConfusionConsumableComponent
extends ConsumableComponent
var number_of_turns: int
func _init(definition: ConfusionConsumableComponentDefinition) -> void:
number_of_turns = definition.number_of_turns
func get_targeting_radius() -> int:
return 0
We have a number of turns we set from the definition for that component. Also we will target a single enemy, so we will return a targeting radius of 0. Remember, -1 meant no targeting. And 1 would mean a center tile plus the tiles directly around it. A targeting range of 0 means exactly one target tile.
func activate(action: ItemAction) -> bool:
var consumer: Entity = action.entity
var target: Entity = action.get_target_actor()
var map_data: MapData = consumer.map_data
if not map_data.get_tile(action.target_position).is_in_view:
MessageLog.send_message("You cannot target an area that you cannot see.", GameColors.IMPOSSIBLE)
return false
if not target:
MessageLog.send_message("You must select an enemy to target.", GameColors.IMPOSSIBLE)
return false
if target == consumer:
MessageLog.send_message("You cannot confuse yourself!", GameColors.IMPOSSIBLE)
return false
MessageLog.send_message("The eyes of the %s look vacant, as it starts to stumble around!" % target.get_entity_name(), GameColors.STATUS_EFFECT_APPLIED)
target.add_child(ConfusedEnemyAIComponent.new(number_of_turns))
consume(consumer)
return true
Our activate()
fuunction needs to check a few things before we can actually use it. First we need to make sure the target tile is visible to us. Otherwise a player could just cast into the dark until they hit an enemy hidden from them, finding out that there is an enemy, and what and where it is. Of course, we can only affect entities, so we need to check that there is a valid target (i.e., an alive actor). Lastly, we need to check that the player doesn’t confuse themself. If we get throug all those checks we can send a message that the casting was successful, and attach a new confusion AI component to that target, preconfigured with the stored number of turns. Then we consume the scroll and return true
.
We need to make sure again that this component is properly created and attached to items using it. So in entity.gd change _handle_consumable()
to:
func _handle_consumable(consumable_definition: ConsumableComponentDefinition) -> void:
if consumable_definition is HealingConsumableComponentDefinition:
consumable_component = HealingConsumableComponent.new(consumable_definition)
elif consumable_definition is LightningDamageConsumableComponentDefinition:
consumable_component = LightningDamageConsumableComponent.new(consumable_definition)
elif consumable_definition is ConfusionConsumableComponentDefinition:
consumable_component = ConfusionConsumableComponent.new(consumable_definition)
if consumable_component:
add_child(consumable_component)
It’s time to create the new scroll. Create a new resource of type EntityDefinition
at res://assets/definitions/entities/items/confusion_scroll_definition.tres. Set the Name to “Confusion Scroll”, and the Texture to the same scroll icon as for the lightning scroll. For the color I used #cf3fff, a bright purple. Disable Is Blocking Movement and set the Type to Item. Then, in the Consumable Definition slot create a new ConfusionConsumableComponentDefinition
, and set the Number of Turns for it to 10.
The last step is to make this item appear in our dungeon generation. In dungeon_generator.gd first add it to our list of entity types:
const entity_types = {
"orc": preload("res://assets/definitions/entities/actors/entity_definition_orc.tres"),
"troll": preload("res://assets/definitions/entities/actors/entity_definition_troll.tres"),
"health_potion": preload("res://assets/definitions/entities/items/health_potion_definition.tres"),
"lightning_scroll": preload("res://assets/definitions/entities/items/lightning_scroll_definition.tres"),
"confusion_scroll": preload("res://assets/definitions/entities/items/confusion_scroll_definition.tres"),
}
Then, in _place_entities()
add a line for it:
if can_place:
var item_chance: float = _rng.randf()
var new_entity: Entity
if item_chance < 0.7:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.health_potion)
elif item_chance < 0.9:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.confusion_scroll)
else:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.lightning_scroll)
dungeon.entities.append(new_entity)
Run the game, and if you are lucky you will find a confusion scroll which you can then use on an enemy.
Now for our final spell scroll, the fireball. This is just damage, so it’s far less involved than the previous scroll. Still, we need to update the visuals, so we can indicate how big the fireball will be. Let’s modify the reticle a bit, so it can show an area.
In reticle.tscn add a new Line2D
node as a child of Reticle. Change its Width to 2 pixels, and its Default Color to full red (#ff0000). Also change the End Cap Mode to Box. Now we update reticle.gd. Add a new onready variable:
@onready var camera: Camera2D = $Camera2D
@onready var border: Line2D = $Line2D
Next we create a new function to set the border for a specific radius:
func _setup_border(radius: int) -> void:
if radius <= 0:
border.hide()
else:
border.points = [
Vector2i(-radius, -radius) * Grid.tile_size,
Vector2i(-radius, radius + 1) * Grid.tile_size,
Vector2i(radius + 1, radius + 1) * Grid.tile_size,
Vector2i(radius + 1, -radius) * Grid.tile_size,
Vector2i(-radius, -radius) * Grid.tile_size
]
border.show()
If the radius is 0 or less we simply hide the border, as we don’t need it in that case. Otherwis we set the points
array on Line2D so that it forms a sqare around the highlighted tile. The +1s in there are because we have to calculate the positions from the top left corner of the highlighted tile. So in case we have a radius of 1 then the top left corner of the border will be one to the left and one up from that corner, but the bottom right corner needs to include the highlighted tile itself, so it’s two tiles down and two tiles right from that origin point. Also notice that we need four points. If we only used four we would have an open shape, so we have to have a last point that is identical with the first point.
In order to use this function we need to call it in select_position()
, when we set everything up. Here is that function again:
func select_position(player: Entity, radius: int) -> Vector2i:
map_data = player.map_data
grid_position = player.grid_position
var player_camera: Camera2D = get_viewport().get_camera_2d()
camera.make_current()
_setup_border(radius)
show()
await get_tree().physics_frame
set_physics_process.call_deferred(true)
var selected_position: Vector2i = await position_selected
set_physics_process(false)
player_camera.make_current()
hide()
return selected_position
That’s it for the area version of the reticle. We can now create the fireball consumable. First, create a new script extending ConsumableComponentDefinition
at res://src/Entities/Actors/Components/ComponentDefinitions/fireball_damage_consumable_component_definition.gd. It will define the radius as well as the damage that it does:
class_name FireballDamageConsumableComponentDefinition
extends ConsumableComponentDefinition
@export var damage: int
@export var radius: int
Now create a new script extending ConsumableComponent
at res://src/Entities/Actors/Components/fireball_damage_consumable_component.gd. Here is the top of the scirpt:
extends ConsumableComponent
var damage: int
var radius: int
func _init(definition: FireballDamageConsumableComponentDefinition):
damage = definition.damage
radius = definition.radius
func get_targeting_radius() -> int:
return radius
The interesting part here is the targeting radius. This is also the reason we are using a function rather than hard coding a value. A fixed value would work for our previous consumables, but for this we have a settable radius. Now for the activate()
function:
func activate(action: ItemAction) -> bool:
var consumer: Entity = action.entity
var target_position: Vector2i = action.target_position
var map_data: MapData = consumer.map_data
if not map_data.get_tile(target_position).is_in_view:
MessageLog.send_message("You cannot target an area that you cannot see.", GameColors.IMPOSSIBLE)
return false
var targets := []
for actor in map_data.get_actors():
if actor.distance(target_position) <= radius:
targets.append(actor)
if targets.is_empty():
MessageLog.send_message("There are no targets in the radius.", GameColors.IMPOSSIBLE)
return false
if targets.size() == 1 and targets[0] == map_data.player:
MessageLog.send_message("There are not enemy targets in the radius.", GameColors.IMPOSSIBLE)
return false
for target in targets:
MessageLog.send_message("The %s is engulfed in a fiery explosion, taking %d damage!" % [target.get_entity_name(), damage], GameColors.PLAYER_ATTACK)
target.fighter_component.take_damage(damage)
consume(action.entity)
return true
This seems convoluted but it’s mostly set up for some checks again. Again we ensure that the player can only place the center of the fireball at a spot they can see. Then we build a targets array. We go through all the actors on the map and if they are within the radius of the explosion we add them to that array. If that array is empty, we prevent the player from wasting their spell and their action. Also, while player can be damaged by the fireball (if they are surrounded by a swarm of enemies centering a fireball on themself might be the best option), we prevent the player from firing if they are the only target. If all that’s ok, we go through all the targets and assign damage to them, then close with the familiar consuming and returning of true
.
We need to handle that consumable component agian in entity.gd:
func _handle_consumable(consumable_definition: ConsumableComponentDefinition) -> void:
if consumable_definition is HealingConsumableComponentDefinition:
consumable_component = HealingConsumableComponent.new(consumable_definition)
elif consumable_definition is LightningDamageConsumableComponentDefinition:
consumable_component = LightningDamageConsumableComponent.new(consumable_definition)
elif consumable_definition is ConfusionConsumableComponentDefinition:
consumable_component = ConfusionConsumableComponent.new(consumable_definition)
elif consumable_definition is FireballDamageConsumableComponentDefinition:
consumable_component = FireballDamageConsumableComponent.new(consumable_definition)
if consumable_component:
add_child(consumable_component)
Now create a new EntityDefinition
resource at res://assets/definitions/entities/items/fireball_scroll_definition.tres. Set the Name to “Fireball Scroll” and the Texture once again to a scroll icon. Set the color to a full red (#ff0000). Uncheck Is Blocking Movement and set Type to Item. Then add a new FireballDamageConsumableComponentDefinition
to the Consumable Definition slot. Set the Damage to 12 and the Radius to 3.
To spawn this items we once again add our new item to dungeon_generator.gd. Modify the entity_types
as follows:
const entity_types = {
"orc": preload("res://assets/definitions/entities/actors/entity_definition_orc.tres"),
"troll": preload("res://assets/definitions/entities/actors/entity_definition_troll.tres"),
"health_potion": preload("res://assets/definitions/entities/items/health_potion_definition.tres"),
"lightning_scroll": preload("res://assets/definitions/entities/items/lightning_scroll_definition.tres"),
"confusion_scroll": preload("res://assets/definitions/entities/items/confusion_scroll_definition.tres"),
"fireball_scroll": preload("res://assets/definitions/entities/items/fireball_scroll_definition.tres"),
}
Also add it in _place_entities()
:
if can_place:
var item_chance: float = _rng.randf()
var new_entity: Entity
if item_chance < 0.7:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.health_potion)
elif item_chance < 0.8:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.fireball_scroll)
elif item_chance < 0.9:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.confusion_scroll)
else:
new_entity = Entity.new(dungeon, new_entity_position, entity_types.lightning_scroll)
dungeon.entities.append(new_entity)
And with that we have a fireball scroll in the game. We now have quite a variety of scrolls, which might make it harder for you to actually find one to try its functionality in the game if you test it, but sooner or later you should come across one.
One last thing we need to handle is a weakness in our input handler state machine that the fireball exposes. The hacky timing of how states are switched when selecting items makes the following edge case possible: The player, at low health, goes into the inventory, causing a switch to the dummy input handler. They select the fireball and target both an enemy and the player character. This causes the player character to die, which immediately causes a transition to the game over input handler. After that execution returns to the cleanup code of the item selection, which now has the game transition “back” to the main game input handler. The effect is that the player entity is at 0 health points, is displayed as a pile of bones called “Remains of Player”, but can still take actions normally. Even worse, they cannot fall to 0 hp again, making them effectively an immortal skeleton.
The further I get into this tutorial series the more I am displeased with the input handler state machine. If I were to redo the tutorial I would probably choose a different approach. However, while having to account for edge cases with special code is always a bit hacky, we should be able to handle this issue pretty easily. The game over input handler does kind of represent a terminal state. With the way it works now we don’t want to be able to leave this state, so we can encode this into the state machine. To do that we add the following check at the start of the transition_to()
function in input_handler.gd:
func transition_to(input_handler: InputHandlers) -> void:
if current_input_handler == input_handler_nodes[InputHandlers.GAME_OVER]:
return
if current_input_handler != null:
current_input_handler.exit()
current_input_handler = input_handler_nodes[input_handler]
current_input_handler.enter()
We check if we are currently in the game over input handler, and if so, we interrupt the transition, making it impossible to leave that input handler. This should prevent any bugs where we try to switch input handlers after the player entity has died.
This concludes this part of the tutorial. Next time we will start handle saving and loading of games. You can find that part of the tutorial at https://selinadev.github.io/14-rogueliketutorial-10/