Project 0:2048
截止时间:2021 年 1 月 29 日#
简介#
本项目的高层概览视频位于:https://youtu.be/Xzihuj_JZBI ↗。
本项目的目的是让你有机会熟悉 Java,以及课程使用的各种工具,例如 IntelliJ IDE,以及用于编写和运行单元测试的 JUnit。虽然你会在 proj0 文件夹中看到许多文件和大量代码,但任务只位于 Model.java 中,而且仅限于四个方法。
评分只取决于程序是否能够按照测试要求正确工作,以及你是否提交了指定内容。本项目没有隐藏测试。之后的作业还会对代码风格进行评分,但本项目不会。不过,我们仍然建议遵循课程的 style61b 指南 ↗,因为它会帮助你编写整洁的代码,只是本项目不会据此扣分。
这份作业规格相当长,而且起始代码很多。我们建议你在开始编程之前完整阅读规格。刚开始时,它很可能会让你觉得不知所措。为了彻底理解内容,你可能需要多次重新阅读规格中的某些部分;而且在完成项目较早的部分之前,后面的某些内容可能不会完全说得通。
最终,我们希望这段经历能让你获得一种力量感:你成功驾驭了这样一项庞大的任务。
游戏#
你很可能见过,甚至玩过 “2048”。这是 Gabriele Cirulli 编写的一款单人电脑游戏,它又基于 Veewo Studio 更早的游戏 “1024”(可以查看他的 2048 在线版本 ↗)。
在本项目中,你将构建这个游戏的核心逻辑。也就是说,我们已经完成了所有 GUI 代码、按键处理以及大量其他脚手架。你的工作将是完成其中最重要、也最有趣的部分。
具体来说,你将填写 Model.java 文件中的 4 个方法,它们控制用户按下某些按键之后会发生什么。
游戏本身相当简单。游戏在一个 的方格网格上进行,每个方格可以为空,也可以包含一块写有某个整数的方块;这个整数是大于等于 2 的 2 的幂。在第一次移动之前,应用程序会在最初为空的棋盘上随机选择一个方格,并添加一块值为 2 或 4 的方块。2 或 4 的选择是随机的:选择 2 的概率为 75%,选择 4 的概率为 25%。
然后,玩家通过方向键选择一个方向来倾斜棋盘:北、南、东或西。所有方块都会沿该方向滑动,直到运动方向上不再有空位(也可能一开始就没有空位)。一个方块可能会与另一个方块合并,从而为玩家获得分数。
下面的 GIF 展示了进行几步移动后的效果。

