跳转到主要内容 链接 搜索 菜单 展开 Document (外部链接)

项目0:2048

截止日期:1月29日星期一,太平洋时间晚上11:59。

常见问题

每个作业顶部都会有一个常见问题链接。你也可以通过在URL末尾添加 /faq 来访问。项目0的常见问题位于 这里

请注意,本项目的提交次数有限。详情请参阅 提交与评分

概述

前置知识:

  • 实验1 (环境搭建必需)
  • 作业0 (推荐,用于Java语法练习)
  • 第1-2讲
  • 61B代码风格指南 (我们会在自动评分器中检查代码风格!)
  • 实验2(可选但推荐的前置知识 - 对调试有帮助)

点击这里查看项目的视频概述。 该视频来自项目的早期版本,因此会有一些细微差异。

在本项目中,你将通过创建一个可玩的2048游戏来练习Java。我们已经为你实现了图形和用户交互代码,因此你的任务是实现游戏的逻辑。

如果你不熟悉2048, 你可以通过这个链接试玩演示版

这个项目一开始可能看起来令人生畏!有很多初始代码使用了你可能没有见过的Java语法,但没关系!在现实世界中,你经常会使用不完全理解的代码库,并且需要进行一些修补和实验才能获得想要的结果。别担心,当我们到项目1时,你将有机会从头开始。

使用Git

将工作 频繁地 提交到你的仓库非常重要。版本控制是一个强大的工具,可以在你搞砸了或者你的狗吃掉了你的项目时救你,但你必须定期使用它才能发挥作用。可以每15分钟提交一次;Git只保存更改的内容,尽管它表现得像是对整个项目进行了快照。

命令 git status 会告诉你自上次提交以来修改、删除或可能添加了哪些文件。 它还会告诉你有多少更改尚未发送到GitHub仓库。

典型的命令大致如下:

git status                          # 查看需要添加或提交的内容。
git add <file or folder path>       # 添加或暂存任何修改的文件。
git commit -m "提交信息"      # 提交更改。使用描述性的信息。
git push origin main                # 将本地更改反映到GitHub上,以便Gradescope可以看到。

然后你可以继续处理项目,直到准备好再次提交和推送,届时你将重复上述步骤。养成频繁提交并使用有意义的提交信息的习惯对你有利,这样当你需要恢复到旧版本的代码时,不仅可能,而且很容易。我们建议你每次添加重要的代码部分或达到某个里程碑(例如通过新测试)时都提交一次。

2048规则:基本规则

2048在一个方格网格上进行。每个方格可以是空的,或者包含一个带数字的方块。

玩家选择一个方向(使用方向键)来 倾斜 棋盘:北、南、东或西。所有方块都会朝该方向滑动,直到运动方向上没有空余空间为止。

当一个方块滑动时,它可能会与另一个具有相同数字的方块 合并 。每当两个方块合并形成一个更大的方块时,玩家将获得新方块上数字对应的分数。你将在任务4-10中实现这一点。

游戏开始时会随机生成一个方块(值为2或4)。每次倾斜后,如果倾斜没有改变棋盘状态,则不会随机生成新方块。否则,会在棋盘的一个空格子上添加一个随机生成的方块。你的代码不需要添加任何新方块!我们已经为你完成了这部分。

当当前玩家没有可用的移动(没有任何倾斜可以改变棋盘),或者某一步形成了一个包含2048的方块时,游戏结束。你将在任务1-3中实现这一点。

环境搭建

获取框架文件

按照 作业工作流指南 中的说明获取框架代码并在IntelliJ中打开。对于本项目,我们将在 proj0/ 目录中工作。

如果你遇到某种错误,请停下来,要么仔细阅读 git常见问题 来解决,要么在答疑时间或Ed上寻求帮助。与使用git命令瞎猜相比,这样可以省去很多麻烦。如果你发现自己试图使用Google推荐的命令,如force push, 不要这样做 不要使用 git push -f ,即使你在Stack Overflow上找到的帖子说要这样做!

如果你无法让Git正常工作,作为最后的手段,请观看 这个视频 来提交你的作业。

文件结构

proj0 文件夹分为两个 game2048logic game2048rendering 。虽然我们在61B中不会过多讨论它们,但包是一种将代码组织到不同文件夹中的方式。例如,所有图形代码都在 game2048rendering 包中,所有游戏逻辑代码都在 game2048logic 包中。你可以在下面的文件结构中看到这一点:

proj0
├── game2048logic
|   ├── Model.java
├── game2048rendering
    ├── Board.java
    ... (其他一些文件) ...
    ├── Main.java
    ├── Side.java
    ├── Tile.java

在整个项目中,你只需要修改 game2048logic/Model.java 文件。对其他文件的更改不会被Gradescope识别。

