Pygame——创建游戏地图

记得以前有几款很经典的游戏(红色警戒,命令与征服,英雄无敌),不小心暴漏了自己的年龄(这几款游戏都有年头了),因为知道并玩过这几款游戏的人可能还记得,里面有一个功能,就是自己编辑地图,在自己编辑的地图上玩游戏。
当时觉得这个功能很炫酷,因为通常游戏场景都是游戏制作者给出的,玩家没得选。最近学习Python,觉得Pygame可以很轻松就实现这个功能,于是自己实现了一下。供感兴趣的朋友们参考,批评指正。
废话不多说,说正事。
既然是编辑地图,必须要有背景和地图上展示的元素,以及元素在地图上的位置等信息。
打码开始:
地图元素类:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Elements(Sprite):
    def __init__(self, image, position, layer, layerGroup, *groups):
        self._layer = layer
        self.groups = layerGroup, *groups
        super().__init__(self.groups)

        # image 包含了路径 "assets/images/"...
        self.imageFile = image      # 记录图像路径和文件名
        self.image = pygame.image.load(image).convert_alpha()
        self.rect = Rect(position, self.image.get_size())

    def get_layer(self):
        return self._layer

地图类如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Map:
    def __init__(self):
        self.bg = None
        self.elements = []

    def re_init(self):
        if self.bg:
            self.bg.kill()

        self.bg = None

        for element in self.elements:
            element.kill()

        self.elements = []

    def change_bg(self, newBg):
        if self.bg:
            self.bg.kill()

        self.bg = newBg

    def add_element_into_map(self, element):
        self.elements.append(element)

实际上地图就是一个地图元素(包含背景地图和元素的容器)。因此非常简单。那么如何实现编辑地图呢?下面给出地图编辑类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class MapMaker(Sprite):
    def __init__(self, image, layer, layerGroup):
        self._layer = layer
        super().__init__(layerGroup)

        self.layerGroup = layerGroup

        self.image = pygame.image.load(image).convert()
        self.rect = Rect((0, 0), SCREEN_SIZE)

        # 保存从素材库获取的背景地图素材和地图元素素材
        self.bgElements = []
        self.elements = []
       
        # 从素材库中读取素材
        self.get_elements_from_files()

        # 添加背景和地图元素后的地图 并给一个缺省的背景
        self.map = Map()
        self.map.bg = Elements(DEFAUT_MAP_BACKGROUP, (0, 0), BACKGROUND_LAYER, self.layerGroup)

        # 处理事件时使用
        self.elementSelected = None  # 将要操作的地图元素
        self.relPos = (0, 0)    # 记录在选择区鼠标位置与被选择元素rect.topleft 的相对位置

        self.selectedList = []  # 从地图区选择的地图元素
        self.relPosList = []    # 修改地图去元素的位置时记录rect.topleft 和鼠标位置的相对值

        # 是否加载过地图,保存加载地图的信息用于覆盖或者新建地图
        self.currentOpenedMap = None

        # 增加命令按钮
        self.buttonList = Group()
        self.init_button_list()

