第七章:五子棋游戏编程
本章我们将学习另一种经典游戏编程,五子棋游戏。在第二章的时候,我们介绍了如何编写井字棋。五子棋游戏可以看作是井字棋的升级版,只不过会有漂亮的窗口来显示棋子的位置,而不再是使用简陋的终端。
一、五子棋游戏介绍
游戏规则
五子棋是一种桌面游戏,两名玩家各自使用黑色或白色的棋子,轮流在一个棋盘上落子。只要任一名玩家的棋子连成一条线,或者说五子连珠,即获得游戏胜利。游戏的精妙之处在于,玩家不仅要考虑让自己的棋子连成线,还要阻止对方的棋子连成线。和前面几章游戏最大的区别在于,玩家会有较长的思考时间,也就是说游戏并非每帧需要渲染画面。游戏画面显示如下。
游戏资源
本游戏只用到一种外部游戏资源,就是字体文件。除此之外,所有的游戏元素都是通过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变量用于保存当前一手落子信息。
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呢。