轻松一把,写个《扫雷》来玩玩(以wxPython实现)

原创
2020/11/17 21:17
阅读数 456

1. 概述

相信大家对《扫雷》游戏都不陌生,它规则简单,且颇具可玩性。从技术的角度来说,这个小游戏实现起来并不太难,所以是个很好的练手题目。今天我们就尝试用wxPython来实现一个简单的《扫雷》游戏。(附件里有全部资源和源码,可供大家参考)

下图是我截取的一张游戏效果图,虽然简陋,但已能正常运行。


接下来,我们开始详细讲解。

2. 《扫雷》规则

《扫雷》的游戏规则和操作说明:

  • 《扫雷》的基本操作区是个简单的二维地图,长宽随用户选择的游戏难度不同而不同。
  • 地图里可操作的基本单元是小格。
  • 初始情况下,地图里每个小格都是未打开的。
  • 玩家可通过鼠标左键点击打开小格。如果小格里具有地雷,则游戏失败,否则会显示该小格周围8个小格里共埋有多少地雷。如果周围没有地雷,则不显示数字(也就是说不会显示0)。
  • 未打开的小格可以通过鼠标右键点击来做标记。
    • 点击一次右键,标记为红旗,表示玩家认为此处有雷。如果小格标记有红旗,那么该小格不允许被用户手动或自动打开。
    • 再点击一次右键,标记为问号,表示玩家不确定此处是否有雷。
    • 继续点击一次右键,清除问号标记。
  • 对于已打开的小格,可以通过鼠标左键双击,或鼠标左右键同时点击,来快捷打开其周围未打开的小格。请注意,如果当前小格显示的数字大于0,但周围的红旗标记格数目小于当前小格显示的数字,则不会快捷打开周围小格。
  • 如果打开了一个周围雷数为0的小格,则游戏会自动打开其周围8个小格。而如果新打开的小格里仍然含有周围雷数为0的小格,则会进一步继续打开相应小格。如此循环下去。
  • 每局游戏从点开一个方块开始计时,并每秒更新已经经过的秒数,直到成功找出所有地雷或中途失败为止。

3. 设计思路

各位朋友不妨先自己思考一下,该如何设计这个游戏。一个明显的单位是就是一个小格,而一局游戏无非是将若干小格组织成一张二维表而已。

一个小格应该有如下几个信息:
1)打开状态,表示其是否已经被点开了;
2)标记状态,表示用鼠标右键点击后,做了什么标记,比如红旗标记、问号标记;
3)地雷信息,表示这个小格里是否具有地雷;
4)周围雷数,表示这个小格紧相邻小格里共含有多少地雷。注意,即便该小格里有一颗地雷,它也是会记录周围的雷数的,只不过在游戏界面上,不会显示这个数字而已。

我们可以这样定义小格:

class MineBlock:
    def __init__(self, open_state, block_flag):
        self.open_state = open_state
        self.block_flag = block_flag
        self.around_num = 0
        self.has_mine   = False

然后,我们可以进一步定义一个主面板类:MinePanel,玩游戏时的主要操作都是在这个面板里完成的。

在wxPython里,wx.Panel类可以理解为基本的控件容器面板,我们的MinePanel就继承于它。

class MinePanel(wx.Panel):
    def __init__(self, parent, sz, st):
        super().__init__(parent, size=sz, style=st)
        self.Bind(wx.EVT_PAINT, self._on_paint)
        self.Bind(wx.EVT_SIZE, self._on_size)
        self.Bind(wx.EVT_LEFT_DOWN, self._on_mouse_left_down)
        self.Bind(wx.EVT_LEFT_UP, self._on_mouse_left_up)
        self.Bind(wx.EVT_LEFT_DCLICK, self._on_mouse_left_dbclick)
        self.Bind(wx.EVT_RIGHT_DOWN, self._on_mouse_right_down)
        self.Bind(wx.EVT_RIGHT_UP, self._on_mouse_right_up)
        self.timer = wx.Timer(self)
        self.Bind(wx.EVT_TIMER, self._on_timer, self.timer)
        
        self.SetBackgroundStyle(wx.BG_STYLE_PAINT)
        self.num_colors     = [None, wx.Colour(0, 0, 230), wx.Colour(0, 180, 0), wx.Colour(210, 0, 0), wx.Colour(220, 50, 180), \
                               wx.Colour(30, 200, 200), wx.Colour(240, 240, 30), wx.Colour(160, 80, 180), wx.Colour(0, 0, 0)]
        self.control_panel  = ControlPanel(self)
        self.block_height   = 0
        self.red_flag_count = 0
        self.num_txt_font   = None
        self.left_down      = False
        self.right_down     = False
        self.red_flag_icon  = None
        red_flag_icon_file  = 'red_flag.png'
        if (os.path.exists(red_flag_icon_file)):
            self.red_flag_icon = wx.Bitmap(red_flag_icon_file)

