跳转至

第七章:五子棋游戏编程

本章我们将学习另一种经典游戏编程,五子棋游戏。在第二章的时候,我们介绍了如何编写井字棋。五子棋游戏可以看作是井字棋的升级版,只不过会有漂亮的窗口来显示棋子的位置,而不再是使用简陋的终端。

一、五子棋游戏介绍

游戏规则

五子棋是一种桌面游戏,两名玩家各自使用黑色或白色的棋子,轮流在一个棋盘上落子。只要任一名玩家的棋子连成一条线,或者说五子连珠,即获得游戏胜利。游戏的精妙之处在于,玩家不仅要考虑让自己的棋子连成线,还要阻止对方的棋子连成线。和前面几章游戏最大的区别在于,玩家会有较长的思考时间,也就是说游戏并非每帧需要渲染画面。游戏画面显示如下。

ch07-01

游戏资源

本游戏只用到一种外部游戏资源,就是字体文件。除此之外,所有的游戏元素都是通过PyGame绘制出来的。这些游戏元素包括了棋盘、交叉线、棋子等重要角色,还包括和玩家交互的按键,以及显示文字。

二、游戏功能和程序设计

游戏功能:

本游戏要实现的主要功能包括:

  • 棋盘显示功能,显示纵横线条以及玩家的棋子
  • 棋盘落子功能,当玩家鼠标点击某个棋盘位置时,需要在最近交叉点上显示对应棋子
  • 按键交互功能,设计两个功能按键,分别是用于重启游戏和交换玩家先手顺序
  • 显示信息功能,显示当前应该哪位玩家落子,显示输赢等信息
  • 判断胜负功能,在玩家落子后,需要判断是否出现五子连珠的情况

程序设计

和井字棋的思路类似,我们将整体游戏程序划分成两个大的类,Board类用于保存内部数据,处理游戏逻辑,Game类用于处理游戏界面的展示和交互。五子棋的界面要处理的元素比较多,可以再将界面区域区分为三个部分。第一个最大的部分即是棋盘区域。第二个区域是按键,用于和玩家交互。第三个区域是棋盘下方用于显示游戏信息的区域。简略的类图显示如下。

classDiagram

    Game *-- Board : Composition
    Game *-- Board_Area :Composition
    Game *-- Message_Area : Composition
    Game *-- Button : Composition

    class Game {
    board
    board_area
    message_area
    buttons
    ...
    }

    class Board {
    }

    class Board_Area {
    }

    class Message_Area {
    }

    class Button {
    }

Game类中负责处理游戏核心逻辑,当游戏在进行中时,程序需要接收玩家的输入信息。这个输入信息就是鼠标在不同区域的点击。如果点击在棋盘区域则是落子操作,如果点击在按键区域则是游戏交互操作。当玩家进行落子时,要根据落子的坐标位置,来判断具体的经纬坐标数据,将其保存入Board类中,同时在棋盘区域显示落子。落子后判断是否有玩家胜出。

为了保持游戏的扩展性,我们需要在棋局和棋盘两个方面保持灵活性。在棋局内部逻辑上,能够玩五子或其它子的连珠游戏,棋盘的经纬线数量能随之变化,棋盘区域的大小也要能随之变化。这对于程序实现有更高的要求。

三、代码实现

Board类

本游戏较为复杂,所以分为两个py文件来编写代码。首先是编写负责游戏内部棋局状态的Board类,将其代码保存在board.py文件的Board类中。初始化函数很简单,就是根据参数确定棋盘的纵横经纬线数量和连珠数。

class Board:

    def __init__(self, width, height, n_in_row):
        self.width = int(width)
        self.height = int(height)
        self.n_in_row = int(n_in_row)

reset_board函数用于开始游戏或重启游戏时,清空内部数据。current_player保存当前玩家编号,玩家编号取值为1或2,分别表示执黑子的玩家1和执白子的玩家2。然后用一个列表availables来表示当前棋局可落子的位置,虽然棋盘坐标是二维的,我们在Board类中用一个一维的列表来表示,如果是9乘以9的棋盘,内部数据是以0到80这81个数字来表示。states是一个字典,表示当前棋局状态,即哪些位置被哪个玩家棋子占据。last_move用于保存上一步的落子位置。棋局重启时将其设置为-1值。

    def reset_board(self, start_player=1):
        self.current_player = start_player 
        self.availables = list(range(self.width * self.height))
        self.states = {}
        self.last_move = -1

