IIPP迷你项目(四)"Pong"
1 小球在墙面的反弹
1-1 让小球在画布上匀速运动
在这一步中,首先应该明确小球是如何匀速运动的。它的方法是规定一个列表v,Scott老师说这代表着“速度(Velocity)”,但是我觉得也可以拿“向量(Vector)”理解,它指明了“小球该往哪个方向运动”。
现在我们写写代码做做这个实验。如前述,我们知道小球的当前位置用列表p来表示,假如我们给定一个列表v=[20,20],然后不借用任何timer,仅仅靠画布自身的一秒钟刷新60次来完成小球位置的更新——也就是,在我们眼中,小球是沿着给定方向“动起来”了。
import SimpleGUICS2Pygame.simpleguics2pygame as simplegui ### globals ### WIDTH = 600 HEIGHT = 400 BALL_RADIUS = 20 p = [WIDTH/2,HEIGHT/2] v = [20,20] ### event handlers ### def draw(canvas): p[0] += v[0] p[1] += v[1] canvas.draw_circle(p,BALL_RADIUS,2,"Red","White") ### create frame ### frame = simplegui.create_frame("Ball physics",WIDTH,HEIGHT) ### register event handlers ### frame.set_draw_handler(draw) ### start frame ### frame.start()
事实上,我们规定的v列表代表了各方向的速度,而canva一秒钟刷新60次的机制给定了时间。用简单的物理语言描述出来,即“每隔1/60秒,小球沿着x方向移动20个像素点,与此同时沿着y方向移动20个像素点(因此看起来就像是小球“斜着”动起来的)”。换算一下,也就是小球每秒在x、y方向各运动20*60=1200个像素点,这速度实在是太快了——而通过实验,也可以发现画布中小球的确是一闪而过的,这正好证实了我们的推论。
而通过调整v列表中元素的大小,我们也的确能够控制小球运动的速度,这就和现实中的“调整速度”是一样的。
1-2 判断小球撞在墙面上
这一步我们根据小球球心的坐标来判断。假设小球的当前位置为p=[x,y],则p[0]、p[1]分别为小球的横坐标和纵坐标。
和我做过的毕业设计一样,在计算机中规定画布的右下角的坐标为(width,height),这里的坐标系相当于数学中常用的平面直角坐标系关于x轴的轴对称,也就意味着越往右,x越大;越往下,y越大。
规定小球的半径为r,如图所示,我在图中标注出了当小球撞在四面壁上其圆心坐标分别是怎样一个情况,其中打*的代表“取值多少都可以”。
根据这幅图,并且规定圆心坐标为(p[0],p[1]),我们很容易得到“碰壁”的条件:
(1)碰左壁:p[0]≤r;
(2)碰右壁:p[0]≥width-r;
(3)碰上壁:p[1]≤r;
(4)碰下壁:p[1]≥height-r。
1-3 小球在墙壁上的反弹
这一部分我们首先应结合1-2判断小球是否撞在墙面上,然后才谈得上对撞在墙面上的小球的反弹。
现在我们假设小球确实已经撞在一面墙壁上了,为了方便观看,我们先把小球当做一个质点来处理。如图所示,当一个物体被反弹时,可知入射角等于反射角,且反射前后速度的模值是不变的(|v|、v[0]、v[1]的大小皆保持不变)。
我将入射速度与反射速度分别用一个向量来表示,并且把它们分别分解为水平方向和竖直方向的两个分向量,如图所示,其中紫色的为入射速度的分量,红色的为反射速度的分量。可以很容易发现:竖直方向的分量大小、方向皆没有发生变化;水平方向分量则是大小不变,方向与原来相反。
用Python语言描述出来,反射后的速度应作如下变化:
v[0] = -v[0] # 水平方向大小不变,方向相反 v[1] = v[1] # 竖直方向保持不变
应注意,上面的分析我们仅仅针对小球往左边或者右边的壁碰撞,如果是往上、下壁碰撞,则情况不再是“水平翻转,竖直不变”了,而是“水平不变,竖直翻转”。作为拓展练习,我写了一个小球在一个框内到处反弹的程序。
import SimpleGUICS2Pygame.simpleguics2pygame as simplegui ### globals ### WIDTH = 600 HEIGHT = 400 r = 20 p = [WIDTH/2,HEIGHT/2] v = [20,20] ### event handlers ### def draw(canvas): p[0] += v[0] p[1] += v[1] # reflection if ( p[0] <= r ): v[0] = -v[0] v[1] = v[1] elif ( p[0] >= (WIDTH-r) ): v[0] = -v[0] v[1] = v[1] elif ( p[1] <= r ): v[0] = v[0] v[1] = -v[1] elif ( p[1] >= (HEIGHT-r) ): v[0] = v[0] v[1] = -v[1] canvas.draw_circle(p,r,2,"Red","White") ### create frame ### frame = simplegui.create_frame("Ball physics",WIDTH,HEIGHT) ### register event handlers ### frame.set_draw_handler(draw) ### start frame ### frame.start()
这个程序的运行效果如图所示:
2 使用“ ↑ ↓ ← → ”按键控制挡板移动
2-1 速度叠加型控制
在讲解视频中,给出的是一个用键盘控制小球做“上,下,左,右”运动的实例,它的核心思想是设定一个速度列表v,然后每次按下按键都会在原先速度的基础上进行一个△v的叠加,以此来控制小球运动的速度。在这里Joe老师也给出了4种试验来帮助我们更好地理解什么叫“速度叠加型”控制:
(1)一开始小球是静止的,按一下“←”键,然后小球开始朝右运动,之后不必按任何按键,小球自己会朝左边做直线运动;
(2)在上面的基础上,再次按下“←”键,则小球开始以刚才的2倍速朝左边做直线运动;
(3)在上面的基础上,按下“→”键,则小球恢复到1倍速,朝左边做直线运动;
(4)在上面的基础上,按下“→”键,则小球停止运动。
这就是“速度叠加型”控制,每一次按下按键改变的是速度列表v,本部分代码在按键事件处理函数keydown(key)中完成。整个程序清单如下:
import SimpleGUICS2Pygame.simpleguics2pygame as simplegui ### globals ### WIDTH = 600 HEIGHT = 400 r = 20 p = [WIDTH/2,HEIGHT/2] v = [0,0] ### event handlers ### def draw(canvas): p[0] += v[0] p[1] += v[1] canvas.draw_circle(p,r,2,"Red","White") def keydown(key): delta_v = 1 if key == simplegui.KEY_MAP["left"]: print key v[0] -= delta_v elif key == simplegui.KEY_MAP["right"]: v[0] += delta_v elif key == simplegui.KEY_MAP["down"]: v[1] += delta_v elif key == simplegui.KEY_MAP["up"]: v[1] -= delta_v ### create frame ### frame = simplegui.create_frame("Ball physics",WIDTH,HEIGHT) ### register event handlers ### frame.set_draw_handler(draw) frame.set_keydown_handler(keydown) ### start frame ### frame.start()
其中KEY_MAP函数是用来将其传入参数所代表的按键转换为相应的ASCII码的(该传入参数是一个字符串),比如KEY_MAP("left")将返回“←”键的ASCII码。而与此同时,keydown(key)函数中的key也是ASCII码表述的,故通过比较keydown(key)函数中的key和 KEY_MAP("left") 是否相等,可以判断是否有“←”键按下。
注:KEY_MAP可转换的全部按键见这里:KEY_MAP。
2-2 按键则动,松键则止
通过玩其他游戏也知道,更常见的一种物体控制方法应是“按键则动,松键则止”,而Pong就是典型的例子。在这里我尝试改动上面小球那个程序,来实现这种“按键则动,松键则止”式控制,一旦这种方法实现了,那么相当于Pong的挡板移动也就实现了。
方法非常简单,只需要在上面程序的基础上添加一个松开按键事件即可,并给它关联一个事件处理函数keyup(key),使得每一次松开按键,都会将速度列表v中的全部元素清零。
应该注意的是,在Python的函数中,如果修改的是一个全局变量列表v中“某个元素”的值,则不必声明v是global就能直接改;如果修改整个全局变量列表v的值,则必须声明global,否则只当该函数下的“v”是一个与全局变量列表v同名的局部变量,在一个函数运行到尾部时,意味着该函数内全部的局部变量的生命周期都到头了,这些局部变量都会被销毁。因此这里在keyup(key)事件处理函数中修改全局列表v的时候必须加注“global”。
本部分代码如下:
import SimpleGUICS2Pygame.simpleguics2pygame as simplegui ### globals ### WIDTH = 600 HEIGHT = 400 r = 20 p = [WIDTH/2,HEIGHT/2] v = [0,0] ### event handlers ### def draw(canvas): p[0] += v[0] p[1] += v[1] canvas.draw_circle(p,r,2,"Red","White") def keydown(key): delta_v = 1 if key == simplegui.KEY_MAP["left"]: print key v[0] -= delta_v elif key == simplegui.KEY_MAP["right"]: v[0] += delta_v elif key == simplegui.KEY_MAP["down"]: v[1] += delta_v elif key == simplegui.KEY_MAP["up"]: v[1] -= delta_v def keyup(key): global v v=[0,0] ### create frame ### frame = simplegui.create_frame("Ball physics",WIDTH,HEIGHT) ### register event handlers ### frame.set_draw_handler(draw) frame.set_keydown_handler(keydown) frame.set_keyup_handler(keyup) ### start frame ### frame.start()
经过试验,也可以发现上面的代码完全能够完成“按键即走,松键则停”的功能。
(2016.7.1记)
3 Pong
本次程序是按照IIPP官网给出的流程一步一步实现的。为了进一步巩固所学知识,在这个程序编完后我也会再编一个类似的小游戏,比如砸砖块之类的,敬请期待吧~~
3-1 复位按键Restart
在frame上添加按键Resteart,使得每一次按下这个按钮的时候,小球都会回到画布正中心,并且沿一个由随机数指定的方向做匀速直线运动。
这里面函数的嵌套关系是这样的:
3-1-1 spawn_ball()函数
该函数用于给全局变量p、v进行初始化赋值。其中p为小球位置坐标,初始时为p = [WIDTH/2,HEIGHT/2];v为小球速度,初始速度由生成的随机数来指定。也就是p规定了小球一开始呆在什么位置,而v规定了小球将以多大速度、朝哪个方向进行运动。未来小球一经发射及各种碰撞反弹,这两个全局变量都会发生变化,故每一次我们复位的时候都需要将p、v进行初始化。
3-1-2 小球的初始速度
需要注意的一点是,小球的初始速度并非一个固定值,而是由随机数指定的。spawn_ball()的完整写法是spawn_ball(direction),这个direction参数指明了小球初始是朝哪边运动,有:
(1)direction == "RIGHT":小球朝右上方运动;
(2)direction == "LEFT":小球朝左上方运动。
在new_game()函数中,我生成了一个0~100间的随机数,如果生成的随机数在0~50之间,则令direction="LEFT";如果生成的随机数在50~100之间,则令direction="RIGHT"。因此只要按下Restart按钮,小球往哪个方向发射都有可能。
3-2 触碰凹槽
在Pong这个游戏中,我们规定画布的上、下边界为墙壁,画布的左、右边界为凹槽,凹槽一碰即死。在触碰凹槽后,将会给对手加分,同时将会自动调用spawn_ball()函数开启下一轮游戏。
在这里,我规定如果小球触碰的是左边的凹槽,则设定spawn_ball(direction)中的direction为"LEFT",被开启的下一轮游戏小球是往左发射的;如果触碰的是右边的凹槽,则设定spawn_ball(direction)中的direction为"RIGHT",被开启的下一轮游戏小球是往右发射的。
为了更好地区分清楚小球、凹槽以及还没编写好的挡板,我将界面设置如下图所示,为了增加凹槽的“一碰即死”的触目惊心感,我将凹槽画成了红色,并且设定它们所铺就的范围分别为(0~10)、(590~600)。
3-3 添加挡板
3-3-1 挡板绘制
(1)由于simplegui中不提供画矩形的函数,故这里直接拿较宽的线条来代替矩形。我这里设置线条的长度为60像素点,宽度为10个像素点,也就是相当于绘制了一个60*10的矩形。
(2)用paddel1代表左边的挡板,paddel2代表右边的挡板。以paddel1为例,paddel1_pos为当前左挡板最高点的位置,而paddel1_vel为左挡板的速度列表。那么绘制左挡板时,可以写:
paddle1_pos = [15,170] canvas.draw_line( paddle1_pos, [paddle1_pos[0]+0,paddle1_pos[1]+60] , 10 , "White") # paddle1
这就是绘制一个60*10的矩形挡板了。其中draw_line函数的前两个参数分别为一条直线的两个端点的坐标,其中第一个端点坐标即paddel1_pos,第二个端点坐标为第一个端点横坐标不变、纵坐标加上60个像素点所得。
注意,这里绘制的直线 [(15,170),(15,230)] 为实际矩形竖直方向上的对称轴,也就是说所绘制的矩形挡板1在一开始的时候(还未移动的时时候),其水平方向上的范围为(10~20),竖直方向上的范围为(170~230)。
同理可绘制矩形挡板2,其水平方向上的范围为(580~590),竖直方向上的范围为(170~230)。因此我们设定对称轴为 [(585,170),(585,230)]
paddle2_pos = [585,170] canvas.draw_line( paddle2_pos, [paddle2_pos[0]+0,paddle2_pos[1]+60] , 10 , "White") # paddle2
3-3-2 让挡板动起来
(1)这里我分别设定让“↑”和“↓”控制左挡板,让“w”和“s”控制右挡板。
(2)正如2-2中程序所写,让挡板遵循“按键即动,松键即止”的规则,则很容易写出控制挡板运动的程序。
(3)这里有一点,当两个按键同时按下时,将会出现“卡壳”现象,目前我还没考虑怎么解决,总之这一次注意在按键的时候不要同时按下两个键就可以了,一定要松一个,再按下一个。
下图给出的是挡板受键盘控制的效果图,请注意左下角按键提示。
3-3-3 撞板判断
我们需要让小球碰到板表面的一刻立即反弹,因此首先需要进行一个“撞板判断”,即:小球撞到板了吗?
就以左边的挡板为例。刚才在3-2中说过,红色部分铺就的范围为0~10,在3-3-1中说过,白色挡板的范围为10~20。因此,我们应该在draw函数(draw函数为画布的回调函数,每1/60秒进入一次)中添加条件判断,当小球的横坐标p[0]满足“ p[0] == r + 20 ”的时候(即小球的球面抵达左侧挡板高度的时候,见下图虚线),进入判断语句:小球的纵坐标p[1]是否在(paddle1_pos[1], paddle1_pos[1]+60)范围内?如果在该范围内,则说明小球是被挡板接住了;反之,当小球的横坐标满足“ p[0] == r + 20 ”,可纵坐标却不在(paddle1_pos[1], paddle1_pos[1]+60)范围内,则说明小球没被接住,将一头扎入血池中。
这部分的代码将被添加入draw函数中。
到目前为止,这个游戏就基本成型了,如图所示:
3-3-4 挡板范围限制
我们必须限制挡板,使其只出现在画布上,不允许超出画布范围。
这一部分说起来也容易,以左边挡板为例,如果挡板碰到画布顶端或底端,则不允许再改变paddel1_pos[1]即可实现这一点。但是如果我们按照这种思想把代码写成:
#paddles' range if ( paddle1_pos[1]<0 ): # paddle1 paddle1_pos[1] += 0 elif ( paddle1_pos[1]>340 ) : paddle1_pos[1] -= 0 else: paddle1_pos[1] += paddle1_vel[1]
可以发现,当挡板触碰到画布的顶端或者底端之后,再怎么按“↑”“↓”键,挡板也没反应。
仔细分析一下。假如我将左挡板上移至paddle1_pos[1]<0,由于在这个条件下我们保持paddle1_pos[1]等于一个负值不变,那么在下一次刷新画布时,挡板将仍停留在paddle1_pos[1]<0这个位置不变,下下一次亦是如此,这使得我们的程序陷入了一个死循环。简单来说,一旦挡板不处于(0~340)范围内,那么不论按什么键都无法让它脱离这个窘境,它将永远没可能进入到正常的(0~340)范围内。
解决方案很简单,只需要将上面代码块中的paddle1_pos[1]+=0改成paddle1_pos[1]+=1,paddle1_pos[1]-=0改成paddle1_pos[1]-=1即可。
这是为什么呢?因为这使得挡板一旦发现“不对,我超范围了!”它就会朝后小小地退一步,使得它又回到了正常的(0~340)范围内,而不是傻站着不动,一直停留在“按键无能为力”范围内。
因此这一部分还是颇为有趣的,需要多考虑多咀嚼才能明白“为什么要这样写,为什么原来那个不行”。正确的限制挡板范围代码如下:
#paddles' range if ( paddle1_pos[1]<0 ): # paddle1 paddle1_pos[1] += 1 elif ( paddle1_pos[1]>340 ) : paddle1_pos[1] -= 1 else: paddle1_pos[1] += paddle1_vel[1]
3-4 速度变化
为了随时间增加,游戏难度也随之增强,这里设定了“速度变化”。方法很简单,每一次成功撞板后,都把小球的速度增加为原来的10%。
经尝试,这件事要和“反射”这一步一起做,否则游戏会有板子接不住球的bug。只需要在设定反弹的同时,将速度的大小变为原来的1.1倍即可,代码如下:
#collides the paddles? if ( ( p[0] <= (r + 20) ) and (p[1] > paddle1_pos[1]) and ( p[1] <= (paddle1_pos[1]+60) )): v[0] = -1.1*v[0] v[1] = 1.1*v[1] elif ( ( p[0] >= (WIDTH-r-20) ) and (p[1] > paddle2_pos[1]) and ( p[1] <= (paddle2_pos[1]+60) )): v[0] = -1.1*v[0] v[1] = 1.1*v[1]
3-5 添加score
这一部分非常简单,只需要设定全局变量score1与score2,然后每次哪一方掉入血池,则对手分数“+1”,并将文本打印在画布上即可。
但是也要注意,每一次开启new_game时,要把这两个全局变量复位成0。
游戏效果如下:
4 程序清单
import SimpleGUICS2Pygame.simpleguics2pygame as simplegui import random ### globals ### WIDTH = 600 HEIGHT = 400 r = 20 p = [WIDTH/2,HEIGHT/2] v = [0.,0.] paddle1_pos = [15,170] paddle2_pos = [585,170] paddle1_vel = [0,0] paddle2_vel = [0,0] score1 = 0 score2 = 0 ### helper function ### def spawn_ball(direction): global p,v p = [WIDTH/2,HEIGHT/2] v_horizental = random.randrange(120./60.,240./60.) v_vertical = random.randrange(60./60.,180./60.) if (direction == "RIGHT"): v = [ v_horizental, - v_vertical ] elif (direction == "LEFT"): v = [ - v_horizental, - v_vertical ] def new_game(): global score1,score2 score1 = 0 score2 = 0 direction = random.randrange(0,100) if (direction >= 50): direction = "RIGHT" else: direction = "LEFT" spawn_ball(direction) ### event handlers ### def draw(canvas): global score1,score2 p[0] += v[0] p[1] += v[1] #paddles' range if ( paddle1_pos[1]<0 ): # paddle1 paddle1_pos[1] += 1 elif ( paddle1_pos[1]>340 ) : paddle1_pos[1] -= 1 else: paddle1_pos[1] += paddle1_vel[1] if ( paddle2_pos[1]<=0 ): # paddle2 paddle2_pos[1] += 1 elif ( paddle2_pos[1]>=340 ) : paddle2_pos[1] -= 1 else: paddle2_pos[1] += paddle2_vel[1] # collides and bounces if ( p[1] <= r ): v[0] = v[0] v[1] = -v[1] elif ( p[1] >= (HEIGHT-r) ): v[0] = v[0] v[1] = -v[1] #collides the paddles? if ( ( p[0] <= (r + 20) ) and (p[1] > paddle1_pos[1]) and ( p[1] <= (paddle1_pos[1]+60) )): v[0] = -1.1*v[0] v[1] = 1.1*v[1] elif ( ( p[0] >= (WIDTH-r-20) ) and (p[1] > paddle2_pos[1]) and ( p[1] <= (paddle2_pos[1]+60) )): v[0] = -1.1*v[0] v[1] = 1.1*v[1] else: # touch the gutters if ( p[0] <= r): score2 += 1 spawn_ball("LEFT") elif ( p[0] >= (WIDTH-r)): score1 += 1 spawn_ball("RIGHT") canvas.draw_line([5,0],[5,400],10,"Red") # gutters1 canvas.draw_line([595,0],[595,400],10,"Red") # gutters2 canvas.draw_line([300,0],[300,400],2,"White") # middle line canvas.draw_circle(p,r,2,"Yellow","Yellow") canvas.draw_line( paddle1_pos, [paddle1_pos[0]+0,paddle1_pos[1]+60] , 10 , "White") # paddle1 canvas.draw_line( paddle2_pos, [paddle2_pos[0]+0,paddle2_pos[1]+60] , 10 , "White") # paddle2 canvas.draw_text( "Player1: "+str(score1), [100,50], 30, "Orange") canvas.draw_text( "Player2: "+str(score2), [380,50], 30, "Orange") def keydown(key): delta_v = 2 if key == simplegui.KEY_MAP["down"]: paddle1_vel[1] += delta_v elif key == simplegui.KEY_MAP["up"]: paddle1_vel[1] -= delta_v elif key == simplegui.KEY_MAP["w"]: paddle2_vel[1] -= delta_v elif key == simplegui.KEY_MAP["s"]: paddle2_vel[1] += delta_v def keyup(key): if key == simplegui.KEY_MAP["down"]: paddle1_vel[1] = 0 elif key == simplegui.KEY_MAP["up"]: paddle1_vel[1] = 0 elif key == simplegui.KEY_MAP["w"]: paddle2_vel[1] = 0 elif key == simplegui.KEY_MAP["s"]: paddle2_vel[1] = 0 def restart(): new_game() ### create frame ### frame = simplegui.create_frame("Pong",WIDTH,HEIGHT) ### register event handlers ### frame.set_draw_handler(draw) frame.set_keydown_handler(keydown) frame.set_keyup_handler(keyup) frame.add_button("Restart", restart, width=100) ### start frame ### frame.start()
(2016.7.3记)
by 悠望南山