This is part of a series of tutorials; the main page can be found here.
The tutorial uses libtcod version 1.6.0 and above.
Can you feel that? Itâs the sense of anticipation in the air! Thatâs right, from now on we wonât rest until our game lets us smite some pitiful minions of evil, for great justice! Itâll be a long journey, and the code will become more complicated, but thereâs no point in tip-toeing around it any more; itâs a game and we want to play it. Some sections will be a bit of a drag, but if you survive that, the next part will be much more fun and rewarding.
First, we must handle monster placement. While this may seem daunting, itâs actually pretty simple, thanks to our generic object system! It only requires us to create a new object and append it to the objects list. Therefore, all we need to do is, for each room, create a few monsters in random positions. So weâll create a simple function to populate a room:
def place_objects(room):
#choose random number of monsters
num_monsters = libtcod.random_get_int(0, 0, MAX_ROOM_MONSTERS)
for i in range(num_monsters):
#choose random spot for this monster
x = libtcod.random_get_int(0, room.x1, room.x2)
y = libtcod.random_get_int(0, room.y1, room.y2)
if libtcod.random_get_int(0, 0, 100) < 80: #80% chance of getting an orc
#create an orc
monster = Object(x, y, 'o', libtcod.desaturated_green)
else:
#create a troll
monster = Object(x, y, 'T', libtcod.darker_green)
objects.append(monster)
The constant MAX_ROOM_MONSTERS = 3 will be defined with the other constants so that it can be easily tweaked.
I decided to create orcs and trolls, but you can choose anything else. In fact, you should change this function as much as you want! This is probably the simplest method. If you want to add more monsters, youâll need to keep the random value in a variable and compare it multiple times, using this pattern:
#chances: 20% monster A, 40% monster B, 10% monster C, 30% monster D:
choice = libtcod.random_get_int(0, 0, 100)
if choice < 20:
#create monster A
elif choice < 20+40:
#create monster B
elif choice < 20+40+10:
#create monster C
else:
#create monster D
As an alternative, you could define a number of pre-set squads and choose one of them randomly, each squad being a combination of some monsters (for example, one troll and a few orcs, or 50% orcs and 50% goblin archers). The sky is the limit! You can also place items in the same manner, but weâll get to that later.
Now, for the dungeon generator to place monsters in each room, call this function right before adding the new room to the list, inside make_map:
#add some contents to this room, such as monsters
place_objects(new_room)
There! I also removed the dummy NPC from the initial objects list (before the main loop): it wonât be needed any more.
Here, weâll add a few bits that are necessary before we can move on. First, blocking objects. We donât want more than one monster standing in the same tile, because only one will show up and the rest will be hidden! Some objects, especially items, donât block (it would be silly if you couldnât stand right next to a healing potion!), so each object will have an extra âblocksâ property. Weâll also take the opportunity to allow each object to have a name, which will be useful for game messages and the Graphical User Interface (GUI), which weâll go over later. Just add these two properties to the beginning of the Object âs init method:
def __init__(self, x, y, char, name, color, blocks=False):
self.name = name
self.blocks = blocks
Now, weâll create a function that tests if a tile is blocked, whether due to a wall or an object blocking it. Itâs very simple, but it will be useful in a lot of places, and will save you a lot of headaches further down the line.
def is_blocked(x, y):
#first test the map tile
if map[x][y].blocked:
return True
#now check for any blocking objects
for object in objects:
if object.blocks and object.x == x and object.y == y:
return True
return False
OK, now itâs time to give it some use! First of all, in the Object âs move method, change the if condition to:
if not is_blocked(self.x + dx, self.y + dy):
Anyone, including the player, canât move over a blocking object now! Next, in the place_objects function, weâll see if the tile is unblocked before placing a new monster:
#only place it if the tile is not blocked
if not is_blocked(x, y):
Donât forget to indent the lines after that. This guarantees that monsters donât overlap! And since objects have two more properties, we need to define them whenever we create one, such as the line that creates the player object. Replace the old object initialization code:
#create object representing the player
player = Object(SCREEN_WIDTH/2, SCREEN_HEIGHT/2, '@', libtcod.white)
#create an NPC
npc = Object(SCREEN_WIDTH/2 - 5, SCREEN_HEIGHT/2, '@', libtcod.yellow)
#the list of objects with those two
objects = [npc, player]
With this:
#create object representing the player
player = Object(0, 0, '@', 'player', libtcod.white, blocks=True)
#the list of objects starting with the player
objects = [player]
And update the code that creates the monsters:
if libtcod.random_get_int(0, 0, 100) < 80: #80% chance of getting an orc
#create an orc
monster = Object(x, y, 'o', 'orc', libtcod.desaturated_green, blocks=True)
else:
#create a troll
monster = Object(x, y, 'T', 'troll', libtcod.darker_green, blocks=True)
If you added the optional room ânumbersâ in part 3, youâll need to update the code to include a name.
room_no = Object(new_x, new_y, chr(65+num_rooms), 'room number', libtcod.white)
Last stop before we get to the actual combat system! Our input system has a fatal flaw: player actions (movement, combat) and other keys (fullscreen, other options) are handled the same way. We need to separate them. This way, if the player pauses or dies he canât move or fight, but can press other keys. We also want to know if the playerâs input means he finished his turn or not; changing to fullscreen shouldnât count as a turn. I know theyâre just simple details - but the game would be incredibly annoying without them! We only need two global variables, the game state and the playerâs last action (set before the main loop).
game_state = 'playing'
player_action = None
Inside handle_keys, the movement/combat keys can only be used if the game state is âplayingâ:
if game_state == 'playing':
#movement keys
Weâll also change the same function so it returns a string with the type of player action. Instead of returning True to exit the game, return a special string:
return 'exit' #exit game
And testing for all the movement keys, if the player didnât press any, then he didnât take a turn, so return a special string in that case:
else:
return 'didnt-take-turn'
After the call to handle_keys in the main loop, we can check for the special string âexitâ to exit the game. Later weâll do other stuff according to the player_action string.
player_action = handle_keys()
if player_action == 'exit':
break
For the first part of the combat system we have to manage the player and monsters taking combat turns, as well as the player making an attack. To make it simple, the player takes his turn first, in handle_keys. If he took a turn, all the monsters take theirs. This goes after the handle_keys block in the main loop:
#let monsters take their turn
if game_state == 'playing' and player_action != 'didnt-take-turn':
for object in objects:
if object != player:
print 'The ' + object.name + ' growls!'
Thatâs just a debug message, in the next part weâll call an AI routine to move and attack. Now weâll take care of the player input. Since he can attack now, instead of calling move (inside handle_keys) like this:
player.move(0, -1)
fov_recompute = True
âŚweâll make a function called player_move_or_attack and replace all those 4 blocks with calls like this:
player_move_or_attack(0, -1)
The function itself has a few lines of code but doesnât do anything extraordinary. It has to check if thereâs an object in the direction the player wants to move. If so, a debug message will be printed (this will later be replaced by an actual attack) If not, the player will just move there.
def player_move_or_attack(dx, dy):
global fov_recompute
#the coordinates the player is moving to/attacking
x = player.x + dx
y = player.y + dy
#try to find an attackable object there
target = None
for object in objects:
if object.x == x and object.y == y:
target = object
break
#attack if target found, move otherwise
if target is not None:
print 'The ' + target.name + ' laughs at your puny efforts to attack him!'
else:
player.move(dx, dy)
fov_recompute = True
Alright, the code is ready to test! No damage is done yet but you can see the monsters taking their turns after you (notice when you switch to fullscreen it doesnât count as a turn, yay!), and you can bump into them to heroically but unsuccessfully try to destroy them!
The whole code so far is available here.
Guess whatâs next?