do_move函数用于执行玩家落子的数据操作。输入的move参数是落子坐标,如果是9乘以9的棋盘,其取值将是在0到80之间的数字。落子时,以move为key,以玩家编号为value,来增加棋局状态states的信息。这样就可以通过states知道,棋盘上哪个玩家棋子占据了哪个位置。同时,将列表availables中的可落子位置删除一个,因为这个位置已经被占据。再转换玩家编号,并将last_move赋值为当前落子位置move。

    def do_move(self, move):
        self.states[move] = self.current_player
        self.availables.remove(move)
        self.current_player = self.current_player % 2 + 1
        self.last_move = move

has_a_winner函数用于判断胜负情况,即判断有没有出现五子连珠。moved是基于棋局状态states做了处理,取出已经被占据的坐标位置。如果在游戏初期,落子个数还不多的时候,是可以直接判断没有出现胜负局面。然后对moved进行遍历。将一维坐标转换为二维坐标。这里有四个if条件判断,也就是横向、纵向、对角等四种连珠形态。以第一个条件判断中的代码为例,以当前落子位置为基础,从states中取出连续n个位置的棋子编号,将取出的数据转成集合类型,再计算集合中的元素个数。如果个数等于1,意味着这连续n个位置的棋子都是同一玩家的,胜利条件达成。其它三个条件判断中的代码逻辑是类似的。

    def has_a_winner(self):
        width = self.width
        height = self.height
        states = self.states
        n = self.n_in_row
        moved = list(states.keys())
        if len(moved) < self.n_in_row + 2:
            return False, -1

        for m in moved:
            h = m // width
            w = m % width
            player = states[m]

            if (w in range(width - n + 1) and
                len(set(states.get(i, -1) for i in range(m, m + n))) == 1):
                return True, player

            if (h in range(height - n + 1) and
                len(set(states.get(i, -1) for i in range(m, m + n * width, width))) == 1):
                return True, player

            if (w in range(width - n + 1) and h in range(height - n + 1) and
                len(set(states.get(i, -1) for i in range(m, m + n * (width + 1), width + 1))) == 1):
                return True, player

            if (w in range(n - 1, width) and h in range(height - n + 1) and
                len(set(states.get(i, -1) for i in range(m, m + n * (width - 1), width - 1))) == 1):
                return True, player

        return False, -1

game_end函数来判断游戏是否结束,结束有两种情况,一种是出现胜利者,另一种是双方已经将棋盘上可以落子的地方全部占据,无子可下,也是游戏结束。

    def game_end(self):
        end, winner = self.has_a_winner()
        if end:
            return True, winner
        elif not len(self.availables):
            return True, -1
        return False, -1

Button类

Button类主要用于两个功能,一个是和玩家的鼠标进行交互,另一个是绘图,在窗口中显示按键。在初始化函数中,定义了按键的颜色,边框矩形的位置和大小,以及按键上显示的文字。

class Button:

    def __init__(self,x,y,width,height,text=''):
        self.color = (245, 245, 245)
        self.rect = Rect(x,y,width,height)
        self.text = text

pressed函数负责判断按键有没有被鼠标点击,和前一章一样,此处调用了collidepoint函数,它负责将边框矩形和鼠标坐标pos一起输入判断,看二者有没有发生重叠。draw函数则是绘制按键的矩形主体和边框部分,然后用函数draw_text来显示文本。draw_text函数是Game类的类函数,我们会在后续介绍如何编写。

    def pressed(self, pos):
        return self.rect.collidepoint(pos)

    def draw(self,surface,textsize):
        pygame.draw.rect(surface, self.color, self.rect)
        pygame.draw.rect(surface, Game.BLACK, self.rect, width=1)
        Game.draw_text(surface,self.text, self.rect.center, textsize)

Board_Area类

Board_Area类负责棋盘区域的显示。初始化函数定义了颜色和基本单位尺寸UnitSize,基本尺寸用于设置棋盘中一个格子需要占据多大的像素空间,可以人工定义不同的基本单位尺寸,以缩放棋盘显示的大小。BoardSize是棋盘纵横经纬线数量。board_lenth用于计算像素单位的棋盘大小,它是上述二者的乘积。最后是用Rect类,定义了棋盘区域的边框矩形大小。