从 mapMaker类的初始化函数中可以看到定义了一些地图操作时需要的变量,另外定义了几个button。Button类在我的以前的文章中已经给出过,基本上没有什么修改,小小调整一下就可以直接拿过来用。这里就不重复了。
下面是button初始化和button的回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
    def init_button_list(self):
        self.buttonList.append(Button(NEW_MAP_BUTTON, "New Map", FONT_NAME, FONT_SIZE, FONT_COLOR, MSG_LAYER,
                                    (BOTTOM_COLOR, ON_HOVER_COLOR, ON_CLICK_COLOR),
                                    self.layerGroup, None, None, None, self.new_map_callback))
        self.buttonList.append(Button(LOAD_MAP_BUTTON, "Load Map", FONT_NAME, FONT_SIZE, FONT_COLOR, MSG_LAYER,
                                    (BOTTOM_COLOR, ON_HOVER_COLOR, ON_CLICK_COLOR),
                                    self.layerGroup, None, None, None, self.load_map))
        self.buttonList.append(Button(SAVE_MAP_BUTTON, "Save Map", FONT_NAME, FONT_SIZE, FONT_COLOR, MSG_LAYER,
                                    (BOTTOM_COLOR, ON_HOVER_COLOR, ON_CLICK_COLOR),
                                    self.layerGroup, None, None, None, self.save_map))
        # 暂时不实现在此地图上玩游戏的功能,后续的文章中会继续实现。
        self.buttonList.append(Button(START_GAME_BUTTON, "Start Game", FONT_NAME, FONT_SIZE, FONT_COLOR, MSG_LAYER,
                                    (BOTTOM_COLOR, ON_HOVER_COLOR, ON_CLICK_COLOR),
                                    self.layerGroup, None, None, None, FONT_COLOR, None))

    def new_map_callback(self):
        self.map.re_init()
        self.map.bg = Elements(DEFAUT_MAP_BACKGROUP, (0, 0), BACKGROUND_LAYER, self.layerGroup)

        self.currentOpenedMap = None

        pygame.display.update()

    def save_map(self):
        dialog = win32ui.CreateFileDialog(0)  # 0 为保存文件对话框
        dialog.SetOFNInitialDir(IMAGE_PATH + "maps/")
        flag = dialog.DoModal()

        if flag != 1:
            return

        mapFileName = dialog.GetPathName()
        with open(mapFileName, 'w+') as mapFile:
            # 第一行保存背景地图。如果选择地图,那么保存给定的地图,如果没有选定地图,给一个缺省地图
            bgInfo = self.map.bg.imageFile + ":" + "(0, 0)" + ":" + str(self.map.bg.get_layer())
            mapFile.write(bgInfo)
            mapFile.write("\r")
            # 后续保存地图上的山、树、等其他元素
            for element in self.map.elements:
                info = element.imageFile + ":" + str(element.rect.topleft) + ":" + str(element.get_layer())
                mapFile.write(info)
                mapFile.write("\r")

    # 从保存的地图文件中加载信息到 self.map中
    def load_map(self):
        dialog = win32ui.CreateFileDialog(1)        # 1 为选择文件对话框
        dialog.SetOFNInitialDir(IMAGE_PATH + "maps/")
        flag = dialog.DoModal()

        if flag != 1:
            return

        mapFile = dialog.GetPathName()
        if mapFile:
            self.map.re_init()
            with open(mapFile, 'r') as elements:
                elementList = elements.readlines()

                # 第一行保存的是地图背景
                imageFile, position, layer = elementList[0].split(":")
                self.map.bg = Elements(imageFile, eval(position), int(layer), self.layerGroup)
                elementList.pop(0)

                # 后面是地图中的元素
                for element in elementList:
                    imageFile, position, layer = element.split(':')
                    self.map.elements.append(Elements(imageFile, eval(position), int(layer), self.layerGroup))

从素材库中读取地图元素方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    # 从素材库(文件目录下)读取素材信息到 self.bgElements 和 self.elements[]
    def get_elements_from_files(self):
        element = None

        # 地图元素显示位置
        bgPosition = BG_POSITION
        elementPos = ELEMENTS_POSITION
        for root, dirs, files in os.walk(IMAGE_PATH + 'mapelements/'):
            for file in files:
                if root[-2:] == 'bg':
                    element = Elements(IMAGE_PATH + 'mapelements/bg/' + file, bgPosition, BACKGROUND_LAYER,
                                       self.layerGroup)
                    self.bgElements.append(element)
                    bgPosition = (bgPosition[0] + element.rect.width, bgPosition[1])
                else:
                    if root[-1] == '1':
                        element = Elements(IMAGE_PATH + 'mapelements/layer1/' + file, elementPos, LAYER_1,
                                           self.layerGroup)

                    if root[-1] == '2':
                        element = Elements(IMAGE_PATH + 'mapelements/layer2/' + file, elementPos, LAYER_2,
                                           self.layerGroup)

                    self.elements.append(element)
                    elementPos = (elementPos[0], elementPos[1] + element.rect.height)

