1 前言

最近没什么功夫,平时上班没什么时间,周末也很少有整块的时间能坐下来调程序、更帖子;

这个打砖块游戏我调试了一个多星期(总的学Python的时间一月有余),想讲的东西还是挺多的,目的是希望给和我一样刚入门Python这门语言、刚接触Pygame、但是还存在不熟练和各种疑惑的同学一点启发,同时也分享一下自己的心得;但是由于篇幅不适宜太长,这篇文章只介绍逻辑流程,细节部分后面会再开文章介绍,有兴趣的可以继续关注!

这篇日志是打算长时间更新了,虽然工程量在熟练的工程师手里只能算微小,但是对于一个非职业程序员&自学者来说,也算是一个阶段性的突破。各位看官有耐心的话且容我慢慢道来。

先放上源码包,程序主文件是arkanoid.py,安装了Python3.2+和Pygame,就可以直接双击开始游戏;

操作很简单,按键盘Enter开始或者重玩游戏,键盘左右方向键控制挡板不让球落下并击碎砖块。

 打砖块_单关完整版.zip


2 游戏规划

2.1 打砖块是啥游戏?

其实玩过黑白掌机、红白掌机、小霸王游戏机的同学来说,打砖块这个游戏肯定是不陌生的,在我心目里普及度应该不输俄罗斯方块。打砖块,英文名Arkanoid,是一款经典的休闲益智游戏,1986年由日本的Taito株式会社发行,随便百度或者Google一下就能发现有很多很多移植修改版本。

2.2 模块划分

做一个项目,模块划分当然是很重要的。当然,模块划分是可以中途改变的,不过基础一定要打好,否则修改起来非常费劲。仔细分析了一番打砖块这个游戏,我决定以游戏中的实体对象为划分原则,以游戏主程序(arkanoid)、球(ball)、砖块(bricks)、挡板(paddle)、按键(keys)、界面(interfaces)、公共资源(common)这几个模块来组成此游戏。下图是这几个模块之间的联系:

 

3 模块细谈

3.1 paddle.py -- 左右移动的挡板,最先绘制的实体对象

先贴上这个模块的代码。

这个模块只有一个类:Paddle()。主程序就是利用这个类来建立一个挡板的对象,然后通过其他模块来控制挡板。Paddle()类有4个函数。

3.1.1 初始化 __init__()

Python的类都有一个初始化的函数,并且至少给一个形参self。初始化过程中,会把其他传递过来的形参挪为己用(初始化形参),同时新建一些变量方便后续函数使用。

参数说明:

各个参数都已经在程序注释里做了解释,有几个地方对于初学pygame的同学来说可能比较陌生,比如创建矩形、设置颜色等等,这里强烈建议跟我一样的新手们学会查看Pygame官方文档(点击进入),第一个需要关注的就是怎么画一个矩形:

 

很好理解,pygame.draw.rect()需要4个参数:

  1. surface:即在哪个屏幕上画这个矩形
  2. color:矩形的颜色,3个8位的RGB值
  3. Rect:矩形对象,使用pygame.Rect()函数创建
  4. width:矩形的线条宽度,如果为0,则实心填充

3.1.2 更新 update()

由于挡板Paddle是一直处于受控状态的,所以程序后台需要一直更新Paddle的状态,主要是根据左右移动标志位的布尔值来判断是向哪个方向移动的,然后以一定的速度(paddle_speed)向这个方向移动。

需要注意的是,不是没一次移动都会在屏幕上表现出来,这个后面会说到,原因是过于频繁地刷新屏幕会导致游戏占用过高的计算机资源,这对于这样一个简单的游戏来说是没有必要的;但是后台更新、计算挡板的移动方向、当前位置是可控而频繁的,主要是为了让游戏有比较好的操控性。

3.1.3 绘制挡板 draw_paddlw()

绘制只需要调用一个Pygame的函数,那就是pygame.draw.rect,这个函数同样在Pygame的官方文档里有说明。绘制挡板将会在interfaces这个模块中被调用。

3.1.4  获取挡板(矩形)的上边沿的点 get_paddle_sides()

这是游戏中一个游戏丰富性的设计。一般状态下,游戏中的小球永远都是做45°方向的运动的,下落时撞击到挡板就会以90°角反弹回来,这个很好理解;但是游戏进行久了你就会发现,每次挡板接住小球、小球的运动方向都是一样的,会很无聊,更有甚者,如果游戏的屏幕长宽设计不合理,球的运动路线会进入死循环。

如图所示,我把挡板上边沿分成三个部分:左1/4、中间1/2、右1/4,挡板接到球时:

  • 若是左1/2接触到球,不管球是什么方向飞来的,都会朝左边反弹
  • 若是右1/2接触到球,不管球时什么方向飞来的,都会朝右边反弹
  • 若是中间1/2接触到球,则遵循自然的反弹方向