class Board_Area:

    def __init__(self, unitsize, boardsize):
        self.color = (254, 185, 120)
        self.UnitSize = unitsize
        self.BoardSize = boardsize
        self.board_lenth = self.UnitSize * self.BoardSize
        self.rect = Rect(self.UnitSize, self.UnitSize, self.board_lenth, self.board_lenth)

draw函数用于窗口显示棋盘区域,首先是绘制棋盘区域的底色,再使用draw.line函数来绘制棋盘上的纵横经纬线,最后还需要在棋盘边缘部分来显示经纬线的坐标数字。仍然是使用draw_text来完成这个任务。

    def draw(self, surface,textsize):
        pygame.draw.rect(surface, self.color, self.rect)
        for i in range(self.BoardSize):
            start = self.UnitSize * (i + 0.5)
            pygame.draw.line(surface, Game.BLACK, (start + self.UnitSize, self.UnitSize*1.5),
                             (start + self.UnitSize, self.board_lenth + self.UnitSize*0.5))
            pygame.draw.line(surface, Game.BLACK, (self.UnitSize*1.5, start + self.UnitSize),
                             (self.board_lenth + self.UnitSize*0.5, start + self.UnitSize))
            Game.draw_text(surface, 
                            text = self.BoardSize - i - 1, 
                            position = (self.UnitSize / 2,start + self.UnitSize), 
                            text_height = textsize)
            Game.draw_text(surface,
                            text = i, 
                            position = (start + self.UnitSize, self.UnitSize / 2),                           text_height = textsize)

Message_Area类

Message_Area类用来负责在棋盘区域下方,显示游戏信息。初始化函数主要部分是一个边框矩形,draw函数是绘制这个矩形,同时显示文字。

    def __init__(self, x, y, width, height):
        self.rect = Rect(x,y,width,height)

    def draw(self, surface, text, textsize):
        pygame.draw.rect(surface, Game.BackGround, self.rect)
        Game.draw_text(surface,text, self.rect.center, textsize)
        pygame.display.update()

Game类

我们使用Game类来编写游戏核心逻辑。在类属性中保存颜色信息。在初始化函数中,将前面定义的棋局类Board进行初始化,定义基本尺寸大小UnitSize,和对应的文字尺寸大小TextSize,然后用一个字典buttons来存放多个交互按键。init_screen函数用于绘制窗口,restart_game用于绘制棋盘启动游戏。

class Game:

    WHITE = (255, 255, 255)
    BLACK = (0,0,0)
    BackGround = (197, 227, 205)

    def __init__(self, width=9, height=9, n_in_row=5):
        pygame.init()
        self.board = Board(width, height, n_in_row)
        self.BoardSize = width
        self.UnitSize = 45     
        self.TextSize = int(self.UnitSize * 0.3)
        self.buttons = dict()
        self.last_move_player = None 
        self.game_end = False
        self.init_screen()
        self.restart_game()

init_screen函数主要负责窗口surface的定义,然后是将前述的三大块显示区域进行初始化。交互按键区域包括了两个按键,一个负责重启游戏,一个负责交换选手顺序。随后实例化Board_Area类和Message_Area类。

    def init_screen(self):
        self.ScreenSize = (self.BoardSize * self.UnitSize + 2 * self.UnitSize,
                           self.BoardSize * self.UnitSize + 3 * self.UnitSize)
        self.surface = pygame.display.set_mode(self.ScreenSize)
        pygame.display.set_caption('Gomoku')
        self.buttons['RestartGame'] = Button(0, self.ScreenSize[1] - self.UnitSize, 
                                              self.UnitSize*3,                                                                            self.UnitSize,
                                              text = "再战江湖")
        self.buttons['SwitchPlayer'] = Button(self.ScreenSize[0] - self.UnitSize*3,                                                        self.ScreenSize[1] - self.UnitSize, 
                                               self.UnitSize*3
                                               self.UnitSize,
                                               text = "交换先手")
        self.board_area = Board_Area(self.UnitSize, self.BoardSize)
        self.message_area = Message_Area(0, self.ScreenSize[1]-self.UnitSize*2, 
                                          self.ScreenSize[0], self.UnitSize)