什么的代码有点摸不着头脑? 看一下文件组织结构:
在这里插入图片描述
明白了吗?mapelements目录下分类存储了bg,layer1,layer2等信息,你可以根据自己的需要存放地图元素。总之,显示在最上面的layer 要设置的大,在地下可以被部分或全部覆盖的元素layer 设置要小。
好了,mapMaker类的基本功能就差不多了。这里展示一下我做的一个演示。
在这里插入图片描述
格栅区是地图编辑区,下面是背景地图素材,右边是地图元素素材,右下角是四个功能按钮。
为什么这里展示的是格栅背景图呢?因为游戏钟有会有敌人和英雄,通常英雄是由玩家操纵的,而敌人是电脑自己操作的。不管是敌人还是英雄,都会运动(从一个地方移动到另一个地方)。那么英雄或者敌人怎么能从当前的地方到达目的地呢?想一想,地图中的山川、河流、房子、树木,这些对于运动物体来说都是障碍物,那么运动体在寻路的过程中必须要避开障碍寻找最佳的路径到达目的地。后续的文章中将会讨论并实现这个过程,本文只讨论地图编辑相关的实现。
上面的代码虽然提供了一些操作,那么元素怎么从元素区放到地图上的呢?怎么更新背景呢?地图上的元素需要移动或删除怎么弄呢?这些都涉及到对事件的响应和处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
    # 检测相关事件,包括命令按钮, 地图元素操作等
    def check_event(self, event):
        for button in self.buttonList:
            button.check_event(event)

        if event.type == MOUSEBUTTONDOWN and pygame.mouse.get_pressed()[0]:
            downPos = pygame.mouse.get_pos()
            # 从地图背景元素区选择元素
            for element in self.bgElements:
                if element.rect.collidepoint(downPos):
                    self.elementSelected = Elements(element.imageFile, element.rect.topleft, element.get_layer(),
                                                    self.layerGroup)
                    self.relPos = (element.rect.left - downPos[0], element.rect.top - downPos[1])

            # 从地图元素区选择元素
            for element in self.elements:
                if element.rect.collidepoint(downPos):
                    self.elementSelected = Elements(element.imageFile, element.rect.topleft, element.get_layer(),
                                                    self.layerGroup)
                    self.relPos = (element.rect.left - downPos[0], element.rect.top - downPos[1])

            # 在地图区选择已经放在地图山的元素,有些元素可能重叠,所以点击一个位置可能会选择到多个元素
            for element in self.map.elements:
                if element.rect.collidepoint(downPos):
                    self.selectedList.append(element)
                    self.relPosList.append((element.rect.left - downPos[0], element.rect.top - downPos[1]))

        # 拖动元素时随鼠标位置显示
        if event.type == MOUSEMOTION:
            relativeMove = pygame.mouse.get_rel()
            if self.elementSelected:
                self.elementSelected.rect.left += relativeMove[0]
                self.elementSelected.rect.top += relativeMove[1]

            for element in self.selectedList:
                element.rect.left += relativeMove[0]
                element.rect.top += relativeMove[1]

        if event.type == MOUSEBUTTONUP:
            upPos = pygame.mouse.get_pos()

            # 将从元素区选择的地图元素添加到地图上
            if self.elementSelected and self.map.bg.rect.collidepoint(upPos):
                # 如果是背景
                if self.elementSelected.imageFile.find("/mapelements/bg/") != -1:
                    self.map.change_bg(Elements(self.elementSelected.imageFile.replace("mapelements/bg", "bg"),
                                                (0, 0), BACKGROUND_LAYER, self.layerGroup))

                    self.elementSelected.kill()
                # 如果是地图元素
                else:
                    self.elementSelected.rect.left = (upPos[0] + self.relPos[0]) // GRID_WIDTH * GRID_WIDTH
                    self.elementSelected.rect.top = (upPos[1] + self.relPos[1]) // GRID_HEIGHT * GRID_HEIGHT

                    self.map.add_element_into_map(self.elementSelected)
                    self.relPos = (0, 0)

                self.elementSelected = None
                # self.map.output_map_sprites()
            # 如果将选择的元素区的背景或者地图元素放在非地图区,相当于放弃刚才的选择
            else:
                if self.elementSelected:
                    self.elementSelected.kill()
                    self.elementSelected = None

            # 修改地图区的元素位置或者删除选中的元素
            if self.selectedList:
                # 如果是修改选择元素的位置
                if self.map.bg.rect.collidepoint(upPos):
                    for i in range(0, self.selectedList.__len__() - 1):
                        self.selectedList[i].rect.left = (upPos[0] + self.relPosList[i][0]) // GRID_WIDTH * GRID_WIDTH
                        self.selectedList[i].rect.top = (upPos[1] + self.relPosList[i][1]) // GRID_HEIGHT * GRID_HEIGHT
                # 如果删除选中的地图元素
                else:
                    for sprite in self.selectedList:
                        sprite.kill()

                # 清空列表
                self.selectedList.clear()
                self.relPosList.clear()