在上面的代码里,除了注册了我们感兴趣的事件处理函数外,我们还加入了一个ControlPanel对象,它是做什么的?简单地说,就是负责处理游戏界面里展现剩余雷数、消耗时间、重开按钮的子面板。这个面板只是个逻辑上的概念,所以并不继承于wx.Panel。

我们画一张简单的关系示意图:


需要说明的是,虽然在游戏界面里,所有小格会最终显现成一张二维表格,但实际上我是把这些小格存储到一个一维列表里的。在运行时需注意计算好行列坐标,不要弄错了。

在编写游戏时,一个基本的理念是:以“操作”来修改状态,以“绘制”来反映状态。现在我们可以设想玩一把游戏的大体流程,流程示意图如下:

理清思路后,实现起来就比较简单了。我们先看点击鼠标左键时的动作:

    def _on_mouse_left_up(self, event):
        self.left_down = False
        if (self.control_panel.handle_mouse_left_up(event)):
            return
        if (self.die or self.success):
            return
        (v_x, v_y) = event.GetPosition()
        if (self.right_down):
            self._quick_open(v_x, v_y)
            return
        (need_refresh, update_rect) = self._try_to_open_a_block(v_x, v_y)
        if (need_refresh):
            if (update_rect != None):
                self.RefreshRect(update_rect)
            else:
                self.Refresh()
            if (not self.die and self.start_time == 0):
                self.start_time = time.time()
                self.last_time = self.start_time
                self.timer.Start(milliseconds=1000)
        self._check_success()
  • 首先,如果点击的是游戏控制板区域,则由控制板处理:self.control_panel.handle_mouse_left_up()。
  • 如果在抬起左键时,右键处于按下状态,则按同时点击左右键处理,其实会尝试执行前文说的快速打开周边8个小格的动作。
  • 如果不是以上情况,则按普通的打开一个小格的动作处理,这个也是我们主要关心的动作:_try_to_open_a_block()。
    为了尽量减小重绘的范围,_try_to_open_a_block()动作返回的内容里有一个update_rect。我们前文说过,如果新打开的小格里仍然含有周围雷数为0的小格,则会进一步继续打开相应小格,如此循环下去。所以我们一开始是不知道要重绘多大区域的,只有在递归动作完成后,从_try_to_open_a_block()函数返回时,我们才能得到并最终重绘这个区域。

_try_to_open_a_block()的代码如下:

    def _try_to_open_a_block(self, x, y):
        if (self.rows_num <= 0):
            return (False, None)
        (x_index, y_index) = self._calc_x_y_index(x, y)
        (blk, update_rect) = self._set_block_opened(x_index, y_index)
        if (self.die):
            return (True, None)
        if (blk != None):
            return (True, update_rect)
        return (False, None)

其中会先计算出要打开哪个坐标的小格,然后用_set_block_opened()设置该小格为“已打开”状态。

    def _set_block_opened(self, x_index, y_index):
        if (self.die):
            return (None, None)
        update_rect = None
        if (y_index >= 0 and y_index < self.rows_num and x_index >= 0 and x_index < self.cols_num):
            block_index = y_index * self.cols_num + x_index
            block = self.blocks_map[block_index]
            if (block.open_state == OPEN_STATE_NOT_OPEN and block.block_flag != BLOCK_FLAG_REDFLAG):
                block.open_state = OPEN_STATE_OPENED
                update_rect = self._get_one_block_rect(x_index, y_index)
                if (block.has_mine):
                    print("!!!!! DIE !!!!!!" + "   x_index=" + str(x_index) + ", y_index=" + str(y_index))
                    self.die = True
                    self.timer.Stop()
                else:
                    if (block.around_num == 0):
                        rect = self._recursive_open_all_neighbour_zero_around(x_index, y_index)
                        if (update_rect != None and rect != None):
                            update_rect.Union(rect)
                return (block, update_rect)
        return (None, update_rect)