restart_game函数是当游戏启动时调用,主要任务是绘制游戏开局时静态信息,last_move_player变量用于保存当前一手落子信息。

    def restart_game(self): 
        self.draw_static()
        self.last_move_player = None

draw_static函数,负责绘制两部分偏静态的区域内容,即棋盘和按键。先定义游戏窗口的背景底色,再分别调用board_area的draw函数,和button的draw方法。最后进行窗口刷新,这样完成了开局后的静态内容绘制。

    def draw_static(self):
        self.surface.fill(self.BackGround)
        self.board_area.draw(self.surface, self.TextSize)
        for _ , button in self.buttons.items():
            button.draw(self.surface,self.TextSize)
        pygame.display.update()

在每一次落子时,都需要在窗口绘制最新的棋子。render_step函数负责这个任务。为了和棋盘上其它棋子区别开,我们对于最新落子会增加一个十字线,让玩家知道最新落子的位置。所以会基于last_move_player来判断。将之前的落子显示成一个普通落子,而最新落子上需要增加十字线。这里调用的绘图函数是draw_pieces。

    def render_step(self, move):
        for event in pygame.event.get():
            if event.type == QUIT:
                exit()
        if self.last_move_player:
            self.draw_pieces(self.last_move_player[0], self.last_move_player[1], False)
        self.draw_pieces(move, self.board.current_player, True)
        self.last_move_player = move, self.board.current_player
        pygame.display.update()

上面代码的核心函数draw_pieces是如下实现的,先将落子信息move转换为二维坐标,将坐标信息转换为窗口上以像素为单位的坐标pos。然后用craw.circle函数来绘制一个圆形,表示棋子。如果需要显示最新棋子,还需要绘制一个十字线,即两条直线。

    def draw_pieces(self, move, player, last_step=False):
        x, y = self.move_2_loc(move)
        pos = [int(self.UnitSize * 1.5 + x * self.UnitSize), 
                int(self.UnitSize * 1.5 + (self.BoardSize - y - 1) * self.UnitSize)]
        color = [self.BLACK, self.WHITE][player-1]
        pygame.draw.circle(self.surface, color, pos, int(self.UnitSize * 0.45))
        if last_step:
            color = [self.WHITE, self.BLACK][player-1]
            start_p1 = pos[0] - self.UnitSize * 0.3, pos[1]
            end_p1 = pos[0] + self.UnitSize * 0.3, pos[1]
            pygame.draw.line(self.surface, color, start_p1, end_p1)
            start_p2 = pos[0], pos[1] - self.UnitSize * 0.3
            end_p2 = pos[0], pos[1] + self.UnitSize * 0.3
            pygame.draw.line(self.surface, color, start_p2, end_p2)

还需要函数来负责对落子坐标进行转换,分别是从一维形式转换为二维坐标的move_2_loc函数,以及从二维坐标转换为一维形式的loc_2_move函数。

    def move_2_loc(self, move):
        return move % self.BoardSize, move // self.BoardSize

    def loc_2_move(self, loc):
        return int(loc[0] + loc[1] * self.BoardSize)

get_input函数用于获取玩家的输入,Output是一个带名字的元组,用于保存在不同输入下的信息。如果玩家点击了右上角,则返回quit字符串。如果有鼠标点击事件,则获取鼠标坐标mouse_pos,将这个坐标放入各个按键进行遍历,看是哪一个按键被点击了,返回点击按键的名字。如果是鼠标点击中了棋盘区域,则将坐标转换为经纬线坐标,再转换为一维格式,返回落子信息。整个返回结果都包在一个Output元组中。

    def get_input(self):
        while True:
            for event in pygame.event.get():
                if event.type == QUIT:
                    return Output('quit',None)

                if event.type == MOUSEBUTTONDOWN: 
                    if event.button == 1:
                        mouse_pos = event.pos

                        for name, button in self.buttons.items():
                            if button.pressed(mouse_pos):
                                return  Output(name, None)

                        if self.board_area.rect.collidepoint(mouse_pos):
                            x = (mouse_pos[0] - self.UnitSize)//self.UnitSize
                            y = self.BoardSize - (mouse_pos[1] - self.UnitSize)//self.UnitSize - 1
                            move = self.loc_2_move((x, y))
                            if move in self.board.availables:
                                return  Output('move',move)