下面是上图所展示的、发生合并时需要遵循的完整规则。
- 两块数值相同的方块会合并成一块数值为原来两倍的方块。
- 一次倾斜中,由合并产生的方块不会再次合并。例如,如果有
[X, 2, 2, 4],其中 X 表示空位,并把方块向左移动,结果应当是[4, 4, X, X],而不是[8, X, X, X]。这是因为最左侧的 4 已经参与过一次合并,所以不应当再次合并。 - 如果沿运动方向有三个相邻方块具有相同数值,那么运动方向上靠前的两块会合并,而后面的那块不会。例如,如果有
[X, 2, 2, 2]并向左移动,结果应当是[4, 2, X, X],而不是[2, 4, X, X]。
作为这些规则的推论,如果沿运动方向有四块相邻方块具有相同数值,它们会形成两块合并后的方块。例如,如果有 [4, 4, 4, 4] 并向左移动,结果是 [8, 8, X, X]。这是因为根据规则 3,靠前的两块会合并,之后后面的两块也会合并;但根据规则 2,这两块新合并出的方块(本例中的 8)不会在这次倾斜中继续彼此合并。
上面的动画 GIF 中包含了上述三条规则各自的应用,因此请多看几遍,确保充分理解这些规则。
为了测试理解,你应当完成课程提供的 Google Form 小测 ↗。这个小测不计入 61B 课程成绩。
如果一次倾斜没有改变棋盘状态,就不会随机生成新方块。否则,程序会在一个空方格上添加一块随机生成的方块。注意:**你的代码不会负责添加任何新方块!**这一部分我们已经替你完成。
你可能还会注意到屏幕底部有一个 “Score” 字段,它会随着移动而更新。分数不会每一步都发生变化,而只会在两块方块合并时变化。你的代码需要更新分数。
每当两块方块合并成一块更大的方块,玩家会获得等于新方块数值的分数。当当前玩家没有任何可用移动(任何方向的倾斜都无法改变棋盘),或者一次移动形成了值为 2048 的方块时,游戏结束。你的代码负责检测游戏何时结束。
“Max Score” 是用户在当前游戏会话中取得过的最高分。只有游戏结束时它才会更新,因此在上面的动画 GIF 示例中,它始终保持为 0。
作业理念与程序设计#
本节规格的视频概览位于:https://youtu.be/3YbIOga6ZdQ ↗。
在这个项目中,我们提供了大量起始代码,其中使用了许多尚未讲解的 Java 语法,甚至还有一些本课程永远不会讲到的语法。
这里的想法是:在真实世界中,你经常会面对自己并未完全理解的代码库,需要通过试验和摸索来获得想要的结果。不要担心;下周进入 Project 1 时,你会有机会从头开始编写代码。
下面将说明 Paul Hilfinger 创建的骨架代码背后的一些架构思想。你不需要理解每一个细节,不过这些内容可能会很有意思。
骨架代码体现了两种常见设计模式:Model-View-Controller 模式(MVC)和 Observer 模式。
MVC 模式把问题划分为三个部分:
- Model 表示正在被描述和操作的主题;在本项目中,它包含棋盘游戏的状态以及修改状态的规则。我们的 Model 位于
Model、Side、Board和Tile类中。Model的实例变量可以完全确定游戏状态。注意:你只会修改Model类。 - View 是模型的视图,它向用户显示游戏状态。我们的 View 位于
GUI和BoardWidget类中。 - Controller 是游戏控制器,它把用户操作转换为对模型的操作。我们的 Controller 主要位于
Game类中,不过它也会使用 GUI 类读取按键。
MVC 模式不是 61B 的课程主题,考试或未来项目中也不会要求你了解或理解这种设计模式。
骨架使用的第二种模式是 “Observer 模式”。简单地说,这意味着 Model 实际上不会主动把变化报告给 View。相反,View 会把自己注册为 Model 对象的观察者。这是一个略微高级的话题,因此这里不提供更多信息。
现在介绍你将接触的各个类。
Tile#
这个类表示棋盘上带数字的方块。如果一个 Tile 类型的变量为 null,它会被视为棋盘上的空方格。你不需要创建任何 Tile 对象,不过由于会在 Model 类中使用它们,你需要理解它们。你需要使用这个类的唯一方法是 .value(),它会返回给定方块的数值。例如,如果 Tile t 对应一个数值为 8 的方块,那么 t.value() 会返回 8。
Side#
Side 类是一种特殊类型的类,称为 Enum。枚举与普通类类似,但功能受到限制。具体来说,枚举只能取有限集合中的某个值。在这里,四个方向各自对应一个值:NORTH、SOUTH、EAST 和 WEST。你不需要使用这个类的任何方法,也不需要操作它的实例变量。
枚举可以使用类似 Side s = Side.NORTH 的语法赋值。注意,我们不是使用 new 关键字,而是直接把 Side 值设为四个值之一。类似地,假如有函数 public static void printSide(Side s),可以用 printSide(Side.NORTH) 调用它,把值 NORTH 传给函数。
如果想进一步了解 Java 枚举,请查看 Java Enum 教程 ↗。
Model#
这个类表示游戏的完整状态。一个 Model 对象表示一局 2048。它拥有表示棋盘状态的实例变量(例如所有 Tile 对象的位置、分数等),还拥有多种方法。当你做到项目第四个也是最后一个任务(编写 tilt 方法)时,一项挑战就是判断哪些方法和实例变量有用。
Board#
这个类表示方块棋盘本身。你将使用它的三个方法:setViewingPerspective、tile 和 move。作为可选试验,也可以使用 getRandomNonNullTile。
本作业中,你只会编辑 Model.java 文件。Gradescope 只会获取你的 Model.java,并使用其他文件的骨架版本;因此,如果修改了 Tile.java,Gradescope 不会识别这些修改。
开始项目#
首先,确保已经完成 Lab 1 ↗。如果没有完成 Lab 1 要求的全部必要配置,就无法进行这个项目。
获取骨架文件#
首先,确保仓库中的所有内容都已正确更新并提交。开始之前,在 sp21-s*** 目录中运行:
git statusbash命令应当报告目录是干净的,而且没有任何需要添加和提交的未跟踪文件。如果存在,就把它们添加并提交。
永远不要在没有完成这件事的情况下开始一个新项目。
要获取骨架文件,请在 sp21-s*** 目录中使用:
git pull skeleton masterbash现在,你会在学生仓库中看到一个 proj0 文件夹,其中包含所有骨架代码。
在极少数我们必须更新骨架的情况下,可以使用同一条命令,把相同的更新合并到项目中。
重新开始:Skeleton#
与其努力让当前代码工作,你有时可能想完全重新开始。通过 Git 可以做到这一点!只需在 sp21-s*** 目录中运行:
git checkout skeleton/master -- proj0bash注意:这条命令会清除 proj0 目录中所有尚未提交的更改。因此,如果认为当前代码以后可能还有用,请先创建一个提交,再运行这条命令。之后,你可以使用类似命令把 proj0 目录恢复为刚才创建的提交中的状态。
IntelliJ 配置#
现在在 IntelliJ 中打开文件。首先启动 IntelliJ。它会显示最近项目列表,但由于尚未开始这项作业,项目不会出现在其中。要打开项目,请单击应用窗口右上角的 “Open” 按钮,这会打开操作系统的文件浏览器。进入学生仓库中的 proj0 文件夹,然后单击打开:


屏幕左上角会显示 proj0 目录中的文件和文件夹列表。如果没有看到,请单击 proj0 文件夹旁的向下箭头来展开。它应当如下所示:

.idea 文件夹由 IntelliJ 生成,用于存储各种设置,可以忽略。
game2048 文件夹是所有 Java 文件的源目录。你需要完成的所有工作都位于这里。
javalib 文件夹是一个 Java 库。它包含三个 .jar 文件,即我们想使用的预编译文件。这三个文件让我们能够运行 JUnit,还提供一些 UC Berkeley 特有的功能,以便在漂亮的窗口中渲染游戏。
IntelliJ 通常足够聪明,能够自动完成其余配置;不过,如果你的 IntelliJ 遇到困难,下面会逐步说明配置过程。
首先,告诉 IntelliJ 我们使用 Java 15。进入 File > Project Structure:

单击 “Edit” 按钮左侧的方框,从中选择 JDK,也就是 Java 版本。

接下来,告诉 IntelliJ 我们要使用 javalib 文件夹中的 .jar 文件。仍然位于 Project Structure 中时,在左侧单击 Project Settings 下的 “Library”。如果已经看到 javalib 被添加,就不需要做任何事。否则,单击 “+” 按钮,再选择 “Java”,这会打开操作系统的文件浏览器;然后选择 javalib 文件夹。最后,单击屏幕右下角的 “Apply”,再单击蓝色 “OK” 按钮。
整个配置过程大致如下(抱歉,图片比较模糊):

为了确保配置正常,打开 game2048 文件夹,并右键单击 Java 文件 Main。你会看到若干选项,我们关心的是绿色的 “Run Main.main()” 按钮。它应当如下图所示:

单击它启动 2048 游戏。一个带有空棋盘的新窗口会出现。现在先关闭窗口;到规格中 “主要任务:构建游戏逻辑” 一节时,我们还会回来。
如果什么都没有出现,说明配置不正确。重新完成上面的步骤,确保没有遗漏,不过不要在这件事上花超过 10 分钟。配置问题最好在 TA 帮助下解决,也就是说,应当在 Ed 上发帖或前往 Office Hours。如果在 Ed 上发帖,需要告诉我们已经完成和尝试过的全部操作,以便清楚了解错误。请附上所有内容的截图,尤其是收到的错误消息。
你可能遇到一个奇怪现象:代码能够正确编译和运行,但 IntelliJ 仍然显示红色下划线。进入 Model 类,找到 addTile 方法。这个方法由我们提供,但你可能看到 tile 变量下面有红线,并出现如下错误消息:

不过我们清楚地知道它是正确的,因为 1)代码能够运行,2)这是起始代码!IntelliJ 虽然非常强大,但有时也会像这样判断错误。要修复它,进入 File > Invalidate / Restart,再在出现的窗口中单击 “Invalidate and Restart”。

IntelliJ 会重新索引 JDK,并从头配置项目,这需要一两分钟。完成后,源文件中应当不再有红色下划线。
只有上述配置正常,才能完成项目,因此请把尽快完成配置作为首要任务。
你的任务#
本项目的工作是修改并完成 Model 类,具体包括 emptySpaceExists、maxTileExists、atLeastOneMoveExists 和 tilt 方法。其他所有内容已经为你实现。我们建议按这个顺序完成。前两个相对直接,第三个(atLeastOneMoveExists)更困难,最后的 tilt 方法可能会相当困难。我们预计 tilt 需要 3 到 10 小时完成。
前三个方法处理游戏结束条件,最后的 tilt 方法会在用户按键后修改棋盘。阅读非常短的 checkGameOver 方法体,可以了解这些方法如何用于判断游戏是否结束。
先看前三个方法。
public static boolean emptySpaceExists(Board b)#
如果给定棋盘中任何方块为 null,这个方法应当返回 true。本项目中绝对不要以任何方式修改 Board.java。在这个方法中,你会需要使用 Board 类的 tile(int col, int row) 和 size() 方法,不需要其他方法。
注意:我们在设计 Board 类时使用了特殊关键字 private,它不允许你直接访问 Board 的实例变量。例如,如果尝试访问 b.values[0][0],代码不会工作。这是一件好事!它会迫使你学习使用 tile 方法,而整个项目的其余部分也会一直使用这个方法。
尝试打开 TestEmptySpace.java 并运行测试。你应该会看到 6 个测试失败、2 个测试通过。正确编写 emptySpaceExists 后,TestEmptySpace 中全部 8 个测试都应当通过。
有关怎样开始编写这个方法的快速概览,请查看课程提供的视频。
public static boolean maxTileExists(Board b)#
如果棋盘中任何方块的数值等于获胜方块值 2048,这个方法应当返回 true。注意,不要在代码中硬编码常量 2048,而应当使用 MAX_PIECE,它是 Model 类的一部分。换句话说,不要写 if (x == 2048),而应当写 if (x == MAX_PIECE)。
在代码中保留 2048 这样的硬编码数字是一种不良编程实践,有时称为“魔法数字”。魔法数字的危险在于,如果修改了代码中某处的数字,却没有修改另一处,可能会得到意料之外的结果。使用 MAX_PIECE 这样的变量,可以确保所有位置一起改变。
编写方法后,TestMaxTileExists.java 中的测试应当通过。
public static boolean atLeastOneMoveExists(Board b)#
这个方法更有挑战性。如果存在任何有效移动,它应当返回 true。“有效移动”是指:在玩 2048 时,如果用户能够按下某个按键(UP、DOWN、LEFT 或 RIGHT),并使至少一块方块移动,那么这次按键就是有效移动。
存在有效移动的情况有两种:
- 棋盘上至少有一个空位。
- 有两块相邻方块具有相同数值。
例如,对于下面的棋盘,应当返回 true,因为至少有一个空位。
| 2| | 2| |
| 4| 4| 2| 2|
| | 4| | |
| 2| 4| 4| 8|text对于下面的棋盘,应当返回 false。无论在 2048 中按下哪个按钮,什么都不会发生;也就是说,没有两块相邻方块具有相同数值。
| 2| 4| 2| 4|
| 16| 2| 4| 2|
| 2| 4| 2| 4|
| 4| 2| 4| 2|text对于下面的棋盘,应当返回 true,因为向左或向右移动会合并两块 64,而向上或向下移动会合并两块 32。换句话说,至少存在两块数值相同的相邻方块。
| 2| 4| 64| 64|
| 16| 2| 4| 8|
| 2| 4| 2| 32|
| 4| 2| 4| 32|text编写方法后,TestAtLeastOneMoveExists.java 中的测试应当通过。
主要任务:构建游戏逻辑#
作业的第四个也是最后一个部分是实现 tilt。只有在 TestEmptySpace、TestMaxTileExists 和 TestAtLeastOneMoveExists 的全部测试都通过之后,才应当开始这个方法。
计算机科学本质上只关乎一件事:管理复杂性。编写 tilt 方法是一段丰富的经历,会让你有机会亲自尝试管理复杂性。我必须警告你,这很可能会是一次令人沮丧的经历。你可能会尝试若干种最终失败的方法,然后不得不重新开始。
在讨论 tilt 应当怎样工作之前,先尝试运行游戏。
打开 Main 类并单击运行按钮。游戏窗口应当出现。尝试按方向键,你会看到什么都没有发生。这是因为尚未实现 tilt 方法。完成 tilt 后,就可以玩游戏了。
public boolean tilt(Side side)#
tilt 方法负责真正移动棋盘上的所有方块。例如,如果棋盘是:
| 2| | 2| |
| 4| 4| 2| 2|
| | 4| | |
| 2| 4| 4| 8|text并按下向上键,tilt 会修改 board 实例变量,使游戏状态变成:
| 2| 8| 4| 2|
| 4| 4| 4| 8|
| 2| | | |
| | | | |text除了修改棋盘,还有两件事必须发生:
- 必须更新
score实例变量,使其反映所有方块合并的总价值(如果有)。在上面的例子中,两块 4 合并为 8,两块 2 合并为 4,因此分数应当增加 8 + 4 = 12。 - 如果棋盘有任何变化,必须把局部变量
changed设为true。这是因为在tilt的骨架代码末尾可以看到,我们调用了setChanged()方法;它会通知 GUI 有内容需要绘制。你自己不会调用setChanged,只需要修改局部变量changed。
棋盘上的所有方块移动都必须使用 Board 类提供的 move 方法完成。访问棋盘上的所有方块都必须使用 Board 类提供的 tile 方法。由于 GUI 实现中的一些细节,在一次 tilt 调用中,对给定方块应当只调用一次 move。本文的 Tips 部分会进一步讨论这个限制。
课程视频中提供了怎样开始编写这个方法的快速概览。
提示#
我们强烈建议一开始只考虑向上方向,也就是参数 side 等于 Side.NORTH 的情况。为了支持这一过程,我们提供了 TestUpOnly 类,其中包含四个测试:testUpNoMerge、testUpBasicMerge、testUpTripleMerge 和 testUpTrickyMerge。你会注意到,这些测试都只涉及一次向上移动。
考虑怎样实现向上移动时,请注意以下内容。
对于给定的一列,顶行(第 3 行)的方块保持原位。如果第 2 行上方的位置为空,它可以向上移动;如果上方方块与它数值相同,它也可以向上移动一格。换句话说,遍历各行时,从第 3 行开始向下遍历是安全的,因为一个方块移动一次之后,不可能还需要再次移动。
这听起来可能不会太难,但实际上真的很难。准备好拿出记事本,推导许多例子。努力编写优雅的代码,尽管在这个问题中很难做到优雅。我们强烈建议创建一个或多个辅助方法来保持代码整洁。例如,可以编写一个辅助函数处理棋盘中的一列,因为每一列彼此独立。也可以编写一个能够返回目标行数值的辅助函数。
提醒:对给定方块只能调用一次 move。换句话说,假设棋盘如下,并按下向上键:
| | | | |
| | | | |
| | | | |
| | | | 2|text一种实现方式可能如下:
Tile t = board.tile(3, 0)
board.move(3, 1, t);
board.move(3, 2, t);
board.move(3, 3, t);
setChanged();
return true;java不过,GUI 会被弄糊涂,因为同一块方块不应当在只调用一次 setChanged 的情况下移动多次。相反,需要通过一次 move 调用完成整个移动,例如:
Tile t = board.tile(3, 0)
board.move(3, 3, t);java从某种意义上说,困难的部分在于判断每块方块最终应当位于哪一行。
为了测试理解,你应当完成课程提供的 Google Form 小测。这个小测(以及后面的其他小测)完全可选,不计分,但我们强烈建议完成,因为它可以发现你对游戏机制的概念误解。可以任意多次尝试。
要判断何时更新分数,请注意:如果把方块 t 移动到列 c、行 r 会替换一块已存在的方块(也就是发生合并),board.move(c, r, t) 方法会返回 true。
看起来更糟糕的是,即使让向上方向的 tilt 工作,仍然需要为另外三个方向完成相同的事情。如果采用朴素方法,会得到大量重复、只有少量修改的代码,并产生许多引入隐蔽错误的机会。
对于这个问题,我们直接提供了一个干净解法的关键思想。它会让你只增加两行代码,就能处理另外三个方向!具体来说,Board 类拥有 setViewingPerspective(Side s) 函数,它会改变 tile 和 move 方法的行为,让它们表现得就像给定方向是 NORTH。
例如,考虑下面的棋盘:
| | | | |
| 16| | 16| |
| | | | |
| | | | 2|text如果调用 board.tile(0, 2),会得到 16,因为 16 位于第 0 列、第 2 行。如果调用 board.setViewingPerspective(s),其中 s 是 WEST,棋盘会表现得就像 WEST 是 NORTH;也就是说,就像你把头向左转了 90 度,如下所示:
| | | 16| |
| | | | |
| | | 16| |
| 2| | | |text换句话说,之前的 16 现在会位于 board.tile(2, 3)。如果拥有正确实现的 tilt,并调用 board.tilt(Side.NORTH),棋盘会变成:
| 2| | 32| |
| | | | |
| | | | |
| | | | |text要让棋盘恢复原始观察视角,只需调用 board.setViewingPerspective(Side.NORTH),这会让棋盘重新表现为 NORTH 就是 NORTH。执行后,棋盘的行为就像它是:
| | | | |
| 32| | | |
| | | | |
| 2| | | |text可以看到,这与把原始棋盘中的方块滑向 WEST 完全相同。
**重要:**结束 tilt 调用之前,务必使用 board.setViewingPerspective 把观察视角设回 Side.NORTH,否则会发生奇怪的事情。
为了测试理解,请尝试第三个也是最后一个 Google Form 小测。可以任意多次尝试。
测试#
虽然未来我们会要求你能够测试自己的程序,但本项目提供了完整测试套件。
测试分布在 5 个文件中:TestEmptySpace、TestMaxTileExists、TestAtLeastOneMoveExists、TestUpOnly 和 TestModel。每个文件测试代码中的某个特定部分,只有 TestModel 例外,它会协调测试你编写的全部内容。这种测试称为集成测试,在测试中非常重要。
单元测试会隔离运行各部分,而集成测试会把所有部分一起运行,用于捕获由不同函数相互作用导致的隐蔽 bug。
因此,在通过其余测试之前,不要尝试调试 TestModel!事实上,下面讨论测试的顺序就是你应当尝试它们的顺序。
接下来逐个查看这些测试,并说明怎样阅读错误消息。
TestEmptySpace#
这些测试检查 emptySpaceExists 方法的正确性。如果某个测试失败,错误消息大致如下:

左侧会看到所有运行过的测试列表。黄色 X 表示测试失败,绿色对勾表示测试通过。右侧会看到一些有用的错误消息。要单独查看某个测试及其错误消息,请单击左侧的测试。例如,假设我们想查看 testCompletelyEmpty 测试。

右侧现在只显示这个测试的错误消息。第一行包含一条有用信息:"Board is full of empty space",后面跟着棋盘的 String 表示。可以清楚看到棋盘为空,但 emptySpaceExists 方法返回了 false,导致测试失败。如果某个测试失败,测试代码顶部的 Javadoc 注释也包含一些有用信息。
TestMaxTileExists#
这些测试检查 maxTileExists 方法的正确性。错误消息与 TestEmptySpace 类似,仍然可以单击每个测试来单独查看。请记住,maxTileExists 方法应当只寻找最大方块,不应当检查任何其他内容(例如不应当寻找空位)。如果你的方法这样做,就无法通过所有测试。
TestAtLeastOneMoveExists#
这些测试检查 atLeastOneMoveExists 方法的正确性。错误消息与上面两个测试类似。由于 atLeastOneMoveExists 依赖 emptySpaceExists,在 TestEmptySpace 的全部测试通过之前,不应当期待这些测试能够通过。
TestUpOnly#
这些测试检查 tilt 方法的正确性,但只检查向上(Side.NORTH)方向。这些测试的错误消息不同,来看一个例子。假设运行全部测试后发现 testUpTrickyMerge 失败。单击这个测试后,会看到:

第一行会说明倾斜方向(在这些测试中始终是 North),之后显示倾斜前你的棋盘是什么样子、预期棋盘是什么样子,最后显示你的棋盘实际是什么样子。
你会看到,在一次 tilt 调用中,我们让同一块方块合并了两次,结果得到一块数值为 8 的方块,而不是两块数值均为 4 的方块。因此,棋盘表示底部所显示的 score 也不正确。
对于其他测试,可能很难立刻看出预期棋盘与实际棋盘之间的区别。对于这些测试,可以单击错误消息最底部蓝色的 “Click to see difference” 文本,在一个单独窗口中并排比较预期棋盘(左侧)与实际棋盘(右侧)。对于这个测试,结果如下:

调试这些问题可能有些棘手,因为很难判断自己做错了什么。首先,应当判断违反了上面列出的三条规则中的哪一条。在这个例子中,可以看到违反的是规则 2,因为一块方块合并了多次。这些测试方法的 Javadoc 注释是很好的资源,因为它们会明确说明正在测试哪条规则或哪种配置。也可以通过查看移动前后的棋盘来判断违反了哪条规则。
之后才是棘手的部分:重构现有代码,让它正确处理这条规则。我们建议用纸笔写下代码执行的步骤,先理解棋盘为什么会变成当前样子,再设计修复方法。这些测试只调用一次 tilt,因此不必担心调试多次 tilt 调用。
TestModel#
这些测试会一起检查所有内容的正确性。大多数测试与 TestUpOnly 中的测试类似,只调用一次 tilt;但它们还包含 gameOver 测试(会一起测试 emptySpaceExists、maxTileExists 和 atLeastOneMoveExists),以及连续多次调用 tilt 的测试。
这些测试的错误消息与 TestUpOnly 完全相同,Javadoc 注释同样有助于判断测试正在检查什么。
不需要担心测试的实际代码;你不必理解或修改任何测试,不过欢迎阅读它们,了解测试是怎样编写的。如果非常有雄心,也可以添加自己的测试。
评分#
满分项目会通过我们提供的全部单元测试。请记住,本项目没有隐藏测试,因此,如果通过了所有这些测试,你就拥有一个满分项目!
Gradescope 上某些测试的权重略有不同,因此,如果通过了某个比例的测试,你的百分制分数可能会是另一个比例(很可能更高)。
这是因为项目中的某些部分比其他部分更困难,而且我们知道这是许多学生第一次使用 Java,因此据此设置了各部分权重。
下面列出不同完成程度大约能够获得的百分比:
- 只实现
emptySpaceExists或maxTileExists:约 27%。 - 实现除
tilt之外的全部内容:约 47%。 - 实现全部内容,但
tilt只支持 Up 方向:约 68%。 - 实现全部内容,但不支持合并:约 64%。
- 实现全部内容,但没有实现合并规则 2:约 93%。
可以看到,实现合并规则 2 只占项目的约 7%。这是因为它是一条很难处理的规则,却只涉及游戏中很小一部分情况,所以我们相应地设置了权重。
额外加分#
在周四晚上 11:59 之前(即项目截止前一天)把最终提交交到 Gradescope 的学生会获得 2 分额外加分。“最终提交”是 Gradescope 上被激活的提交,因此截止时间后仍然可以继续提交,但只有在周四 11:59 之前的提交被设为 active 时,才会获得这 2 分。额外分会在截止时间后应用,因此项目截止之前不会显示。
提交与版本控制#
以较高频率把工作提交到仓库非常重要。当你把某些内容弄坏,或者你的狗吃掉项目时,版本控制是拯救自己的强大工具;但只有经常使用它,它才有用。可以每 15 分钟提交一次。尽管 Git 的行为像是为整个项目创建快照,它实际上只保存发生变化的内容。
git status 命令会告诉你从上次提交以来修改、删除或可能添加了哪些文件,还会告诉你有多少内容尚未发送到 GitHub 仓库。
典型命令大致如下:
git status # To see what needs to be added or committed.
git add <filepath> # To add, or stage, any modified files.
git commit -m "Commit message" # To commit changes.
git pushbash之后可以继续项目,直到再次准备提交和推送,再重复上面的过程。养成频繁提交并编写有信息量的提交消息的习惯,符合你的最佳利益。这样,如果需要恢复到旧代码版本,不但可以做到,而且很容易。我们建议每当添加了一段重要代码或达到某个里程碑(例如通过一个新测试)时都创建提交。
把代码推送到 GitHub(也就是运行 git push)后,可以进入 Gradescope,找到 proj0 作业并提交代码。请注意,Gradescope 使用的是最近一次推送的提交;如果在 Gradescope 提交前没有运行 git push,测试的会是旧代码,而不是计算机上的最新代码。
获取帮助#
少量挣扎和调试是正常的,甚至是健康的;但如果已经按照上面的所有建议尝试,仍然卡住数小时毫无进展,请向工作人员求助!
在 CS 61B 中,获得帮助的两种方式是参加 Office Hours,或者在 Ed 上发帖,让 TA 或其他学生帮助你摆脱困境。
请记住,在 Office Hours 中,TA 对每位学生最多花 10 分钟。为了加快进度,我们要求你带着一个能够清楚向 TA 表达的问题,而不是只说某个测试没有通过。例如,如果某个测试没有通过,先判断具体是哪一部分没有通过,以及为什么存在差异。也许 score 没有正确更新,或者合并没有按应有方式发生。这样会加快帮助过程,甚至可能让你自己找到 bug。
如果在 Ed 上发帖,请阅读课程的 Ed 政策,确保没有意外发布部分解法并违反学术诚信政策。除此之外,欢迎在 megathread 中进行有建设性的讨论。发帖前请先搜索问题,因为许多学生会遇到非常相似的 bug!
原始页面:https://sp21.datastructur.es/materials/proj/proj0/proj0 ↗