这样做的好处就是,球反弹的方向可以由玩家控制,不至于每次接到球都是同一个方向弹上去。

3.2 brick.py -- 静态对象,只有存在和消失两种状态

这个模块的代码:

3.2.1 参数

参数在注释中有说明,都很简单,就不细说了。

3.2.2 包含所有砖块(bricks)的列表

查看Pygame的中文文档可以知道,Pygame界面上画一个矩形,给的坐标是矩形左上角的坐标;这里先根据自己的屏幕大小(后面章节会说到),计算好每个砖块的大小和个数,把每个砖块的左上角坐标列出来,并且创建一个列表:bricks_list,把这些坐标全部囊括其中。

3.2.3 绘制砖块

绘制砖块同样适用Pygame中提供的pygame.draw.rect函数,根据上面已经设置好的参数,利用for循环把设计好的砖块全部打印在画面上。后面章节会说到,当球撞击到某个砖块时,会适用列表的remove函数来删除这个砖块,这样打印在屏幕上的砖块就会少一个。

3.3 keys.py -- 游戏控制、用户输入模块

贴上代码:

3.3.1 参数

这个模块没有自定义参数,而且没有创建class,只有几个函数。

3.3.2 按键事件

按键事件的逻辑流程图如下:

3.4 ball.py -- 运动的小球、本游戏最复杂的模块

老规矩,先贴上代码:

这个模块是这个游戏中最复杂的模块了,这里还是介绍逻辑流程为主。

3.4.1 参数

其实参数在源码注释中已经都有解释,和前面rect类似,这里需要调用pygame.draw.circle函数,新来的同学依旧需要去Pygame官方文档查看一下这个函数使用到的参数:

和画矩形函数相似,就不重复解释了。

3.4.2 更新 update()

和挡板Paddle一样,小球也是不断运动的,所以需要不断地更新球的位置;还有一点也是一样的,那就是,并不是每一次球的位置改变,都会体现在屏幕上,太频繁地刷新屏幕会导致游戏占用太多资源。update()方法里面其实只有3个部分:

  1. 控制球速度:control_ball_speed和ball_speed两个变量起到控制球速度的作用,和rect不一样,circle的位置并不能使用浮点数参数,所以只能采用c语言中延时函数类似的方式来减慢球的速度;
  2. 球的运动:球运动只有4个方向,即左上、左下、右上、右下,角度都是45°角,朝哪个方向运动,是根据moving_right和moving_up两个变量的布尔值来确定的;
  3. 检测是否有撞击事件:检测球是否撞击到边墙、是否撞击到砖块、是否撞击到挡板、是否落地。

3.4.3 绘制小球 draw_ball()

这个方法也只有一个功能,就是在屏幕上绘制圆形。

3.4.4 检测撞墙 hit_wall()

检测撞墙这检测是否撞到左边、顶部和右边的屏幕墙,检测方法很简单,只需要关注圆心的坐标就可以了,圆心坐标离某个边墙的距离=圆形的半径,则表明球已经撞墙。

3.4.5 检测撞到挡板 hit_paddle()

检测方法差不多,当圆心的高度降到离挡板高度距离为圆半径的时候(表明此时圆形的最下边的点已经到了挡板顶部的高度),这时候,只需要检查球最下边的点是否是挡板上边沿的其中一点,就知道挡板是否接住了小球;当然,这里要注意的是,挡板上边沿被分成了3部分,这个上面已经有解释。

3.4.6 检测撞到砖块 hit_brick()

这个模块确实是这个游戏最核心的部分,因为用球打击砖块是这个游戏的主要内容;同时,又因为球的移动和砖块数量的原因,导致这部分的判断、计算要比其他地方更为复杂。

这部分的逻辑流程大致为:

  1. 如果砖块列表为空,则游戏状态标记为成功;若非空,则继续进行;
  2. 遍历砖块列表中的砖块;
  3. 逐一判断球是否撞到当前砖块的某一条边(共上下左右4条)
  4. 若碰撞到上边沿,重复判断当前砖块是否在砖块列表中,如果是,则从列表中删除当前砖块,否则则继续进行;其他边沿同上。