draw_text函数是用于文字的绘制。它是一个静态方法,因为它的输入参数不依赖于类中的属性,函数中先加载字体资源,再根据位置参数,绘制出对应的文本内容。

    @staticmethod
    def draw_text(surface, text, position, text_height=25, 
                  font_color=(0, 0, 0), backgroud_color=None, angle=0):
        font = pygame.font.Font('WenQuan.ttf',int(text_height))
        text = font.render(str(text), True, font_color, backgroud_color)
        text = pygame.transform.rotate(text, angle)
        text_rect = text.get_rect()
        text_rect.center = position
        surface.blit(text, text_rect)

最终,通过编写play_human函数来组装游戏主循环,如果游戏还在进行中,则在信息显示区域,显示请某位玩家落子。然后获取玩家输入,如果输入信息为quit则退出循环,如果是重启信息按键被点击,则重置棋局,重新开始一局。如果交换选手按键被点击,则将玩家顺序调整,重新开始游戏。如果玩家输入的是落子信息,则根据落子坐标显示在窗口中,并在board对象调用do_move函数处理落子操作。再根据当前状态,判断游戏是否结束。

    def play_human(self, start_player=1):
        self.board.reset_board(start_player)

        while True:
            if not self.game_end:
                print('current_player', self.board.current_player)
                text = "请玩家{x}落子".format(x=self.board.current_player)
                self.message_area.draw(self.surface,text,self.TextSize)

            user_input = self.get_input()

            if user_input.action == 'quit':
                break

            if user_input.action == 'RestartGame':
                self.game_end = False
                self.board.reset_board(start_player)
                self.restart_game()
                continue

            if user_input.action == 'SwitchPlayer':
                self.game_end = False
                start_player = start_player % 2 + 1
                self.board.reset_board(start_player)
                self.restart_game()
                continue

            if user_input.action == 'move' and not self.game_end:
                move = user_input.value
                self.render_step(move)
                self.board.do_move(move)
                self.game_end, winner = self.board.game_end()
                if self.game_end:
                    if winner != -1:
                        print("Game end. Winner is player", winner)
                        text = "玩家{x}胜利".format(x=winner)
                        self.message_area.draw(self.surface,text,self.TextSize)
                    else:
                        text =  "二位旗鼓相当!"
                        self.message_area.draw(self.surface,text,self.TextSize)

        pygame.quit()

在文件最后的main主入口编写运行代码,此时将棋盘大小设置为9,即9横9纵的经纬线数量,5即是设置为五子连珠。黑子先行。然后初始化游戏,开始游戏。你可以和你的朋友一起试着玩一下,看谁更厉害吧。

if __name__ == '__main__':
    board_size = 9
    n = 5
    start_player = 1
    game = Game(width=board_size, height=board_size, n_in_row=n)
    game.play_human(start_player)

本章小结

相对于之前的井字棋游戏,本章编写的五子棋游戏相对来讲更为复杂。复杂的部分主要是界面显示。其实内部处理逻辑相对容易。我们将内部处理逻辑和外部界面显示两大模块分开。Board类处理内部处理逻辑,而Game类主要处理界面显示。

此游戏编写的难点在于游戏界面中要显示和交互的元素较多,因此需要将不同的元素进行归类处理。代码中我们将这些元素区分为三大部分。分别是主体的棋盘区域,按键交互区域,信息显示区域。不同区域用不同的类来封装,让整个代码容易理解和调试。

游戏中的几个参数是可以修改的,main入口那里可以尝试修改board_size的大小,棋盘大小会随之改变。你也可以尝试修改n的大小,如果修改成6,则意味着你需要六子连珠才算取胜。你甚至可以试试修改UnitSize,看看游戏界面会有如何的变化。

到目前为止,我们已经完成了四个经典游戏的编写,相信大家对于PyGame模块,和游戏编程设计都有了更深的理解。从下一章开始,我们将开始学习人工智能的基础知识,然后在第十六章,我们会把AI作为五子棋的一个玩家,看看你能否击败AI呢。