在设置“已打开”状态后,如果发现踩到雷,就结束此局。如果周边没有雷,就开始递归打开无雷的若干小格:_recursive_open_all_neighbour_zero_around()。

    def _recursive_open_all_neighbour_zero_around(self, x_index, y_index):
        new_open_blocks = []
        neighbours = [(x_index - 1, y_index - 1), (x_index, y_index - 1), (x_index + 1, y_index - 1), \
                      (x_index - 1, y_index), (x_index + 1, y_index), \
                      (x_index - 1, y_index + 1), (x_index, y_index + 1), (x_index + 1, y_index + 1)]
        if (self.die):
            return None
        blocks_rect = None
        for nb_pnt in neighbours:
            (blk, update_rect) = self._set_block_opened(nb_pnt[0], nb_pnt[1])  # 递归操作
            if (self.die):
                return None
            if (blk != None):
                new_open_blocks.append((nb_pnt, blk))
                if (blocks_rect != None):
                    if (update_rect != None):
                        blocks_rect.Union(update_rect)
                else:
                    blocks_rect = update_rect
            else:
                pass
        return blocks_rect

以上是处理点击左键的动作,接下来在看处理双击的动作:

    def _on_mouse_left_dbclick(self, event):
        if (self.die or self.success):
            return
        (v_x, v_y) = event.GetPosition()
        (need_refresh, update_rect) = self._try_to_open_neighbours_without_redflag(v_x, v_y)
        if (need_refresh):
            if (update_rect != None):
                self.RefreshRect(update_rect)
            else:
                self.Refresh()
        self._check_success()

主要就是要打开周边没有红旗标记的小格:_try_to_open_neighbours_without_redflag()。

    def _try_to_open_neighbours_without_redflag(self, x, y):
        total_around = 0
        seen_mine_around = 0
        count = 0
        (x_index, y_index) = self._calc_x_y_index(x, y)
        block = self._get_block(x_index, y_index)
        if (block == None):
            return (False, None)
        total_around = block.around_num
        neighbours = [(x_index - 1, y_index - 1), (x_index, y_index - 1), (x_index + 1, y_index - 1), \
                      (x_index - 1, y_index), (x_index + 1, y_index), \
                      (x_index - 1, y_index + 1), (x_index, y_index + 1), (x_index + 1, y_index + 1)]
        for nb_pnt in neighbours:
            blk = self._get_block(nb_pnt[0], nb_pnt[1])
            if (blk != None):
                if ((blk.open_state == OPEN_STATE_OPENED and blk.has_mine) or (blk.open_state == OPEN_STATE_NOT_OPEN and blk.block_flag == BLOCK_FLAG_REDFLAG)):
                    seen_mine_around += 1
        if (seen_mine_around < total_around):
            return (False, None)

        nbs_rect = None
        for nb_pnt in neighbours:
            (blk, update_rect) = self._set_block_opened(nb_pnt[0], nb_pnt[1])
            if (self.die):
                return (True, None)
            if (blk != None):
                count += 1
                if (nbs_rect != None):
                    if (update_rect != None):
                        nbs_rect.Union(update_rect)
                else:
                    nbs_rect = update_rect
        
        if (count > 0):
            return (True, nbs_rect)
        return (False, None)

抛开一些零碎逻辑,其中最重要的就是调用_set_block_opened(),正如前文所说,里面也包含着递归打开小格的动作,这里就不赘述了。

4. 小结

扫雷的主要代码就先说这么多,并不十分复杂。作为一个小demo来说,当然没有商业软件那么完备,只供大家轻松一下而已。这个小demo的代码位于https://gitee.com/youranhongcha/python-games.git 仓库的mine_sweeper目录,有兴趣的同学可以clone一份代码看看。
 

 

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部