Difference between revisions of "New Roguelike Tutorial, using Lua+libtcod"
Linux junkie (talk | contribs) |
Hari Seldon (talk | contribs) |
||
Line 231: | Line 231: | ||
That's all for now. Have fun, and good luck! | That's all for now. Have fun, and good luck! | ||
[[Category:Developing]] |
Latest revision as of 15:02, 26 October 2012
The code for this page can be found at https://sites.google.com/site/lualibtcodtutorials/tutorial-one
This tutorial relies on the code, and the attachment is at the bottom of the page. Follow these directions to compile: first, download libtcod 1.5.1 at http://doryen.eptalys.net/libtcod/. Copy the libraries to the code folder, and make sure Lua 5.1 is installed on your machine. Then, just compile all .cpp files to an executable, and run the program like this: "program.exe main.lua". You feed the Lua file you wish to run as an argument to the program, otherwise, it won't run. For our tutorial, main.lua is the target file. Anyway, on with the tutorial!
This is the start of my tutorial series, using libtcod and Lua. If you're not already familiar with libtcod, you can get more information here: http://doryen.eptalys.net/libtcod. Likewise, if you want more information about Lua, you can find it at http://www.lua.org.
You may be asking yourself, why Lua, and why libtcod? The answer is simple. Lua is a simple, easy to use language, but at the same time, it features a wealth of possibilities for any complex project, like a roguelike. And as for libtcod, the answer is again, simple. Libtcod is a console emulator with true color support, so we aren't limited by the usual color palette of curses or similar terminal programs. This makes our games much more visually appealing, and it runs faster as well. That's not to mention the A* and Dijkstra pathfinding, field of vision calculations, and other advanced features that turn complex programming challenges into an easy affair.
But enough about our language and library. Before we get started, here are a few notes. Libtcod is for C and C++, but features wrappers for Python, C#, Clisp, and Lua. Unfortunately, as of the latest release, the official Lua wrapper is incomplete. Fortunately, I wrapped it myself, and you're free to use it. The wrapper is unique, however, in the fact that it uses a C++ launcher to run the program. The reason for this is to speed up startup times, and to allow the programmer to easily extend his Lua application with C/C++ code. To run the tutorial, simply compile the C++ files, and run the executable. You need to feed the executable the Lua file you wish to run as an argument, and it will automatically load the library and start the game. This tutorial uses it exclusively, so you might as well get a feel for things. A tutorial for just the wrapper will be released soon.
This tutorial will start things off with a simple walkaround demo, to get you ready to go. But, it's not *that* simple of a demo, as it features a dungeon generator with the usage of hand-crafted rooms, saving and loading of the map and player statistics, and the start of a wizard mode, to aid in debugging. Not too shabby for a walkaround demo, huh?
Anyway, let's get the ball rolling. We'll start off examining the "main.lua" file, which is the entry point for our application. You'll notice the first line reads:
game = {}
This initializes a table called "game". The "game" table will be used to store basic game information, as well as the basic functions that run the game. Next, you'll see a string of "dofile" statements. These are similar to C/C++ includes, although unlike the include files, "dofile" actually runs the given file, loading all functions and data into memory, and executing any scripts contained within. dofile ("dungeon.lua")
dofile ("config.lua")
dofile ("player.lua")
dofile ("gui/guimanager.lua")
dofile ("camera.lua")
dofile ("loadsave.lua")
dofile ("wizardmode.lua")
Following this, you'll see three boolean variables declared as part of the "game" table. These are "game.quit", "game.gameOver", and "game.victory". These booleans will be checked later on in the main game loop, and if any of them is true, the loop will exit. "game.quit" is used if the player tries to quit, "game.gameOver" is used if the player dies, and "game.victory" is used if the player wins. As of this stage of the demo, only the "game.quit" variable is used, but the rest will be used later on, when the game is in a more finished state.
game.quit = false
game.gameOver = false
game.victory = false
Next, you'll see our first two libtcod statements. The first initializes "game.rng" to the instance of the default random number generator. We'll be using this RNG throughout the entire program, so pay close attention to this line. The next is used to create an offscreen console, "game.mapScreen", which will be used for displaying our map. By default, libtcod draws to the root console, and only the root console is displayed on the screen. However, by using an offscreen console, we can blit it directly to the root console, and thus display our map. Using offscreen consoles is highly recommended, as it will save you future programming headaches as your game increases in complexity. For example, let's say you wanted to display your map screen at a different position on the screen. Without an offscreen console, you would have to manually update all of you display routines, changing them around to fit the new coordinates. But with an offscreen console, you can simply blit the entire console to a different location, all with one line of code.
game.rng = tcod.rng:getInstance(game.rng)
game.mapScreen = tcod.console:create(game.mapScreen, game.screenWidth, game.screenHeight - 15)
Now we'll move onto our first function, and the only one that the program will actually execute. "game.main" will run all game function from within it. First, it runs "game.init", which initializes all game data, and sets the program up. Then, "game.splashScreen" is run. This function is currently not implemented, but will eventually contain our main menu, with a pretty image in the background. After this, "game.start" is run, and this is where the real meat of the program is. This function will process all rendering and game events. Once this function exits, we then use a couple "if" statements to see if we should run our currently unimplemented game over and victory screens. After this, "game.shutdown" is run, where the closing of the game occurs. We'll examine each of these functions in more detail as the tutorial progresses.
function game.main ()
game.init()
game.splashScreen()
game.start()
if game.gameOver then
game.gameOverScreen()
elseif game.victory then
game.victoryScreen()
end
game.shutdown()
end
Now it's time to examine our "game.init" function. As of now, it's fairly simple and streamlined. First, we run "tcod.console.initRoot", which initializes the root console for libtcod. This must be run before any rendering is accomplished. You'll notice as arguments, we feed it several variables. But wait! These variables haven't been initialized yet, right? Wrong. Open up "config.lua", and you'll see all these variables declared and ready. By making these variables configurable by the user, we can easily allow the player to set the game visuals up the way he likes. First, we'll feed "tcod.console.initRoot" the screen width and height, followed by the window title. Then, we'll set whether or not the game is fullscreen. Finally, we'll set the renderer to use. By default, we'll use "tcod.renderer.glsl", because this will be the fastest renderer for users with video cards. Users who don't have video cards can just tweak the "config.lua" file, and set the renderer to "tcod.renderer.sdl", which will run on all computers.
function game.init ()
tcod.console.initRoot(game.screenWidth, game.screenHeight, game.title,
game.fullscreen, game.renderer)
guiManager.initialize()
dungeon.generate(1, 150, 150)
end
After we initialize the root console, we'll generate our dungeon. In the final game, we won't generate the dungeon here, but for the purposes of our walkaround demo, this is an appropriate place to do it.
Next we'll move onto our "game.start" function. This is the real meat of the program. First, we start a "while" loop, which will run as long as the following statements are true: "game.quit" is *not* true, "game.gameOver" is *not* true, "game.victory" is *not* true, and "tcod.console.isWindowClosed" is *not* true. This last one should be fairly obvious. If the user tries to close the window, we should quite naturally begin to shutdown.
function game.start ()
while not game.quit and not game.gameOver and not game.victory and not
tcod.console.isWindowClosed() do
game.render()
tcod.console.blit(game.mapScreen, 1, 1, game.screenWidth,
game.screenHeight - 15, tcod.console, 1, 1, 1, 1)
guiManager.render()
tcod.console.flush()
game.update(tcod.console.checkForKeypress(tcod.keyevents.pressed)
, tcod.mouse.getStatus())
end
end
In the game loop, you'll see the following functions: "game.render", "tcod.console.blit", "tcod.console.flush", and "game.update". "game.render" quite simply handles all rendering for the game. It currently draws the visible map, and the player. The next version will include monsters, and items. "tcod.console.blit" is used to blit "game.mapScreen" to the root console, so it will actually get displayed. "tcod.console.flush" flushes the root console to the screen, and it's this function that actually does the drawing. Without this function, nothing gets displayed. Finally, "game.update" does the updating for the game, handling the player's movement and actions, and all AI, event, and other processing.
The next implemented function is "game.shutdown". This delete the offscreen console, and then loops for every dungeon map the player has seen, and deletes the FoV objects. It's always important to free all memory you use.
function game.shutdown ()
game.mapScreen:delete()
guiManager.close()
for i=1, player.maxDepth do
dungeon[i].fov:delete()
dungeon[i].doorlessFov:delete()
end
end
Now let's move on to the "game.render" function. As of this release, it only displays the explored map, and the player. First, we'll calculate the FoV, to see what map tiles the player can see. This function takes a few parameters. First, it takes the player's coordinates, followed by the player's light radius. Next is a boolean variable that determines if the wall tiles are lit. We defitely want that to be true. Then we choose the FoV algorithm to use. We're going to use mingos' excellent restrictive algorithm, as it looks good, and is fast. You'll notice a reference to dungeon[level].blood. This is for the currently unimplemented blood splatter effects, which will be added on the next tutorial.
function game.render ()
dungeon[player.depth].fov:computeFov(player.position.x, player.position.y, player.light.radius, true, tcod.fovalgo.restrictive)
for x=1, game.screenWidth do
for y=1, game.screenHeight - 15 do
if wizardMode.omniscient then
game.mapScreen:putCharEx(x, y, dungeon[player.depth].map[x + camera.position.x][y + camera.position.y].graphic,
dungeon[player.depth].map[x + camera.position.x][y + camera.position.y].foreColor,
dungeon[player.depth].map[x + camera.position.x][y + camera.position.y].backColor)
else
if dungeon[player.depth].fov:isInFov(x + camera.position.x, y + camera.position.y) then
dungeon[player.depth].map[x + camera.position.x][y + camera.position.y].isExplored = true
game.mapScreen:putCharEx(x, y, dungeon[player.depth].map[x + camera.position.x][y + camera.position.y].graphic,
dungeon[player.depth].map[x + camera.position.x][y + camera.position.y].foreColor,
dungeon[player.depth].map[x + camera.position.x][y + camera.position.y].backColor)
elseif dungeon[player.depth].map[x + camera.position.x][y + camera.position.y].isExplored then
game.mapScreen:putCharEx(x, y, dungeon[player.depth].map[x + camera.position.x][y + camera.position.y].graphic,
tcod.color.darkerGrey, tcod.color.darkestGrey)
else
game.mapScreen:putCharEx(x, y, string.byte(" "), tcod.color.black, tcod.color.black)
end
end
end
end
game.mapScreen:putCharEx(player.position.x - camera.position.x, player.position.y - camera.position.y, player.graphic, player.color,
dungeon[player.depth].map[player.position.x][player.position.y].backColor)
end
After that, we run across our first use of the Wizard mode. If the Wizard mode is active, and omniscient mode is set, we'll display the entire map. Otherwise, we'll display all tiles in FoV first. Any tile that's in FoV is marked as being explored. Next, if a tile is not in FoV, but is explored, we'll display the tiles in gray scale. Finally, any unexplored tiles are rendered as empty black spaces. After displaying the map, we display the player, at his given position.
You'll notice that the selected map tiles are modified by the camera position. This allows for scrolling maps. Open up "camera.lua" to examine the full implementation of the camera functions. They're fairly simple, but they work.
Finally, we process "game.update". This takes the current keypress and mouse as arguments. You'll notice that we're using a non-blocking call for reading the keypresses. This is used for real-time game loops. However, our game will be turn-based. We'll be using a real-time game loop to allow for our GUI system, which will be used in later tutorials to handle the message buffer, menus, and status bars for health, mana, and skill points.
function game.update (keypress, mouse)
player.update(keypress, mouse)
end
"game.update" only calls one function right now, and that is "player.update". Open "player.lua" now, and we'll examine what exactly how the player updating is handled.
The first thing you'll see is a declaration of the player table, followed by the basic player stats that the demo uses. These are the name, light radius, graphic, color, and the depth, which starts at level one, and the max depth reached, which is also one.
Next, we have the "player.update" function. This is basically a series of "if" statements, each one checking for the various keypresses that correspond to the player's actions. Here are the keypresses available, and their corresponding actions:
Escape - Exits the game loop, by setting "game.quit" to true
Alt + Enter - Toggles the fullscreen status
Shift + Direction - Pans the camera
Direction keys, Number Pad, or Numbers - Movements, including diagonals
c - Close door
o - Open door
S - Saves the current game
L - Loads the game
> or < - Ascend or descend staircases
W - Toggle Wizard mode
O - If Wizard mode is active, toggle omniscient point of view
First, let's analyze the movement. All movement keypresses trigger the "player.move" function. This function is simple, and takes the direction to move as an argument. First, it checks to see if the map tile in the desired direction is walkable. If so, it then checks to see if it is occupied by a creature. If it is, then it executes the currently unimplemented combat function. If not, it moves the player.
Next, we have the open/close door functions. First, it runs a "for" loop, adding all found doors of the desired open/closed status to the "availableDoors" table. Then, a series of "if" statements check for the number of elements in the table. If there are none, the function simply exits. If there is one, it opens/closes the door. If there are more than one, it currently just exits. Eventually, once our GUI is in place, it will have a door chooser, that lets the player pick which of the multiple doors to open/close.
Now, let's move on the the ascend/descend functions. These check to see if the player is standing on the appropriate staircase, as well as a check to ensure the player stays between levels 1 and 20. If so, it updates the player.depth variable by one, in the given direction. If the player is descending, and is entering a level deeper than "player.maxDepth", then maxDepth is increased, and a new level is generated. Finally, a "for" loop scans the dungeon to find the appropriate staircase to place the player on.
And last, but not least, we have the "player.save" function. It's very important to implement saving/loading right away, and continuously expand these functions as the game progresses. Luckily, saving and loading is made extremely easy in Lua. All we have to do is open up the savefile, and write the raw Lua code to save the game state. For the player, it saves the name, depth, maxDepth, graphic, color, light radius, and position. When we want to load, we simply call "dofile" on the savefile, and our data is loaded. Easy, huh?
That's all for "player.lua", so let's move onto our dungeon generator. This is where things get a little tricky. Open up "dungeon.lua", and start reading. Obviously, first things first, we initialize our dungeon table, and then we run "dofile" for "dungeonfeatures.lua", and "theme.lua". A few notes about our generator before we get started. From the beginning, I wanted to allow for handcrafted rooms, instead of boring old randomly generated square blocks. Secondly, I wanted to have themed dungeons. Right now, the current theme is a generic one, that uses all room types. The theme is responsible for the look of the tiles, as well as the tile stats, like durability.
The "dungeonfeatures.lua" file contains all 30 handcrafted rooms. These are drawn out as arrays of strings, and processed by a utility function into actual data. The rooms themselves, which from this point on will be termed features, have a few attributes. One, is the raw data for the feature, followed by the width and height of the given feature. After this, is an array of "connections". These connections determine which wall tiles can be used to connect features. Each feature will connect to another feature via these connections. They are set by the coordinates of the connection, as well as the direction the feature goes in.
As always, feel free to expand the code, and have fun crafting new rooms to add, or modifying the existing ones.
As for the actual dungeon generator, it follows a simple algorithm, which can be found on roguebasin. Here's a reprint of the original algorithm:
1 - Fill the map with wall tiles
2 - Dig room in the map center (in ours, we build it more offcenter, towards the upper left side of the map)
3 - Pick a wall of any room (this is where our "connections" come in handy)
4 - Decide on a new feature to build (this is picked by the theme, so that different themes can have different features)
5 - Check for room through wall (fairly easy, just scan the area to ensure that only wall tiles are in the given block)
6 - If there's room, continue. If not, go back to step 3
7 - Add feature through the wall
8 - Go to step 3, until dungeon is complete (the target number of features is determined by the theme, although we also limit the number of attempts at building, to keep the generator from running out of room and locking up)
9 - Add up and down stairs randomly
10 - Fill with monsters and items (currently unimplemented, but will be handled by the theme)
Fairly simple, right? Analyze the code thoroughly, and try and see how the implementation of the algorithm is handled. Learning to read code is good exercise, so it's best to start now. Any questions can be posted in the relavent thread in the libtcod forums (look for it under the Tutorials section), or can be sent to me via email, at pruettti(at)gmail.com.
Finally, we have the dungeon saving function, which is similar, albeit larger, than the player saving function. It's fairly straightforward, and should be easy for you to follow.
Well, that's all for now. This demo let's you scroll the map, and walkaround, opening and closing doors. The next tutorial will add monsters, with simple AI, and our scheduler system for all actions/events. Happy coding!
Exercises for the reader:
Here are a few things I intentionally left unimplemented, to give you a useful bit of practice. As of now, the camera moves independently of the player. Fix this. Good places to put this new bit of code will be in "player.move", as well as "player.ascend", and "player.descend". It may seem more natural to use "camera.moveRelative", and just move the camera in the direction the player moves, but you'll run into problems when they reach the edges of the screen. "camera.moveAbsolute", however, will allow you to always center the camera on the player.
On that note, the camera movement functions are faulty. They work, but not as they should. Fix them as well. For example, if you attempt to move the camera to coordinate 50,-15, it won't move it at all, because the if statement fails to evaluate to true. Change the if statements around, so that in the previous instance, the camera would be moved to 50,0. This is an easy, but useful exercise.
Also, try adding some new features, with connections, and see how they look in the program. Also try creating your own theme, using different colors for the tiles, and maybe just a subset of the available features. Try making a long, twisty set of tunnels, or maybe one with only cavernous looking rooms.
If you're feeling extra ambitious, add in the door chooser code, for when the player is surrounded by more than one door. Let them pick a direction, and open/close the appropriate door.
If you really don't feel like doing these exercises, don't worry. They're all ready and implemented for chapter two of the tutorial. Each future tutorial will intentionally leave something unimplemented for exercise purposes, but each successive tutorial will fix these things and expand upon them.
That's all for now. Have fun, and good luck!