看起来其实也没多复杂是不是?毕竟只是一个小游戏,而且这里只是采用比较原始的碰撞检测方法,未做任何优化,但是其中有几点还是需要着重说明一下:

  1. 碰撞检测:碰撞检测其实是游戏行业里面一个典型算法,真正复杂的游戏碰撞检测并不简单,只是本项目中需要处理的只是二维的、规则形状的碰撞检测,任务量和复杂度都很低;说到底,球和状况的碰撞检测,就是计算一下当前时刻,球的球心(准确说应该是圆心,不纠结了,大家懂就好)到砖块的边沿上任何一点的距离是不是小于球的半径,计算方法也很简单---勾股定理!
  2. 上面流程中也粗体标注了一下重复判断,既然遍历了,那所有的砖块理应就是列表中的,为什么删除的时候还要判断一下当前这个砖块是不是在这个列表里呢?这里就设计到没加重复判断之前遇到的一个bug,就是球碰撞到砖块的四角上的点的时候触发的:比如,如下图一样,如果球碰到的时候砖块的左下角那个点,在下边沿判断的时候,就会检测到碰撞把这个砖块从列表中删掉了,但是左边沿判断的时候,又会检测到碰撞再删除一次,这时候就会报错说此元素不在列表中!这个bug确实让我头疼了一会,后来还是机智的被我发现了,并且加了一条重复判断顺利解决......不幸的是,后来想了下,检测到碰撞删除这个砖块之后,直接break不就好了??额好吧,不改了,我还是比较喜欢自己找到bug的喜悦感,留下做纪念。

3.5 common.py和interfaces.py

这两个模块我不打算细说了,源码也就不贴了。common.py里面只是存放了一些多个模块需要调用的变量,因为头一次写模块的工程,难免遇上变量规划不合理的情况,干脆把一些需要彼此调用、相互import的变量直接放到一个单独的模块中,更方便。

至于interfaces.py,内容比较多,其实只是在绘制各种界面:

  1. 游戏有几个状态:WELCOME、RUNNING、SUCCESS、RETRY,初始状态为WELCOME;
  2. WELCOME、SUCCESS、RETRY界面会显示相应的提示字符,RUNNING状态会绘制游戏中的各个元素 --- 屏幕、挡板、砖块、球;
  3. WELCOME和RETRY界面做了一点丰富性设计,当ENTER或者回车键按下时,提示字符会有下沉和变色效果,这个打算以后再写一篇博文讲一下,其实也很简单。

3.6 arkanoid.py -- 游戏主程序

源码:

这里每执行一次run_game则是进行一轮新的游戏,而run_game中的大循环则是一直根据不同的游戏状态做不同的屏幕刷新操作(即屏幕上显示不同的interface),唯一需要讲的,是计算频率和刷新频率。

前面章节也有说到,为了控制屏幕刷新率、避免游戏占用太多的计算机资源,游戏的主循环里面采用pygame.time.Clock.tick()实现帧率控制。从Pygame官方文档中可以看到,如果给tick()传递一个数字参数,则这个数字就是游戏最大的绘制帧数(粗略的帧数,更精确的帧率控制会消耗更多的CPU)。

在run_game()函数中,大循环while True中采用这行代码将屏幕的刷新率控制在fps_cnt这个数字,我给了120,读者朋友们可以自己修改一下,比如修改到30,能明显感觉到帧率变低,但是球的运动速度没有明显变化,这是因为我同时控制了游戏的计算频率。

计算频率设置为500是个什么概念呢?在计算机速度足够的前提下,如果在ball.py中没有刻意控制球的速度,则每秒钟球的坐标会移动500次。

这一句通过换算,建立了计算频率和刷新频率之间的关系,以我设计的参数(计算频率500,刷新帧率120)为例:游戏画面每秒钟刷新120次,每次刷新屏幕时,后台计算500/120=4次,即不限制球速的情况下每次刷新屏幕的间隔球的坐标会改变4次,那一秒钟就是改变500次;实际上一秒钟球移动500次速度是很快的,但是这个计算速度设置地太低会导致游戏响应缓慢,因此在ball.py中做了球速限制:

如果ball_speed设置为16,则每计算20-16=4次球才会移动一次,这样每秒钟球坐标变动的次数就是500/4=125,这个速度实际测试下来是可以接受的。

怎么控制刷新率的同时不影响游戏进行的速度也让我纠结了很久,之前只控制屏幕刷新率没有控制计算频率,就会出现一种情况:随着砖块的数量越来越少,球速会越来越快!这是因为砖块减少之后,砖块的边沿数量也会减少,每次球移动需要检测碰撞的计算量也减少,所以每两次球移动的间隔也越来越短导致速度越来越快。采用这种方法,只要计算机速度足够,就不会出现之前的问题。

4 总结

玩中学、学中玩,作为Python的入门项目,使用Pygame写一个小游戏还是挺有意思的。这个小游戏完成了,对Python的基本语法也是足够了解了,模块之间的关联、参数传递也比较熟练;当然,也还存在不少可以改进的地方,比如模块规划、变量的规划等等。总的来说,Python是一门有意思的语言,在各个平台上的普适性是最吸引人的,之前写c就没有这种buff,哈!

分类: 代码相关

发表评论

电子邮件地址不会被公开。 必填项已用*标注

%d 博主赞过: