作业2:打砖块游戏(Breakout)

在这个程序中,你的任务是编写经典的街机游戏Breakout。这个游戏是由Steve Wozniak在与Steve Jobs共同创立Apple之前发明的。虽然这是一个较长的程序,但只要你将问题分解成小块(提示:分解),就完全可以管理。请查看我们的图形参考文档,了解常用图形函数的详细说明。

🎮 Breakout游戏玩法

在Breakout中,世界的初始配置如下图所示。屏幕顶部的彩色矩形是砖块,底部稍大的黑色矩形是挡板。挡板在垂直维度上位置固定,但可以跟随鼠标在屏幕上左右移动,直到到达其空间的边缘(挡板不能移出屏幕的左右方向)。球位于画布的中心。

Breakout初始配置
Breakout初始配置:屏幕顶部有砖块,球在中心,底部有挡板

一个完整的游戏包含三轮。在每一轮中,用户点击画布,然后球从窗口中心以随机角度向屏幕底部发射。球会根据"入射角等于反射角"的物理原理从挡板和世界墙壁反弹(我们可以在后面的说明中讨论如何实现这一点)。当球的底部碰到画布底部时,这一轮结束,之后球会重新居中。这里有一个示例运行的视频 - 游戏结束后,在用户输掉第三轮后球不会重置。

Breakout游戏演示视频

在每一轮中,球会从挡板、砖块和墙壁反弹,直到以下两个条件之一发生:

在这个作业中取得成功将取决于将问题分解成可管理的小块,并在进入下一个之前让每个部分都正常工作。接下来的几个部分描述了解决这个问题的合理分阶段方法。

我们在你的breakout.py文件顶部提供了很多常量。你应该在代码中引用这些常量,而不是直接输入数字。这使你的代码更容易阅读 - 基于常量的代码是好的风格!

🔧 分步实现方法

第一步:创建砖块

在开始玩游戏之前,你应该设置各种组件。从风格上讲,将砖块设置实现为辅助函数是一个好主意,你从main函数调用它。当你将整个程序分解为辅助函数时,请思考:这个函数需要什么信息进来,它需要向调用它的函数返回什么信息?这些就是你的参数和返回值,有时函数可能没有任何参数或返回值。

砖块的数量、尺寸和间距使用常量指定,你可以在starter文件顶部的"第一步"下找到这些常量,以及从窗口顶部到第一行砖块的距离BRICK_Y_OFFSET。你需要自己计算的一个值是左侧砖块的x值,应该选择使砖块在窗口中居中,左右两侧的剩余空间平均分配。砖块的颜色按以下彩虹序列运行:"red"、"orange"、"yellow"、"green"、"blue",每种颜色有两行砖块。这些颜色存储在常量COLORS中。

屏幕顶部的彩色砖块
屏幕顶部的彩色砖块,10行10列,从上到下每种颜色2行:红、橙、黄、绿、蓝

第二步:添加弹跳球

球是一个椭圆,半径由BALL_RADIUS常量指定。在创建球并将其放置在屏幕中央后,你会希望它能够移动并适当弹跳。你现在已经过了"设置"阶段,进入了游戏的"游戏"阶段。首先,创建一个球并将其放在窗口中央。

程序需要跟踪球的速度,这由两个独立的组件组成,你应该将它们声明为变量change_x和change_y。"change"变量表示每个时间步发生的位置变化。你应该从VELOCITY_X_MIN和VELOCITY_X_MAX范围中均匀选择球的起始change_x。你还应该使change_x以相等的概率为正或负,使球开始向右或向左移动。change_y将始终以VELOCITY_Y开始,这是我们提供的另一个常量。

在Python图形库中移动对象有两种方法:

# 将对象移动到特定的new_x, new_y
canvas.moveto(object_name, new_x, new_y)

# 将x坐标增加change_x,y坐标增加change_y
canvas.move(object_name, change_x, change_y)

一旦你创建了球和change变量,你的下一个挑战是让球在世界中弹跳,忽略挡板和砖块。这将需要你编程一个"动画循环",在那里你移动球,更新画布然后暂停。你应该暂停DELAY秒。