但是,你需要查看并使用(但不修改!)其他文件中的一些方法。我们将在规范中提供这些方法的描述。

运行游戏

你可以通过运行 game2048rendering 包中的 Main.java 文件来运行游戏。你可以右键单击该文件并选择"Run 'Main.main()'":

run-main

如果一切设置正确,你应该会看到类似下面的图片:

2048-blank

目前,你的游戏什么都不做,但在本项目结束时,你将拥有一个功能完整的2048实现!

任务1:空位存在检测

Model.java 中,完成 emptySpaceExists() 方法。(不要修改任何其他文件。)

如果棋盘上有任何方块为null,该方法应返回true。

初始代码:棋盘坐标

我们的实现使用xy坐标,(0, 0)位于 左下角 棋盘坐标

初始代码: Board

Board类表示一个方块棋盘。

private 关键字意味着你不能直接访问 Board 类的实例变量。你只能从 Model 中访问 public 方法和变量。(关于这些关键字及其用途,我们将在课程后面详细讨论。)

在任务1中与 Board 对象交互,你需要使用 size() tile(int x, int y) 方法。这些方法在 Board.java 中有文档说明。

初始代码: Tile

Tile类表示棋盘上的带数字的方块。

如果一个 Tile 类型的变量是 null ,这表示棋盘上的一个空格子。要检查 Tile t 是否为null,你可以使用表达式 if (t == null) {...}

与Tile对象交互,你需要使用 value() 方法,该方法返回给定方块的数值。

语法示例:如果 t 是一个 Tile 类型的变量,表示一个值为8的方块,那么 t.value() 将返回8。

如果你尝试在一个 null Tile 对象上调用 value() ,你会得到一个 NullPointerException 。你可以在调用 value() 之前检查方块是否为 null 来避免这种情况。

测试与调试

要测试你的方法,右键单击 TestEmptySpace.java 文件并选择"Run 'TestEmptySpace'"来运行测试:

run-test-empty-space

(你也可以通过右键单击 game2048logic > "Run 'Tests in 'game2048logic'"来运行整个文件夹中的所有测试。)

或者,你可以打开 TestEmptySpace.java 文件,然后单击 public class TestEmptySpace 旁边的绿色箭头来运行测试(你的可能看起来略有不同):

run-test-empty-space-2

你也可以用同样的方式运行单个测试。

在项目的其余部分(以及整个课程中!),你都将以相同的方式运行所有测试。

TestEmptySpace 包含以下测试:

  1. 完全空的棋盘( testCompletelyEmpty ):在没有方块的棋盘上调用 emptySpaceExists
  2. 空的顶行( testEmptyTopRow ):在顶行没有方块的棋盘上调用 emptySpaceExists
  3. 空的底行( testEmptyBottomRow ):在底行没有方块的棋盘上调用 emptySpaceExists
  4. 空的左列( testEmptyLeftCol ):在左列没有方块的棋盘上调用 emptySpaceExists
  5. 空的右列( testEmptyRightCol ):在右列没有方块的棋盘上调用 emptySpaceExists
  6. 一个空位( testAlmostFullBoard ):在只有一个空位的棋盘上调用 emptySpaceExists
  7. 满棋盘但有有效合并( testFullBoard ):在没有空格子但存在合法移动的棋盘上调用 emptySpaceExists 。检查 emptySpaceExists 仍然返回false。
  8. 满棋盘( testFullBoardNoMerge ):在没有空格子且不存在合法移动的棋盘上调用 emptySpaceExists 。检查 emptySpaceExists 仍然返回false。

如果你的实现正确,所有8个测试都应该通过。

以下是如果你失败了其中一个测试,错误消息会是什么样子:

TestEmptySpace全部失败

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

testCompletelyEmpty

右侧现在是该测试的单独错误消息。第一行有一个有用的消息: "Board 是 full of empty space" ,后面是棋盘的 String 表示。你会看到它显然是空的,但我们的 emptySpaceExists 方法返回了 false ,导致这个测试失败。测试代码顶部的javadoc注释也有一些有用的信息,以防你失败了测试。你可以单击蓝色下划线的文本来查看测试内容。

任务2:最大方块存在检测

Model.java 中,完成 maxTileExists() 方法。(不要修改任何其他文件。)

如果棋盘上有任何方块具有获胜值(默认为2048),该方法应返回true。

注意:你不应该在代码中硬编码常量2048,而应该使用变量 MAX_PIECE (已经为你定义好了)。例如,你应该写 if (x == MAX_PIECE) 而不是 if (x == 2048)

保留像 2048 这样的硬编码数字是一种不好的编程习惯,有时被称为"魔术数字"。这种魔术数字的危险在于,如果你在代码的一部分更改了它们而在另一部分没有更改,你可能会得到意外的结果。通过使用像 MAX_PIECE 这样的变量,你可以确保它们一起被更改。

测试与调试

要测试你的方法,请运行 TestMaxTileExists.java 中的测试。

TestMaxTileExists 包含以下测试:

  1. 测试空棋盘( testEmptyBoard ):在没有方块的棋盘上调用 maxTileExists
  2. 测试没有最大方块( testFullBoardNoMax ):在没有最大方块的满棋盘上调用 maxTileExists
  3. 测试有最大方块的棋盘( testFullBoardMax ):在只有一个最大方块的满棋盘上调用 maxTileExists
  4. 测试有多个最大方块的棋盘( testMultipleMax ):在有几个最大方块的棋盘上调用 maxTileExists
  5. 测试右上角有最大方块的棋盘( testTopRightCorner ):在右上角有一个最大方块的棋盘上调用 maxTileExists
  6. 测试左上角有最大方块的棋盘( testTopLeftCorner ):在左上角有一个最大方块的棋盘上调用 maxTileExists
  7. 测试左下角有最大方块的棋盘( testBottomLeftCorner ):在左下角有一个最大方块的棋盘上调用 maxTileExists
  8. 测试右下角有最大方块的棋盘( testBottomRightCorner ):在右下角有一个最大方块的棋盘上调用 maxTileExists

如果你的实现正确,所有测试都应该通过。

任务3:至少存在一步有效移动

Model.java 中,完成 atLeastOneMoveExists() 方法。(不要修改任何其他文件。)

如果存在任何有效移动,该方法应返回true。如果玩家可以按下某个按钮(上、下、左、右)导致至少一个方块移动,则存在有效移动。

有效移动存在的两种情况:

  1. 棋盘上至少有一个空位。
  2. 有两个相邻(它们之间可以有空位)值相同的方块。

例如,对于下面的棋盘,我们应该返回true,因为至少有一个空位。

|   2|    |   2|    |
|   4|   4|   2|   2|
|    |   4|    |    |
|   2|   4|   4|   8|

对于下面的棋盘,我们应该返回false。无论你在2048中按下什么按钮,什么都不会发生,也就是说,没有两个相邻的方块具有相同的值。

|   2|   4|   2|   4|
|  16|   2|   4|   2|
|   2|   4|   2|   4|
|   4|   2|   4|   2|

对于下面的棋盘,我们会返回true,因为向右或向左移动会合并两个64方块,并且向上或向下移动会合并32方块。换句话说,至少存在两个值相同的相邻方块。

|   2|   4|  64|  64|
|  16|   2|   4|   8|
|   2|   4|   2|  32|
|   4|   2|   4|  32|

测试与调试

要测试你的方法,请运行 TestAtLeastOneMoveExists.java 中的测试。

TestAtLeastOneMoveExists 包含以下测试:

  1. 空位存在( testEmptySpace ):在有空位的棋盘上调用 atLeastOneMoveExists
  2. 有效倾斜存在( testAnyDir ):在任何方向倾斜都是有效移动的满棋盘上调用 atLeastOneMoveExists
  3. 有效左/右倾斜( testLeftOrRight ):在左右倾斜是唯一有效移动的满棋盘上调用 atLeastOneMoveExists
  4. 有效上/下倾斜( testUpOrDown ):在上下倾斜是唯一有效移动的满棋盘上调用 atLeastOneMoveExists
  5. 有最大方块的有效倾斜( testMoveExistsMaxPiece ):在存在某些移动且棋盘上有最大方块的棋盘上调用 atLeastOneMoveExists 。虽然棋盘上有最大方块确实意味着游戏结束,但这不应该由此方法处理。
  6. 无有效移动( testNoMoveExists1 testNoMoveExists5 ):在不存在移动的棋盘上调用 atLeastOneMoveExists

如果你的实现正确,所有测试都应该通过。

由于 atLeastOneMoveExists 方法依赖于 emptySpaceExists 方法,在你通过 TestEmptySpace 中的所有测试之前,不要期望通过这些测试。

一旦你让 maxTileExists atLeastOneMoveExists 正常工作,你也应该通过 TestModel.java 中的所有测试。

TestModel 包含以下测试:

  1. 无有效移动( testGameOverNoChange1 testGameOverNoChange2 ):在没有空位且任何方向的倾斜都不可能的棋盘上调用 gameOver
  2. 存在最大方块( testGameOverMaxPiece ):在包含最大方块且没有其他方块的棋盘上调用 gameOver
  3. 存在有效移动( testGameNotOver1 ):在任何方向倾斜都是有效移动的满棋盘上调用 gameOver
  4. 有效右移和下移( testGameNotOver2 ):在只有一个空位的棋盘上调用 gameOver

