Roguelike Tutorial, using python3+tdl, part 9
This is part of a series of tutorials; the main page can be found here.
The tutorial was written for tdl version 3.1.0.
Spells and ranged combat
Magic scrolls: the Lightning Bolt
The inventory we implemented has lots of untapped potential; I encourage you to explore it fully and create as many different items as possible! (Though the special category of items that can be equipped, like swords and armor, will be done in a later section.) To get the ball rolling, we'll start with some magic scrolls the player can use to cast spells. We'll sample a few different spell types; I'm sure you can then create tons of variants from these little examples.
The first spell is a lightning bolt that damages the nearest enemy. It's simple to code because it doesn't involve any targeting. On the other hand, it creates some interesting tactical challenges: if the nearest enemy is not the one you want to hit (for example, it's too weak to waste the spell on it), you have to maneuver into a good position. Just modify the place_objects function to choose at random between a healing potion and a lightning bolt scroll, the same way it's done with monsters:
When used, the new item calls the cast_lightning function. It's quite simple, and many of the spells will be equally short if you define some functions for common tasks. One of those common tasks is finding the nearest monster up to a maximum range, which we'll get to in a second; but first, the lightning bolt spell:
It's a plain spell but an imaginative message can always give it some flavor! It returns a special string if cancelled to prevent the item from being destroyed in that case, like the healing potion. There are also a couple of new constants that have to be defined: LIGHTNING_DAMAGE = 20 and LIGHTNING_RANGE = 5.
Now the closest_monster function. We just need to loop through all the monsters, and keep track of the closest one so far (and its distance). By initializing that distance at a bit more than the maximum range, any monster farther away is rejected. We also check that it's in FOV, so the player can't cast a spell through walls.
This makes use of the distance_to method we wrote earlier for the AI. Alright, the lightning bolt is done! If you have one you can take down a Troll with a single hit, sparing you from a lot of damage.
Spells that manipulate monsters: Confusion
There are many direct damage variants of the Lightning Bolt spell. So we'll move on to a different sort of spell: one that affects the monsters' actions. This can be done by replacing their AI with a different one, that makes it do something different -- run away in fear, stay knocked out for a few turns, even fight on the player's side for a while!
My choice was a Confusion spell, that makes the monster move around randomly, and not attack the player. The AI component that does this is very similar to a regular one, since it defines a take_turn method. It could look like this:
Which simply moves in a random direction (random X and Y displacements between -1 and 1).
It shouldn't go on forever of course, and after the spell runs out the previous AI must restored. So it must accept the previous AI component as an argument to store it. It must also keep track of the number of turns left until the effect ends:
I defined the new constant CONFUSE_NUM_TURNS = 10. Now, the actual scroll that uses this AI! For it to appear in the dungeon it must be added to place_objects. Notice that the chance of getting a lightning bolt scroll must change:
I'm making all scrolls look the same, but in your game that's up to you. The cast_confuse function can now be defined. It hits the closest monster for now, like the lightning bolt; later we'll allow targeting.
This uses the constant CONFUSE_RANGE = 8. Components are "plug & play" so they only take a couple of lines of code to replace! What this does is assign a new "confused AI" component to the monster, giving it the previous AI component as an argument. Then its "owner" property is set to the monster, otherwise the new component wouldn't know what monster it belongs to.
Setting the "owner" has to be done every time you replace a component in the middle of the game. To top it off, print a nice message describing what happened.
This seems like a neat way to interrupt a monster's actions in reaction to something. It is, but not for every situation! You could create a full finite state machine by just swapping AI components, but such a system would be very hard to debug. Instead, for most types of AI that have different states, you can simply have a "state" property in the AI component, like this:
Swapping AI components is most useful for unexpected interruptions, such as when a spell is cast. The BasicMonster or DragonAI shouldn't know what to do when confused; that depends on the spell. They do know, however, what to do in normal situations (attacking, etc), and those behaviors usually don't belong in other AI components; if a single AI is composed of too many small interacting parts, they can be harder to maintain. This is just a piece of advice though, and in programming common sense always wins over any hard rules.
Targeting: the Fireball
Given that we know how to make direct damage spells like Lightning Bolt, others like Blizzard or Fireball are just a matter of finding all monsters in an area and damaging them; you should have no trouble creating them. But it would be much more interesting if the player could choose the target properly, and that's a feature that will benefit many spells. In addition, you can use the same system for ranged weapons like crossbows or slings. So let's do that!
We're gonna build a mouse interface. It's also possible to make a classic keyboard interface, but it would be less intuitive and a bit harder to code; if you prefer that, consider it a small challenge!
We already have some code for getting the coordinates of the mouse, and checking for left-clicks is trivial -- when it happens mouse.lbutton_pressed is True. So we just need to loop until the player clicks somewhere. By redrawing the screen with every loop, the names of objects under the mouse are automatically shown, and we erase the inventory from which the player chose the scroll (otherwise it would still be visible).
We have to "flush" the console to present the changes to the player. This does the job, but there are a few things missing! First, there's no way to cancel. We'll define the right-click and the Escape key as ways to cancel, and return a special code:
If it returned None instead, calling (x,y) = target_tile() would give an error, since we expect 2 output arguments (in fact, it's a tuple that is unpacked to variables x and y). So target_tile must return (None, None) when canceling.
Ok, we also want to avoid targeting out-of-FOV, to prevent firing through walls. An optional maximum range would also be nice. Replace the if clicked: line with:
The GameObject class already has a method that returns the distance between 2 objects, but we'll add another one, that returns the distance between that object and a tile. It's used in the above line of code and will be handy in the future.
That's all for targeting a tile! We can now create a simple fireball spell:
With some new constants FIREBALL_RADIUS = 3 and FIREBALL_DAMAGE = 12. This also uses the new distance method. A scroll that casts the Fireball spell must be added to place_objects, before the Confuse scroll:
And change all the "15" to "10"; since there are 3 scrolls now, each one has a 10% chance of appearing. You can now pick up Fireball scrolls; they're quite handy to roast large groups of Orcs! Try not to get burnt though, it also damages the player. I think it adds some strategic value, balancing the spell. (If you want the player to be immune, add "and obj != player" to the if condition in cast_fireball.)
Targeting single monsters
Let's not stop there! Area spells like the Fireball are fine, but many spells affect single monsters. Can we make a handy function to target a single monster? Sure! It will simply wrap target_tile and stop only when a monster is selected.
The Confuse spell is a bit weak, since monsters that move randomly can be hard to hit before the spell runs out. So we'll compensate a bit by letting the player choose any target for it; conveniently testing our new function. Just replace the first 5 lines of the cast_confuse function with:
Right, there's an inventory feature that didn't make it into Part 8, since it was getting too long. You'll miss it when you hit the maximum number of items in your inventory: dropping items. A new method in the Item component will do that. To drop an item you just add it to the map's objects and remove it from the inventory. Then you must set its coordinates to the player's, so it appears below the player:
To let the player choose an item to drop, we'll call the inventory_menu function when the player presses the D key, then drop the chosen item. Add this to handle_keys, after the inventory key:
Some new spells, targeting, dropping items -- that's enough for now! See how the spells affect your strategy, they'll surely make things much more interesting!
The whole code is available here.