在你让球移动后,是时候让球弹跳了。为此,你需要检查球的坐标是否超出了画布的边界,考虑到球的半径。因此,要看到球是否从右墙反弹,你需要看到球的右边缘坐标是否变得大于窗口的宽度;其他三个方向类似处理。现在,让球也从底部墙壁反弹,这样你就可以观看它在画布周围移动的路径。你可能会发现以下函数有助于获取球的当前坐标:

obj_x = canvas.get_left_x(object_name)
obj_y = canvas.get_top_y(object_name)

一旦你确定球应该弹跳,我们如何编码现实弹跳的物理原理?如果球从顶部或底部墙壁反弹,你需要反转change_y的符号。对称地,从侧墙反弹会反转change_x的符号。

第三步:添加挡板

下一步是创建挡板。只有一个挡板,它是一个矩形。你应该在屏幕水平中央开始挡板,挡板顶部距离画布底部PADDLE_Y_OFFSET像素。

一旦你创建了挡板,你需要让它跟随鼠标。然而,在这里,你只需要注意鼠标的x坐标,因为挡板的y位置是固定的。在动画循环的每次迭代中,获取鼠标的x位置,并将代表挡板的矩形移动到以该位置为中心。要获取鼠标的位置,你可以这样做:

mouse_x = canvas.get_mouse_x()

第四步:检查碰撞

为了使Breakout成为一个真正的游戏,你必须能够判断球是否与窗口中的另一个对象碰撞。我们可以做的一个简化是想象球周围有一个正方形"边界框",并找到与该框重叠的所有对象。有一个画布函数返回与假想矩形重叠的所有对象的列表,该矩形的左上角在(x_1, y_1),右下角在(x_2, y_2):

collider_list = canvas.find_overlapping(x_1, y_1, x_2, y_2)

这是球周围边界框的可视化,你将使用它来识别碰撞对象。在视频游戏编程中,这种方法通常是最容易做的事情,而不是查看对象的复杂几何形状。

球周围的边界框
球周围的边界框:框完美地包含球,左上角在(x,y),右下角在(x + 2r, y + 2r),其中r是球的半径

你可以使用函数canvas.coords(object_name)获取对象边界框的坐标,然后从返回的列表中解包每个坐标。我们可以将这些函数放在一起,获得碰撞对象列表collider_list,如下所示:

# 这个图形函数获取边界框坐标作为列表
ball_coords = canvas.coords(ball)

# 列表有两个点的(x, y)坐标
x_1 = ball_coords[0]
y_1 = ball_coords[1]
x_2 = ball_coords[2]
y_2 = ball_coords[3]

# 然后我们可以获得该区域中所有对象的列表
collider_list = canvas.find_overlapping(x_1, y_1, x_2, y_2)

当你获得碰撞对象列表时,球本身会在该列表中,以及球当前碰撞的任何对象。要测试碰撞对象是否是球,你可以检查对象是否==你的球对象。

如果球与挡板碰撞,你需要让球反弹,使其开始向上移动。如果碰撞者不是挡板,唯一可能是砖块,因为那些是世界上唯一其他的对象。你需要在垂直方向引起反弹(翻转change_y变量的符号),但你也需要拿走砖块。为此,通过调用delete函数将其从屏幕上移除:

canvas.delete(object_name) # 删除对象

球应该在每个动画周期中只与一个对象(例如挡板、砖块)碰撞。换句话说,一旦你发现球与挡板或砖块碰撞,你应该响应那个碰撞,如果球同时与多个砖块碰撞,忽略其余的碰撞者。

第五步:最后的润色

在你让球按预期在画布周围移动后,我们还有几个细节需要考虑:

逐步求精让复杂任务变得简单、清晰。建议你在写更复杂的图形或循环程序时,也采用这种分解-细化-实现-完善的思路!
温馨提示:分阶段实现、逐步测试,遇到bug多画图、输出调试信息,理清物理和逻辑关系。