你可能遇到的一个常见错误是 ArrayIndexOutOfBoundsException 。以下是 ArrayIndexOutOfBoundsException 错误消息的样子:

数组越界异常

当我们试图访问非法索引的值时,会发生 ArrayIndexOutOfBoundsException 。例如,数组 arr = [4, 2, 2, 4] 的合法索引是0、1、2和3。试图访问 arr[4] arr[-1] 会抛出 ArrayIndexOutOfBoundsException

我们可以通过检查测试输出中提供的堆栈跟踪来评估 ArrayIndexOutOfBoundsException 发生在代码的什么位置。仔细看看前面的例子:

堆栈跟踪

堆栈跟踪向我们展示了导致错误的代码执行行,最上面的一行是最近的。 at game2048rendering.Board.vtile(Board.java:53) 这一行告诉我们关于错误的一些信息。首先,我们可以看到 ArrayIndexOutOfBoundsException 是在 game2048rendering.Board 类的 vtile 方法中触发的。 Board.java:53 指定第53行触发了错误。这是由 tile 方法中的第59行调用的,依此类推。

堆栈跟踪是调试的有用起点。你可以单击堆栈跟踪中蓝色下划线的部分,直接跳转到那行代码。

任务4:理解倾斜操作

现在,是时候实现棋盘倾斜的逻辑了。我们建议先完成任务1-3,然后再阅读规范的后续内容!

点击这里查看此任务的视频介绍。

规则:倾斜

2048 示例

上面的动画展示了一些倾斜操作。以下是上图中显示的合并发生的完整规则。

  1. 两个相同值的方块 合并 成一个方块,其数值为初始数字的两倍。

  2. 作为合并结果的方块在该次倾斜中不会再次合并。例如,如果我们有 [X, 2, 2, 4],其中X表示一个空位,我们将方块向左移动,我们应该得到 [4, 4, X, X],而不是 [8, X, X, X]。这是因为最左边的4已经是合并的一部分,所以它不应该再次合并。

  3. 当运动方向上有三个相邻的方块数字相同时,运动方向上最前面的两个方块合并,后面的方块不合并。例如,如果我们有 [X, 2, 2, 2] 并向左移动方块,我们应该得到 [4, 2, X, X] 而不是 [2, 4, X, X]。

作为这些规则的推论,如果运动方向上有四个相同数字的相邻方块,它们会形成两个合并的方块。例如,如果我们有 [4, 4, 4, 4],那么向左移动时,我们会得到 [8, 8, X, X]。这是因为根据规则3,最前面的两个方块会合并,然后后面的两个方块会合并,但由于规则2,这些合并后的方块(在我们的例子中是8)在该次倾斜中不会自己合并。

你会在上面的动画GIF中找到上述3条规则的应用,所以多看几遍以好好理解这些规则。

倾斜规则测验

你的任务:完成这个可选的 Google表单测验 来检查你对倾斜规则的理解。

这个测验不计入61B课程成绩。

实现倾斜

实现倾斜出人意料地具有挑战性。我们必须考虑分数更新、四个不同的方向、三种不同的合并规则等等。

计算机科学本质上是关于一件事:管理复杂性。为了实现这个复杂的功能,我们需要把问题分解成更小的部分,然后逐个解决。

在未来的作业中,你需要自己想办法把问题分解成更小的部分。对于这个项目,以下是我们决定如何处理倾斜问题的大纲:

  • 分数更新: 一旦我们有了移动所有方块的逻辑,这会更容易,所以让我们把这个留到后面(任务10)。

  • 四个方向: 与其同时担心四个方向的倾斜,不如先从向上方向开始。稍后,在任务9中,我们会向你展示一个巧妙的技巧来推广你的代码,只用额外两行代码就能处理其他三个方向。

  • 关键观察: 当你向上倾斜棋盘时,四列中的每一列都可以独立处理。一列中的方块对另一列中的方块没有影响。受这个观察的启发,我们将编写一个 辅助方法 来倾斜一列。然后,要向上倾斜整个棋盘(任务8),我们将调用该辅助方法来逐个倾斜每一列。

  • 另一个关键观察: 当你向上倾斜一列时,我们需要计算该列中每个方块的最终落位。我们可以在一个方法中完成所有这些,但这很快就会变得复杂。相反,让我们编写另一个 辅助方法 来移动单个方块。然后,要倾斜整列(任务7),我们将调用该辅助方法来逐个移动每个方块。

  • 合并规则: 在我们处理合并之前,让我们先尝试实现方块向上倾斜。然后,一旦方块正确地向上倾斜,我们就可以添加实现合并的逻辑(任务6)。

任务5:向上移动方块(不合并)

Model.java 中,填写 moveTileUpAsFarAsPossible(int x, int y) 方法。(不要修改任何其他文件。)

该方法应将位置 (x, y) 处的方块在其列中尽可能向上移动。

请记住,方块可以向上穿过空方格,直到方块到达顶行,或者方块到达一个空方格,其正上方有另一个方块。

对于此任务,暂时不用担心合并。我们将在下一个任务中添加合并逻辑。

初始代码:Board 类中的 move 方法

Board 类中,有一个方法 move(int x, int y, Tile tile) 。该方法将给定的 tile 移动到棋盘上给定的 (x, y) 位置。

为了使图形流畅运行,每次调用 tilt 时,你应该只对给定的方块调用一次 move 。换句话说,你对 moveTileUpAsFarAsPossible 的解决方案应该只精确调用一次 move 方法。

举个例子,假设你有下面的棋盘并按向上键。

|    |    |    |    |
|    |    |    |    |
|    |    |    |    |
|    |    |    |   2|

我们可以通过以下方式实现这一点:

Tile t = board.tile(3, 0);
board.move(3, 1, t);
board.move(3, 2, t);
board.move(3, 3, t);

但是,图形代码会感到困惑,因为同一个方块不应该移动多次。相反,你需要通过一次调用 move 来完成整个移动,例如:

Tile t = board.tile(3, 0);
board.move(3, 3, t);

如果 (x, y) 位置已经包含另一个具有相同值的方块,则 move 方法将合并这两个方块并相应地更新值。

举个例子,假设你有下面的棋盘并按向上键。

|    |    |    |   2|
|    |    |    |    |
|    |    |    |    |
|    |    |    |   2|

你可以使用以下代码生成正确的结果棋盘,这将合并方块并创建一个值为4的新方块:

Tile t = board.tile(3, 0);
board.move(3, 3, t);

如果 (x, y) 位置已经包含另一个具有不同值的方块,则程序将崩溃。你不能将方块移动到包含另一个具有不同值的方块的方格中。

移动规则测验

为了测试你的理解,你应该完成这个 Google Form 测验 。这个测验(以及以下测验)完全是可选的(即不计分),但 强烈建议 完成,因为它会发现你可能对游戏机制存在的任何概念性误解。你可以根据自己的意愿多次尝试这个测验。

测试与调试

要测试无合并的方块移动,请运行 TestMoveTileUp.java 中的测试。

TestMoveTileUp.java 包含以下测试:

  1. 空列中的单个方块( testOneTile ):在上方没有方块的方块上调用 moveTileUpAsFarAsPossible
  2. 两个方块,不同值( testTwoTiles ):在上方有一个不同值方块的方块上调用 moveTileUpAsFarAsPossible
  3. 两个方块合并且不计分( testTwoTilesMergeNoScore ):在上方有一个相同值方块的方块上调用 moveTileUpAsFarAsPossible 。要通过此测试,不需要实现分数计算。
  4. 两个方块合并并更新分数( testTwoTilesMergeScore ):在上方有一个相同值方块的方块上调用 moveTileUpAsFarAsPossible 。期望分数会相应更新。如果你按从1到10的顺序完成任务,那么在完成任务10之前,这个测试不会通过。

如果到目前为止你的实现是正确的,你应该能通过 testOneTile testTwoTiles

如果你的代码崩溃并显示类似这样的消息:

java.lang.NullPointerException: Cannot invoke "game2048rendering.Tile.value()" because the return value of "game2048logic.Model.tile(int, int)" 是 null

这可能意味着你试图在一个空(null)方块上调用 move 。你不能移动一个不存在的方块,所以程序会崩溃。下面是一个移动不存在的方块的例子(你不应该这样做):

Tile t = null;
board.move(2, 3, t);

你可以使用堆栈跟踪来找出是哪一行代码导致程序崩溃的。

任务6:合并方块

修改 moveTileUpAsFarAsPossible 方法,使其考虑方块合并。

请记住,方块可以向上穿过空方格。当方块遇到一个非空方格时,如果该方格包含另一个相同值的方块,并且该方块在本次倾斜中尚未被合并过,则这两个方块应该合并。

初始代码:Tile 类中的 wasMerged 方法

合并的一个棘手问题是规则2:

作为合并结果的方块在该次倾斜中不会再次合并。例如,如果我们有 [X, 2, 2, 4],其中 X 表示一个空格,然后我们将方块向左移动,我们应该得到 [4, 4, X, X],而不是 [8, X, X, X]。这是因为最左边的 4 已经是合并的一部分,所以它不应该再次合并。

如果在这次倾斜操作的中途,我们有 [4, X, X, 4],并且我们想调用 moveTileUpAsFarAsPossible 将最右边的 4 方块向左移动怎么办?我们必须知道最左边的 4 方块是否在这次倾斜中之前已经被合并过(就像这里的情况一样),或者最左边的 4 方块是否仍然有资格合并(在这种情况下,两个 4 将合并成一个 8)。

为了跟踪方块在这次倾斜中是否已被合并,你可以使用 Tile 类的 wasMerged 方法。别担心,如果合并成功, move 方法会自动更新值。

测试与调试

如果到目前为止你的实现是正确的,你现在应该能通过 TestMoveTileUp.java 中的"两个方块合并且不计分"( testTwoTilesMergeNoScore )测试。

任务7:倾斜列

现在我们有了一个 辅助方法 ,可以将单个方块移动到它应该在的位置(包括合并),我们倾斜整列的方法将会简单得多!

Model.java 中,填写 tiltColumn(int x) 方法。(不要修改任何其他文件。)

该方法应将坐标 x 处的给定列向上倾斜,将该列中的所有方块移动到它们应该在的位置,并合并该列中需要合并的任何方块。

记住使用你的 moveTileUpAsFarAsPossible 辅助方法来保持简单!想一想:你应该在哪些方块上调用这个辅助方法,以及以什么顺序调用?

测试与调试

要测试 tiltColumn(int x) 的实现,请运行 TestTiltColumn.java 中的测试。

TestTiltColumn.java 包含以下测试:

  1. 无合并( testNoMergeColumn ):在具有两个不同值方块的列上调用 tiltColumn
  2. 合并,不计分( testMergingColumn ):在具有两个相同值方块的列上调用 tiltColumn 。要通过此测试,不需要实现分数计算。
  3. 合并并计分( testMergingColumnWithScore ):在具有两个相同值方块的列上调用 tiltColumn 。期望分数会相应更新。

如果到目前为止你的实现是正确的,你应该能通过 testNoMergeColumn testMergingColumn

任务8:向上倾斜

同样,你在上一个任务中编写的辅助方法应该会让这个任务简单得多。这就是将这个大问题分解成更小的辅助方法的力量!

Model.java 中,填写 tilt(Side side) 方法。(不要修改任何其他文件。)

该方法应将整个棋盘向上倾斜,将所有列中的所有方块移动到它们应该在的位置,并合并需要合并的任何方块。

对于此任务,你可以忽略 side 参数。我们将在下一个任务中使用它来处理其他三个倾斜方向。

测试与调试

要测试仅向上倾斜,请运行 TestUpOnly.java 中的测试。

TestUpOnly 包含以下测试:

  1. 向上倾斜( testUpNoMerge ):在不同列中有两个方块的棋盘上调用向上方向的 tilt 。这些方块应该移动到空格中(不合并)。
  2. 向上合并( testUpBasicMerge ):在同一列中有两个相同值方块的棋盘上调用向上方向的 tilt 。这些方块应该合并。
  3. 三重合并( testUpTripleMerge ):在同一列中有三个相同值方块的棋盘上调用向上方向的 tilt 。顶部两个方块应该合并,但底部的方块不应该。
  4. 限制合并( testUpTrickyMerge ):在同一列中有三个方块的棋盘上调用向上方向的 tilt 。顶部两个方块具有相同的值,应该合并。底部方块与合并后的方块具有相同的值,但仍然不应该合并。

如果你的实现是正确的,只有 testUpNoMerge 应该通过。暂时不用担心其他测试:一旦实现了分数更改,它们就应该通过。

任务9:四个方向的倾斜

现在我们已经实现了向上方向的倾斜,我们必须对其他三个方向做同样的事情。

一种可能的方法是将我们的代码复制粘贴四次,并稍微更改几行来处理其他三个方向。这会导致代码混乱、难以阅读,并且有很多机会引入隐蔽的错误。如果你在一个副本中修复了某个问题,但没有在其他三个副本中修复怎么办?

对于这个问题,我们已经提供了一个简洁的解决方案。这将使你只用额外的两行代码就能处理其他三个方向!

初始代码: Side

Side 类是一种特殊类型的类,称为 枚举(Enum) 。 枚举只能取有限值集中的一个值。在这种情况下,我们为4个方向中的每一个都有一个值: NORTH (北)、 SOUTH (南)、 EAST (东)和 WEST (西)。你不需要使用这个类的任何方法,也不需要操作实例变量。

枚举可以用类似 Side s = Side.NORTH 的语法赋值。请注意,我们不是使用 new 关键字,而是简单地将 Side 值设置为四个值之一。同样,如果我们有一个类似 public static void printSide(Side s) 的函数,我们可以如下调用这个函数: printSide(Side.NORTH) ,这会将 NORTH 值传递给函数。

如果你想了解更多关于Java枚举的信息, 请参见 https://docs.oracle.com/javase/tutorial/java/javaOO/enum.html

初始代码:Board 类中的 setViewingPerspective 方法

具体来说, Board 类有一个 setViewingPerspective(Side s) 函数,它会改变 tile move 方法的行为,使它们 表现得好像给定的方向是 NORTH

例如,考虑下面的棋盘:

|    |    |    |    |
|  16|    |  16|    |
|    |    |    |    |
|    |    |    |   2|

如果我们调用 board.tile(0, 2) ,我们会得到 16 ,因为16在第0列第2行。如果我们调用 board.setViewingPerspective(s) ,其中 s WEST ,那么棋盘会表现得好像 WEST 是 NORTH,也就是说,就好像你 把头向左转了90度,如下所示:

|    |    |  16|    |
|    |    |    |    |
|    |    |  16|    |
|   2|    |    |    |

换句话说,我们之前的 16 会在 board.tile(2, 3) 的位置。如果我们调用 board.tilt(Side.NORTH) (使用正确实现的 tilt ),棋盘会变成:

|   2|    |  32|    |
|    |    |    |    |
|    |    |    |    |
|    |    |    |    |

要让棋盘回到原始视角,我们只需调用 board.setViewingPerspective(Side.NORTH) ,这会让棋盘表现得好像 NORTH 就是 NORTH 。如果我们这样做,棋盘现在会表现得好像是:

|    |    |    |    |
|  32|    |    |    |
|    |    |    |    |
|   2|    |    |    |

观察一下,这与你将原始棋盘的方块滑向 WEST 的结果是一样的。

重要提示:在你完成 tilt 调用之前,确保使用 board.setViewingPerspective 将视角设置回 Side.NORTH , 否则会发生奇怪的事情。

为了测试你的理解,试试这个第三个也是最后一个 Google Form 测验 你可以根据自己的意愿多次尝试这个测验。

测试与调试

