Complete Roguelike Tutorial, using python+libtcod, part 10
This is part of a series of tutorials; the main page can be found here. |
Tidy initialization
Now that our game is bursting with gameplay potential, we can think about those little things that diehard fans will surely miss during their long playing sessions. One of the most important is, of course, a save/load mechanism! This way they can go to sleep and dream about your scary monsters between play sessions.
To choose between continuing a previous game or starting a new one we need a main menu. But wait -- our initialization logic and game loop are tightly bound, so they're not really prepared for these tasks. To avoid code duplication, we need to break them down into meaningful blocks (functions). We can then put them together to change between the menu and the game, start new games or load them, and even go to new dungeon levels. It's much easier than it sounds, so fear not!
Take a look at your initialization and game loop code, after all the functions. I can identify 4 blocks:
- System initialization (code between console_set_custom_font and panel = ...).
- Setting up a new game (everything else except for the game loop and the FOV map creation).
- Creating the FOV map.
- Starting the game loop.
The reason why I chose this separation is because I think they're the minimal blocks needed to do all the tasks. The system initialization must be done just once. Thinking out loud, here are outlines of all the tasks:
- Create a new game: set up the game, create FOV map, start game loop. (This is what we have now.)
- Load game: load data (we won't deal with this block now), create FOV map, start game loop.
- Advance level: set up new level (we won't deal with this block now), create FOV map. (The game loop is already running and will just continue.)
Hopefully that will make at least some sense, it's not very detailed but gives us something to aim at.
The system initialization code will stay right where it is, it's the first thing the script executes. Now grab the rest of the code before the main loop and put it in a new function; except for FOV map creation (and fov_recompute = True), the lines player_action = None and objects = [player], which will go elsewhere:
def new_game():
global player, inventory, game_msgs, game_state
#create object representing the player
fighter_component = Fighter(hp=30, defense=2, power=5, death_function=player_death)
player = Object(0, 0, '@', 'player', libtcod.white, blocks=True, fighter=fighter_component)
#generate map (at this point it's not drawn to the screen)
make_map()
game_state = 'playing'
inventory = []
#create the list of game messages and their colors, starts empty
game_msgs = []
#a warm welcoming message!
message('Welcome stranger! Prepare to perish in the Tombs of the Ancient Kings.', libtcod.red)
We have to declare some variables as global, since we're assigning them inside a function now. The FOV map creation code goes in its own function, declaring some globals as well:
def initialize_fov():
global fov_recompute, fov_map
fov_recompute = True
#create the FOV map, according to the generated map
fov_map = libtcod.map_new(MAP_WIDTH, MAP_HEIGHT)
for y in range(MAP_HEIGHT):
for x in range(MAP_WIDTH):
libtcod.map_set_properties(fov_map, x, y, not map[x][y].blocked, not map[x][y].block_sight)
Finally, the game loop and the player_action = None line belong to their own function now:
def play_game():
player_action = None
while not libtcod.console_is_window_closed():
#render the screen
render_all()
libtcod.console_flush()
#erase all objects at their old locations, before they move
for object in objects:
object.clear()
#handle keys and exit game if needed
player_action = handle_keys()
if player_action == 'exit':
break
#let monsters take their turn
if game_state == 'playing' and player_action != 'didnt-take-turn':
for object in objects:
if object.ai:
object.ai.take_turn()
That was a lot of shuffling stuff around! Ok, just a couple of tiny changes left. The new_game function should initialize FOV right after creating the map:
initialize_fov()
And, since the objects' list represents objects on the map, it makes sense to initialize it on map creation, so put it in make_map:
def make_map():
global map, objects
#the list of objects with just the player
objects = [player]
Finally, after system initialization you need to call the new functions to start playing:
new_game()
play_game()
That's it! The code is much tidier. If you don't care much about that, though, and prefer visible changes -- then the next section is just for you!
To keep our main menu from appearing a bit bland, it would be pretty cool to show a neat background image below it. Fortunately, libtcod already has a whole lot of image-related functions!
Of course, since libtcod emulates a console, we can't directly show arbitrary images, since we can't access the console's pixels. We can, however, modify the background color of every console cell to match the color of a pixel from the image. The downside is that the image will be in a very low resolution. But Jice came up with a neat trick: by using specialized characters, and modifying both foreground and background colors, we can double the resolution! This is called subcell resolution, and this page of the docs shows some images of the effect (at the end of the page).
This means that, for our 80x50 cells console, we need a 160x100 pixels image. Here's the one I'm using, just download it and put it in your game's folder if you don't want to draw your own. I searched for real dungeon photos with a Creative Commons license using the advanced search in Yahoo Images (I tried it on Google Images too but didn't get as many results), then fired up Paint.net to resize it and modify it as much as possibly to give it the feel of a fantasy setting: torches, no natural light, a guy with a sword, that sort of stuff. I found that stacking layers and using a brush with an extremely low alpha (a value of 3 or so) lets me draw things very gradually, since I'm not exactly an artist! I guess it's ok to spend some time with this since it may be the only actual image in the whole game.
Right, after that small detour, it's back to coding! Let's create a function for the main menu interface, and begin by loading and showing the image:
def main_menu():
img = libtcod.image_load('menu_background.png')
#show the background image, at twice the regular console resolution
libtcod.image_blit_2x(img, 0, 0, 0)
It's really easy. Notice the file name is a bit different from the image I linked earlier. We can now take advantage of our reusable menu function to present 3 options: start game, continue, or quit. Since the function also returns if the player presses a different key (canceled), we need to wrap it in a loop to present the menu again in that situation.
def main_menu():
img = libtcod.image_load('menu_background.png')
while not libtcod.console_is_window_closed():
#show the background image, at twice the regular console resolution
libtcod.image_blit_2x(img, 0, 0, 0)
#show options and wait for the player's choice
choice = menu('', ['Play a new game', 'Continue last game', 'Quit'], 24)
if choice == 0: #new game
new_game()
play_game()
elif choice == 2: #quit
break
Let's test this out! In the main script, instead of starting a new game right away with new_game() and play_game(), call our new function:
main_menu()
The main menu is working now, apart from the "continue" feature which we'll get to later. Now it's time to add some fluff. Before calling the menu function, I want to show my game's title and, of course, the author's name! You will probably want to modify this for your own game, of course.
#show the game's title, and some credits!
libtcod.console_set_foreground_color(0, libtcod.light_yellow)
libtcod.console_print_center(0, SCREEN_WIDTH/2, SCREEN_HEIGHT/2-4, libtcod.BKGND_NONE, 'TOMBS OF THE ANCIENT KINGS')
libtcod.console_print_center(0, SCREEN_WIDTH/2, SCREEN_HEIGHT-2, libtcod.BKGND_NONE, 'By Jotaf')
You'll notice that the menu rectangle itself starts with a blank line. This is because the header string is empty, but console_height_left_rect reports its height as 1 by default. To make that line go away, we need to check that condition in the menu function, between the lines header_height = ... and height = ....
if header == '':
header_height = 0
Another detail is that the player may want to switch to fullscreen in the main menu. However, we only check that key during the game. It's fairly easy to copy that code to the menu function too, right after asking for the key:
if key.vk == libtcod.KEY_ENTER and key.lalt: #(special case) Alt+Enter: toggle fullscreen
libtcod.console_set_fullscreen(not libtcod.console_is_fullscreen())
There it is, a neat main menu, and with only a handful of lines of code!