原文链接:
Complete Roguelike Tutorial, using python+libtcod, part 2www.roguebasin.com
物体和地图
屏幕外的控制台
有一件事情我们现在要先确定才能继续,那就是我们之前在调用console_set_default_foreground and console_put_char 时使用了参数0,这意味着他们被绘制在根控制台之上,也就是所谓的屏幕缓冲区。(与后缓冲区对应,熟悉双缓冲的人 应该知道是啥意思)
然而我们很多时候并不希望绘制的东西自动被用户看到。这在其他的绘图库很常见(比如众所周知的opengl)。这个场景外的控制台保存的并不是像素化之后的结果,而是字符和颜色,因此你可以很容易的进行修改。因此使用这些场景外的控制台(他们包含了半透明、渐进效果)在根控制台上组合出你想要的GUI面板吧(如果有点不理解,想一想PS的界面,就类似一堆控制台的组合)。如果不这样搞以后你就会发现想要想要构成一个复杂的特效丰富的界面是多么困难。(整这些花里胡哨的。。。要炫酷我干嘛不用u3d。。。)
首先,创建一个新的场景外控制台,这个控制台将充满整个屏幕,但是这个将在以后被改变。我们简单的给这个控制台起个名字叫做"con”,这个参数将会在之后被反复使用!你可以把这段代码放在初始化函数里,就在console_init_root.函数后面
con = libtcod.console_new(SCREEN_WIDTH, SCREEN_HEIGHT)
现在改变console_put_char和console_set_default_foreground的第一个参数(这里它使用了两个参数)为con(之前为0)。现在之前绘制的东西都绘制在“con”里了。
最后,在console_flush()之前,将con里的东西blit到根控制台上(熟悉位图绘制的应该知道blit是个啥),以显示绘制在con上的东西。下面的参数对于初学者可能有点莫名其妙,但是他们大概的意思就是把A图片的某一部分(0,0起始),以X的大小,绘制到B图片的某个位置(0,0起始)的一个过程
有兴趣的可以去查查文档,以学习这个函数输入参数的细节(要是用过opencv或者任何一种GDI库的人应该会觉得这部分很简单的,如果还是看不懂,查一查位图绘制相关资料)(这个blit函数在很多语言很多库的位图绘制部分都有用到,可以理解使用ps时,把另一张图片复制进来,进行各种变换之后,放在根图片达成“毫无ps痕迹的结果”的行为)
libtcod.console_blit(con, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 0)
说了这么一大堆,结果看起来和上一章还是没有任何区别,简直是无聊透顶!下一章节我们将玩一点有趣的,我们将介绍我们的新朋友,一个NPC。但是当你写GUI部分时,记得回来复习这一章节
面向对象的地图设计(这个题目是我自己根据内容归纳的,原题让人有点莫名其妙)
现在我们有了一个可以到处溜达的@,现在是回头思考一点软件设计方面问题的时候了。设计一个保存运动的变量是很容易的一件事,但是马上事情就可能会失控,比如说设计一个hp值,bonuses(金钱)值,inventory(物品数据)。我们现在就设计一些这种东西
游戏开发者经常会掉入过度设计的陷阱,那正是我们要避开的问题。我们现在要做的事情就是将玩家人物设计成一个游戏object(学过oo的人应该能够理解这句话),写一个class,包含所有的位置和显示信息(字符和颜色),这个类将是object类的派生类,之后我们还会用object类去派生一些旁的东西,例如地板,怪物,门,楼梯,任何可以交互的东西,下面是object的源码,包含了初始化,三种通用操作“移动,绘制和清除”,用于绘制和擦除的代码跟画玩家人物的方法一模一样(还记得当年学的面向对象么?)
class Object:
#this is a generic object: the player, a monster, an item, the stairs...
#it's always represented by a character on screen.
def __init__(self, x, y, char, color):
self.x = x
self.y = y
self.char = char
self.color = color
def move(self, dx, dy):
#move by the given amount
self.x += dx
self.y += dy
def draw(self):
#set the color and then draw the character that represents this object at its position
libtcod.console_set_default_foreground(con, self.color)
libtcod.console_put_char(con, self.x, self.y, self.char, libtcod.BKGND_NONE)
def clear(self):
#erase the character that represents this object
libtcod.console_put_char(con, self.x, self.y, ' ', libtcod.BKGND_NONE)
请注意,init左右都有两个下划线
现在,在主循环之前,我们不像之前那样设置玩家人物的坐标,而是将其实例化为一个object。我们还把他加入一个列表,这个列表包含了游戏里全部的object。我们还在列表里增加一个黄色的@以表征非玩家角色,就像在rpg里那样(你这个不就是个RPG??),试试看吧!
player = Object(SCREEN_WIDTH/2, SCREEN_HEIGHT/2, '@', libtcod.white)
npc = Object(SCREEN_WIDTH/2 - 5, SCREEN_HEIGHT/2, '@', libtcod.yellow)
objects = [npc, player]
我们现在再做一些改变。首先,在handlekey函数里,我们不再直接修改玩家坐标,我们使用玩家人物的运动方法来替代之前的代码。之后这个代码会加上与其他object的碰撞检测的功能(不仅限于检测玩家,还有其他物体),现在主循环简洁多了,就像下面这样:
for object in objects:
object.clear()
而绘制它们的代码如下所示
for object in objects:
object.draw()
好了,这个通用的object系统完成了。之后我们将会用这个基类派生其他的类,像物品,怪物,还有其他一切需要的。但是不用操之过急
地图
就如同你生成玩家这个概念一样,你将会对地下城地图做同样的事情。你的地图将会是一个二维数组,这个数组里充斥着tiles(地图的小碎块),而你的冒险将在这个地图里进行。我们将在文件的最开始定义地图的大小,它并跟屏幕大小完全一致,还要留下一些空间去显示其他的信息。我们将尽量将这个数据设置成可配置的,但是现在随便设一个先吧
MAP_WIDTH = 80
MAP_HEIGHT = 45
下一个问题,地图块的颜色,现在我们有两种地图块,墙面和地板,我们先设置他们在黑暗环境下的颜色,被视线照亮之后的颜色现在还不需要。要注意它们的值在0到255之间,如果你在网上找到了一种以16进制表征的颜色,用计算器将他们进行转换。
color_dark_wall = libtcod.Color(0, 0, 100)
color_dark_ground = libtcod.Color(50, 50, 150)
每个tile包含了怎样的信息呢?我们先易后难,首先设置两个参数,一个确定它能否被通过,一个确定它能否阻碍光线。在这个例子里,我们最好尽早把它们区分开,因为后面我们将会遇到某些tile可以看过去却无法通过,例如地上的裂痕。相信我,马上我们就会给tile添加一大堆的不同变量。
class Tile:
#a tile of the map and its properties
def __init__(self, blocked, block_sight = None):
self.blocked = blocked
#by default, if a tile is blocked, it also blocks sight
if block_sight is None: block_sight = blocked
self.block_sight = block_sight
就像前面提到的,地图是一个二维数组。最简单的实现就是生成一个行的列表,每一行都拥有一条tiles,因为Python没有原生的多维数组(难道不应该import numpy?搞个矩阵什么的更方便???)
我们将使用一个简洁的小技巧,list comprehension(列表推导)。普通人(在c++的世界里)创建一个list的方法是创建一个空的列表,然后一个一个的增加元素
但是在Python里,语法[element for index in range],index和range和你使用for语句是一样的,将会返回一个元素的列表。如果你第一次接触这个概念,可能会有点懵逼,不要紧,花几分钟时间理解一下吧。
使用两个列表推导,一个对应行数,而另一个对应包含tiles的一行,我们一下子就把整个地图生成了出来!下面的连接里有一堆相关的例子,以及一个简洁列表生成的例子,就像我们用在地图上那样。好吧,为了这样一小段代码,我说的废话够多了(人贵有自知之明啊!)
def make_map():
global map
#fill map with "unblocked" tiles
map = [[ Tile(False)
for y in range(MAP_HEIGHT) ]
for x in range(MAP_WIDTH) ]
访问地图非常简单,用map[x][y]足矣。这里我们在地图上增加两根柱子(不可通过的tiles)来进行示范,并简单的测试一下
map[30][22].blocked = True
map[30][22].block_sight = True
map[50][22].blocked = True
map[50][22].block_sight = True
这里有一个重要的建议:在列表推导中,请务必确保在生成列表物体时调用物体的构造函数,就像我们之前用的Tile(False)一样。如果我们一开始调用floor = Tile(False) 生成一个元素,后面又直接使用floor,我们就会得到一个奇怪的结果,产生一大堆奇怪的bug,究其原因是因为这样我们生成的其实是一个指向最开始生成的floor的指针的列表而不是它的一份拷贝,每个元素指向的其实都是同一个内存区域,你改变任意一个指针的值就会导致其指向的内存区域被改变,所以你在生成每一个元素的时候都要确保构造函数被调用以确保确实生成了一个新的实例(你看看,虽然不用c++,但是指针的概念还是绕不过去的)。这是Python初学者很容易犯的一个错误。
不用担心,我们距离一个可玩的版本越来越近了!(你是怎么定义“可玩”的???)因为我们不仅要绘制地图还要绘制物体了,我们需要把绘制放到一个新的函数里而不是直接放在主循环里
(这种循序渐进的封装思想很值得借鉴和学习,不要等到代码一大坨了再考虑重构,而要在增加新的功能的时候就考虑小幅重构,这种顺手就重构的程序员真是单位的宝没错我说的就是我自己 ヽ(爱´∀‘爱)ノ)
将物体绘制的代码放到新建立的函数render_all里,并在主循环里调用之
def render_all():
#draw all objects in the list
for object in objects:
object.draw()
还是在这个函数里,我们遍历所有的tiles并将他们绘制到屏幕上,用的背景颜色为控制台字符背景色,这样地图就被绘制出来了
for y in range(MAP_HEIGHT):
for x in range(MAP_WIDTH):
wall = map[x][y].block_sight
if wall:
libtcod.console_set_char_background(con, x, y, color_dark_wall, libtcod.BKGND_SET )
else:
libtcod.console_set_char_background(con, x, y, color_dark_ground, libtcod.BKGND_SET )
在render_all()最后调用console_blit作为结尾,因为它是渲染函数的一部分。
这里有一个关于定制的小贴士:如果你想要搞出更加古早的效果,即使用'.','#'这种字符来表示地图(如果你玩过最基本版的nethack应该知道这是啥意思),请查阅英文原文里的链接
好了,别忘了在主循环之前调用make_map(),让它能够在游戏运行之前运行。你应该可以看到两根柱子,并且可以在地图上溜达了!
但是等一等,这里还有一些问题。虽然柱子显示出来了,但是玩家却可以穿过柱子(波斯王子?)。这个问题很好解决,只需要在物体运动之前运行如下函数即可(起始就是做个碰撞检测)
英文原文里有全部源代码的链接
未知后事如何,且听下文分解
if not map[self.x + dx][self.y + dy].blocked:
The object and the map
Off-screen consoles
There's one small thing we need to get out of the way before we can continue. Notice that the drawing functions we called (console_set_default_foreground and console_put_char) have their first argument set to 0, meaning that they draw on the root console. This is the buffer that is shown directly on screen.
It can be useful, however, to have other buffers to store the results of drawing functions without automatically showing them to the player. This is akin to drawing surfaces or buffers in other graphics libraries; but instead of pixels they store characters and colors so you can modify them at will. Some uses of these off-screen consoles include semi-transparency or fading effects and composing GUI panels by blitting them to different portions of the root console. We're going to draw on an off-screen console from now on. The main reason is that not doing so would mean that later on you can't compose different GUI panels as easily, or add certain effects.
First, create a new off-screen console, which for now will occupy the whole screen, but this can be changed later. We'll use a simple name like con because it will be used a lot! You can put this in the initialization, right after console_init_root.
con = libtcod.console_new(SCREEN_WIDTH, SCREEN_HEIGHT)
Now change the first argument of console_put_char (there are 2 calls to this function) and console_set_default_foreground, from 0 to con. They're now drawing on the new console.
Finally, just before console_flush(), blit the contents of the new console to the root console, to display them. The parameters may look a bit mysterious at first, but they're just saying that the source rectangle has its top-left corner at coordinates (0, 0) and is the same size as the screen; the destination coordinates are (0, 0) as well. Check the documentation on the console_blit function for more details.
libtcod.console_blit(con, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 0)
That was a lot of talk for so little code and no visible change! The next section will surely be much more interesting, as we'll introduce our first dummy NPC among other things. Remember to check back the above documentation page when you get to coding your GUI.
Generalizing
Now that we have the @ walking around, it would be a good idea to step back and think a bit about the design. Having variables for the player's coordinates is easy, but it can quickly get out of control when you're defining things such as HP, bonuses, and inventory. We're going to take the opportunity to generalize a bit.
Now, there can be such a thing as over-generalization, but we'll try not to fall in that trap. What we're going to do is define the player as a game Object, by creating that class. It will hold all position and display information (character and color). The neat thing is that the player will just be one instance of the Object class -- it's general enough that you can re-use it to define items on the floor, monsters, doors, stairs; anything representable by a character on the screen. Here's the class, with the initialization, and three common methods move, draw and clear. The code for drawing and erasing is the same as the one we used for the player earlier.
class Object: #this is a generic object: the player, a monster, an item, the stairs... #it's always represented by a character on screen. def __init__(self, x, y, char, color): self.x = x self.y = y self.char = char self.color = color def move(self, dx, dy): #move by the given amount self.x += dx self.y += dy def draw(self): #set the color and then draw the character that represents this object at its positionlibtcod.console_set_default_foreground(con, self.color) libtcod.console_put_char(con, self.x, self.y, self.char, libtcod.BKGND_NONE) def clear(self): #erase the character that represents this object libtcod.console_put_char(con, self.x, self.y, ' ', libtcod.BKGND_NONE)
Please note that there are two underscores on each side of __init__
!
Now, before the main loop, instead of just setting the player's coordinates, we create it as an actual Object. We also add it to a list, that will hold all objects that are in the game. While we're at it we'll add a yellow @ that represents a non-playing character, like in an RPG, just to test it out!
player = Object(SCREEN_WIDTH/2, SCREEN_HEIGHT/2, '@', libtcod.white) npc = Object(SCREEN_WIDTH/2 - 5, SCREEN_HEIGHT/2, '@', libtcod.yellow) objects = [npc, player]
We'll have to make a couple of changes now. First, in the handle_keys function, instead of dealing directly with the player's coordinates, we can use the player's move method with the appropriate displacement. Later this will come in handy as it can automatically check if the player (or another object) is about to hit a wall. Secondly, the main loop will now clear all objects like this:
for object in objects: object.clear()
And draw them like this:
for object in objects: object.draw()
Ok, that's all! A fully generic object system. Later, this class can be modified to have all the special info that items, monsters and all that will require. But we can add that as we go along!
Here's the code so far.
The Map
Just like how you generalized the concept of the player object, you'll now do the same thing with the dungeon map. Your map will be a two-dimensional array of tiles where all your dungeon adventuring will happen. We'll start by defining its size at the top of the file. It's not quite the same size as the screen, to leave some space for a panel to show up later (where you can show stats and all). We'll try to make this as configurable as possible, this should suffice for now!
MAP_WIDTH = 80 MAP_HEIGHT = 45
Next, the tile colors. For now there are two tile types -- wall and ground. These will be their "dark" colors, which you'll see when they're not in FOV; their "lit" counterparts are not needed right now. Notice that their values are between 0 and 255, if you found colors on the web in hexadecimal format you'll have to convert them with a calculator. Finding RGB values by educated trial-and-error works at first but with time you'll have a set of colors that don't mix together very well (contrast and tone as perceived by the human eye, and all that stuff), so it's usually better to look at a chart of colors; just search for "html colors" and use one you like.
color_dark_wall = libtcod.Color(0, 0, 100) color_dark_ground = libtcod.Color(50, 50, 150)
What sort of info will each tile hold? We'll start simple, with two values that say whether a tile is passable or not, and whether it blocks sight. In this case, it's better to seperate them early, so later you can have see-through but unpassable tiles such as chasms, or passable tiles that block sight for secret passages. They'll be defined in a Tile class, that we'll add to as we go. Believe me, this class will quickly grow to have about a dozen different values for each tile!
class Tile: #a tile of the map and its properties def __init__(self, blocked, block_sight = None): self.blocked = blocked #by default, if a tile is blocked, it also blocks sight ifblock_sight is None: block_sight = blocked self.block_sight = block_sight
As promised, the map is a two-dimensional array of tiles. The easiest way to do that is to have a list of rows, each row itself being a list of tiles, since there are no native multi-dimensional arrays in Python.
We'll build it using a neat trick, list comprehensions. See, the usual way to build lists (from C++ land) is to create an empty list, then iterate with a for and add elements gradually.
But in Python, the syntax [element for index in range], where index and range are the same as what you'd use in a for, will return a list of elements. Just take a second to understand that sentence if you never worked with that before.
With two of those, one for rows and another for tiles in each row, we create the map in one fell swoop! The linked page has a ton of examples on that, and also an example of nested list comprehensions like we're using for the map. Well, that's an awful lot of words for such a tiny piece of code!
def make_map(): global map #fill map with "unblocked" tiles map = [[ Tile(False) for y inrange(MAP_HEIGHT) ] for x in range(MAP_WIDTH) ]
Accessing the tiles is as easy as map[x][y]. Here we add two pillars (blocked tiles) to demonstrate that, and provide a simple test.
map[30][22].blocked = True map[30][22].block_sight = True map[50][22].blocked = True map[50][22].block_sight = True
One very important piece of advice: in list comprehensions, always call the constructor of the objects you're creating, like we did with Tile(False). If we had tried to first create an unblocked tile like floor = Tile(False) and then in the list comprehension just refer to that same floor, we'd get all sorts of weird bugs! This is a common rookie (and veteran!) mistake in Python.
That's because all elements in the list would point to the exact same Tile (the one you defined as floor), not copies of it. Changing a property of one element would appear to change it in other elements as well! Calling the constructor for every element ensures that each is a distinct instance.
Don't worry, we're already close to a playable version! Since we need to draw both the objects and the map, it now makes sense to put them all under a new function instead of directly in the main loop. Take the object rendering code to a new render_all function, and in its place (in the main loop) call render_all().
def render_all(): #draw all objects in the list for object in objects: object.draw()
Still in the same function, we can now go through all the tiles and draw them to the screen, with the background color of a console character representing the corresponding tile. This will render the map.
for y in range(MAP_HEIGHT): for x in range(MAP_WIDTH): wall = map[x][y].block_sight ifwall: libtcod.console_set_char_background(con, x, y, color_dark_wall, libtcod.BKGND_SET ) else: libtcod.console_set_char_background(con, x, y, color_dark_ground, libtcod.BKGND_SET )
Also, move the console_blit call to the end of render_all() since it's part of the rendering code, just to keep things tidy.
A little note on customization: if you want a more old-school look, using characters like ' .' and ' # ' to represent floor and wall tiles, check out this Extra.
Ok! Don't forget to call make_map() before the main loop, to set it up before the game begins. You should be able to see the two pillars and walk around the map now!
But wait, there's something wrong. The pillars show up, but the player can walk over them. That's easy to fix though, add this check to the beginning of the Object 's movemethod:
if not map[self.x + dx][self.y + dy].blocked:
Here's the code so far.