(注意: moved to make the testing make a little more sense, but it still doesn't fit perfectly).

要测试所有方向的无合并倾斜,请运行 TestTiltNoMerge.java 中的测试。

这些测试的错误消息有所不同,让我们看一个。假设我们运行了所有测试,注意到我们在"限制合并"( testUpTrickyMerge )测试中失败了。点击那个测试后,我们会看到:

testUpTrickyMerge 错误消息

第一行告诉我们倾斜的方向(对于这些测试,它总是 North),然后是倾斜前你的棋盘是什么样子,然后是我们期望棋盘变成什么样子,最后是你的棋盘实际是什么样子。

你会看到我们在一次 tilt 调用中将一个方块合并了两次,导致一个值为 8 的方块,而不是两个值都为 4 的方块。结果,我们的 score (分数)也是不正确的,正如你在棋盘表示的底部看到的那样。

对于其他测试,可能很难立即注意到预期棋盘和实际棋盘之间的区别;对于那些测试,你可以点击错误消息最底部的蓝色"Click to see difference"(点击查看差异)文本,在一个单独的窗口中获得预期(左侧)和实际(右侧)棋盘的并排比较。下面是这个测试的样子:

testUpTrickyMerge 比较

调试这些可能有点棘手,因为很难说出你做错了什么。首先,你应该确定你违反了上面列出的3条规则中的哪一条。在这种情况下,我们可以看到是规则2,因为一个方块被合并了不止一次。这些方法上的 javadoc 注释是很好的资源,因为它们明确列出了它们正在测试的规则/配置。你也可能通过仅查看倾斜前后的棋盘来找出你违反了什么规则。然后,是棘手的部分:重构你现有的代码以正确地考虑该规则。我们建议用笔和纸写下你的代码采取的步骤,这样你可以首先理解为什么你的棋盘是这个样子,然后想出一个修复方案。这些测试只调用一次 tilt ,所以你不需要担心调试多次 tilt 调用。

我们建议使用给定的测试来调试你的代码,不过你也可以通过运行 Main.java 来调试。你还可以通过更改 Main.java 中的 CUSTOM_START USE_CUSTOM_START 变量从特定状态开始游戏,这可能对调试特定测试有帮助。

任务10:更新分数

此时,你的游戏应该能够在所有四个方向上倾斜棋盘,并考虑合并。我们需要实现的最后一件事是分数更新。

规则:分数

当两个值为 v 的方块合并成一个值为 2v 的方块时,玩家的分数增加 2v

例如,如果我们有如下棋盘:

|   2|    |   2|    |
|   4|   4|   2|   2|
|    |   4|    |    |
|   2|   4|   4|   8|

然后按向上键,游戏的状态变为:

|   2|   8|   4|   2|
|   4|   4|   4|   8|
|   2|    |    |    |
|    |    |    |    |

我们将两个 4 合并成一个 8,将两个 2 合并成一个 4,所以分数应该增加 8 + 4 = 12。

初始代码:Model 类中的 score 实例变量

Model 类有一个实例变量 score ,用于跟踪玩家的分数。在更新游戏分数时修改它。

测试与调试

此时,你的2048实现应该已经完成了!你现在应该能通过每个测试文件中的所有测试。

TestMultipleMoves TestNbyN 这样的测试文件会测试你编写的所有代码之间的协作。这样的测试称为 集成测试 ,在测试中非常重要。单元测试是孤立地运行测试,而集成测试是将所有内容放在一起运行,旨在捕捉由于你编写的不同函数之间的交互而产生的隐蔽错误。在你通过其余所有测试之前,不要尝试调试 TestMultipleMoves TestNbyN

如果你在以下测试中失败(点击每个测试查看输入和有问题的输出):

TestNbyN 中的 "N = 1, 2, 3 的非合并倾斜"

输入:

|   4|    |   4|
|   2|  16|   2|
|    |    |   8|

有问题的输出:

|    |    |   8|
|   4|    |   4|
|   2|  16|   8|
TestMultipleMoves 中的 "多次移动"

输入:

|    |    |    |   4|
|    |    |    |   2|
|    |    |    |   2|
|    |    |   4|    |

有问题的输出:

|    |    |   4|   4|
|    |    |    |   4|
|    |    |    |   4|
|    |    |    |    |
TestMultipleMoves 中的 "多次移动 2"

输入:

|    |   4|   4|   4|
|    |    |    |   8|
|    |    |    |  16|
|   4|    |    |    |

有问题的输出:

|   4|   4|   4|   4|
|    |    |    |  16|
|    |    |    |  32|
|    |    |    |    |

这可能意味着你在一个不需要移动的方块上调用了 move 。换句话说,你试图将一个方块移动到它当前的位置。下面是一个将方块移动到其当前位置的例子(你不应该这样做):

Tile t = board.tile(2, 3);
board.move(2, 3, t);

这会导致 move 方法认为我们需要将方块与自身合并,以创建一个值翻倍的方块。(这就是为什么有问题的输出中有一些方块在正确的位置,但值是两倍的原因。)

要修复这个错误,请确保你的代码在方块实际上没有移动时永远不会调用 move 。例如,你可以在每次调用 move 之前添加一个 if 语句,检查你没有将方块移动到它当前的位置。

代码风格

从这个项目开始, 我们将强制执行代码风格 。你必须遵循 代码风格指南 ,否则你会在自动评分器上被扣分。

你可以也应该使用 CS 61B 插件在本地检查你的代码风格。 对于因未检查代码风格而导致的问题,我们不会取消提交次数限制。

提交与评分

对于因你没有 add、commit 或 push 而导致提交错误文件的情况,我们 不会取消提交次数限制 。你已警告过你了。

提交次数限制

对于这个项目,我们将限制你向自动评分器提交代码的次数。你将获得4个提交"令牌",每个令牌在24小时后会重新生成。

评分概览

你的代码将根据是否通过我们提供的测试来评分。没有隐藏测试;你在 Gradescope 上看到的分数就是你这个项目的分数。

Gradescope 只会评分你的 Model.java 文件。如果你编辑任何其他文件,你的编辑将不会被识别,所以不要编辑任何其他文件。

测试在各自的类别中是"全有或全无"的。如果你在某个测试类别中的一个子测试失败了,你将不会获得该类别的分数,尽管你可能通过了其他测试用例。例如,你会在 Gradescope 的 TestModel 类别中看到5个子测试。

以下是在不同完成程度下你在这个项目中可以获得的百分比细分:

  1. TestEmptySpace :10%
  2. TestMaxTileExists :10%
  3. TestAtLeastOneMoveExists :15%
  4. TestUpOnly :10%
  5. TestModel :5%
  6. TestTiltMerge :10%
  7. TestTiltNoMerge :10%
  8. TestMultipleMoves :15%
  9. TestNbyN :15%

请注意, TestMoveTileUp TestTiltColumn 不计分。这些测试旨在作为你的辅助方法的合理性检查。

一旦你将代码推送到 GitHub(即运行了 git push ),你就可以去 Gradescope,找到 proj0 作业,并在那里提交代码。请记住,Gradescope 使用的代码版本是你推送的最新提交,所以如果你在 Gradescope 上提交之前没有运行 git push ,那么测试的将是旧代码,而不是你电脑上的最新代码。

没有隐藏测试。你在 Gradescope 上看到的分数就是你这个项目的分数。