什么有一段代码:

1
2
self.map.change_bg(Elements(self.elementSelected.imageFile.replace("mapelements/bg", "bg"),
                                                (0, 0), BACKGROUND_LAYER, self.layerGroup))

替换图片路径说明一下,因为背景地图元素只是存储的一个缩略图,真是的大背景地图存储在这里:
在这里插入图片描述
应该明白了吧。当然你也可以在展示地图元素时使用pygame的transform功能,这样只需要保存真实背景图片即可。
好了全部的类都已经完成,进入测试代码了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def main():
    pygame.init()

    screen = pygame.display.set_mode(SCREEN_SIZE, 0, 32)

    layerGroup = LayeredUpdates()

    mapMaker = MapMaker(DEFAUT_MAP_BACKGROUP, BACKGROUND_LAYER, layerGroup)

    screen.fill((0, 0, 0))
    layerGroup.draw(screen)
    pygame.display.update()

    fpsClock = pygame.time.Clock()

    while True:
        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit()

            mapMaker.check_event(event)

        screen.fill((0, 0, 0))
        layerGroup.draw(screen)

        fpsClock.tick(30)

        pygame.display.update()


if __name__ == '__main__':
    main()

所有的代码都在上面了,部分常量的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
IMAGE_PATH = "assets/images/"

SCREEN_SIZE = (1300, 900)

GRID_X_QTY = 100
GRID_Y_QTY = 80
GRID_WIDTH = 10
GRID_HEIGHT = 10
OBSTRUCT = 0

BUTTON_SIZE = (90, 40)
NEW_MAP_BUTTON = (1100, 805)
LOAD_MAP_BUTTON = (1200, 805)
SAVE_MAP_BUTTON = (1100, 855)
START_GAME_BUTTON = (1200, 855)
BG_POSITION = (0, 805)
ELEMENTS_POSITION = (1200, 0)

FONT_SIZE = 18

BACKGROUND_LAYER = 1
LAYER_1 = 10
LAYER_2 = 20
MSG_LAYER = 50

DEFAUT_MAP_BACKGROUP = "assets/images/bg/bg5.png"

What?就这么简单?是的,所有测试代码都在这里,需要实现的功能和操作都封装在类里,因此测试代码只需要在while循环中加入事件检测就可以了。
感兴趣的朋友可以试一试。
另外,保存地图后文件演示如下:

1
2
3
4
5
6
7
8
9
10
11
12
assets/images/bg/bg5.png:(0, 0):0
assets/images/mapelements/layer1/mountain3.png:(140, 300):10
assets/images/mapelements/layer1/mountain4.png:(360, 140):10
assets/images/mapelements/layer1/mountain3.png:(880, 160):10
assets/images/mapelements/layer1/mountain3.png:(940, 160):10
assets/images/mapelements/layer1/mountain1.png:(440, 140):10
assets/images/mapelements/layer1/tower.png:(380, 280):10
assets/images/mapelements/layer1/tower.png:(1000, 300):10
assets/images/mapelements/layer1/mountain4.png:(300, 640):10
assets/images/mapelements/layer1/mountain4.png:(880, 680):10
assets/images/mapelements/layer1/mountain4.png:(820, 680):10
assets/images/mapelements/layer1/mountain2.png:(360, 660):10