Pro0 2048项目要求

27 阅读23分钟

介绍

此项目的高级概述可在youtu.be/Xzihuj_JZBI…

该项目的目的是让您有机会熟悉Java和课程中使用的各种工具,例如IntelliJ IDE和JUnit用于编写和运行单元测试。尽管在proj0文件夹中您会找到许多文件和大量代码,但您的任务仅限于Model.java,并且仅涉及四种方法。

我们将仅根据您是否成功使程序工作(根据我们的测试)并提交分配的部分来评分。没有隐藏测试。在未来的任务中,我们还将根据样式对您进行评分,但这不是这个项目的情况。我们仍然建议您遵循我们的style61b指南,因为您会发现它有助于创建清洁的代码,但您不会因此而得分。

此任务的规范非常长,并且有很多起始代码。我们建议您在开始编程之前先阅读整个规范。一开始可能会感到压倒性。您可能需要多次重新阅读规范的某些部分才能完全消化它,并且在完成项目的早期部分之前,一些后面的部分可能不完全清楚。最终,我们希望您离开此经历时能够感到自己有能力完成这样一个庞大的任务。

关于游戏

您可能已经看过并且可能玩过“2048”游戏,这是由Gabriele Cirulli编写的单人电脑游戏,并基于Veewo Studio早期的“1024”游戏(请参见他的2048在线版本)。

在这个项目中,您将构建此游戏的核心逻辑。也就是说,我们已经编写了所有GUI代码、处理按键以及大量其他脚手架。您的工作将是完成最重要和最有趣的部分。

具体而言,您将在Model.java文件中填写4个方法,这些方法控制用户按下某些键后会发生什么。

游戏本身非常简单。它在一个4×4的正方形网格上进行,每个正方形可以是空的,也可以包含一个带有整数的方块——大于或等于2的2的幂。在第一次移动之前,应用程序将在最初为空的棋盘上的随机方格中添加一个包含2或4的方块。选择2或4是随机的,选择2的概率为75%,选择4的概率为25%。

然后,玩家通过箭头键选择一个方向来倾斜棋盘:北、南、东或西。所有方块都沿着该方向滑动,直到在运动方向上没有空间为止(可能一开始就没有)。一个方块可能会与另一个方块合并,从而为玩家赚取积分。

下面的GIF是一个示例,展示了一些移动的结果。

image

以下是上面显示的合并发生时的完整规则。

  • 两个相同值的方块合并为一个包含初始数字两倍的方块。
  • 合并的结果不会在同一次移动中再次合并。例如,如果我们有[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中列出了每个3个规则的应用,因此请观看几次以充分理解这些规则。

为了测试您的理解,您应该完成此Google表单测验。此测验不是61B课程成绩的一部分。

如果倾斜没有改变棋盘状态,则不会随机生成任何新方块。否则,将在空方格上向棋盘添加一个随机生成的方块。注意:您的代码不会添加任何新方块!我们已经为您完成了这部分。

您可能还注意到屏幕底部有一个“得分”字段,它在每次移动时都会更新。得分并不总是每次移动都会改变,而是仅在两个方块合并时才会改变。您的代码需要更新得分。

每当两个方块合并形成一个更大的方块时,玩家将获得新方块上的积分。当当前玩家没有可用移动(没有倾斜可以改变棋盘)或移动形成包含2048的方块时,游戏结束。您的代码将负责检测游戏何时结束。

“最高得分”是用户在该游戏会话中获得的最高分数。它直到游戏结束才会更新,因此在动画GIF示例中它始终为0。

任务哲学和程序设计

此规范部分的视频概述可在youtu.be/3YbIOga6ZdQ…

在这个项目中,我们为您提供了大量的起始代码,其中使用了许多Java语法,我们尚未涵盖,甚至包括我们课程中永远不会涵盖的一些语法。

这里的想法是,在现实世界中,您经常会使用自己不完全理解的代码库,并且必须进行一些修补和实验才能获得所需的结果。不用担心,下周我们将开始第1个项目,您将有机会从头开始。

下面,我们描述了给定骨架代码架构背后的一些思想,该代码由Paul Hilfinger创建。您不需要理解每个细节,但可能会觉得有趣。

骨架展示了两种常见的设计模式:模型-视图-控制器模式(MVC)和观察者模式。

MVC模式将我们的问题分为三个部分:

  • 模型表示被表示和操作的主题内容-在这种情况下,包括棋盘游戏的状态和可以修改它的规则。我们的模型驻留在Model,Side,Board和Tile类中。 Model的实例变量完全确定游戏的状态。注意:您只会修改Model类。
  • 模型的视图,将游戏状态显示给用户。我们的视图驻留在GUI和BoardWidget类中。
  • 游戏的控制器,将用户操作转换为模型上的操作。我们的控制器主要驻留在Game类中,尽管它还使用GUI类读取按键。

MVC模式不是61B的主题,您也不需要在考试或未来的项目中了解或理解此设计模式。

所使用的第二个模式是“观察者模式”。基本上,这意味着模型实际上不会向视图报告更改。相反,视图将自己注册为Model对象的观察者。这是一个有点高级的主题,因此我们在此不提供其他信息。

现在,我们将介绍您将与之交互的不同类。

Tile类

这个类表示棋盘上的编号方块。如果类型为Tile的变量为null,则它在棋盘上被视为空方块。您不需要创建这些对象中的任何一个,但由于您将在Model类中使用它们,因此需要了解它们。您需要使用此类的唯一方法是.value(),它返回给定方块的值。例如,如果Tile t对应于值为8的方块,则t.value()将返回8。

Side类

Side类是一种特殊类型的类,称为枚举。枚举具有受限功能。具体而言,枚举只能取有限集合中的一个值。在这种情况下,我们为4个方向中的每个方向都有一个值:NORTH,SOUTH,EAST和WEST。您不需要使用此类的任何方法或操作实例变量。

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

如果您想了解更多关于Java枚举的信息,请参见

Mode类

这个类表示整个游戏的状态。Model对象表示2048游戏。它具有用于棋盘状态的实例变量(即所有Tile对象的位置,得分等),以及各种方法。当您到达此项目的第四个任务(编写tilt方法)时,其中一个挑战将是找出哪些方法和实例变量是有用的。

Board类

这个类表示方块板本身。它有三个您将使用的方法:setViewingPerspective,tile,move。可选地,为了进行实验,您可以使用getRandomNonNullTile。

在此任务中,您只需要编辑Model.java文件。Gradescope只会使用其他文件的骨架版本,因此如果您例如对Tile.java进行编辑,则Gradescope将无法识别。

开始

首先,确保你已经完成了lab 1。如果你没有完成该实验,将不能开始这个项目,所有需要你做的初始化工作都早lab 1中。

为了确保设置都正确,请打开game2048文件夹并右键单击Main Java文件:您将看到几个选项,但我们关心的是绿色的“Run Main.main()”按钮。它应该看起来像以下图片:

单击该按钮以启动2048游戏。这将启动一个带有空白棋盘的新窗口。现在只需关闭窗口,我们将在后面的“主任务:构建游戏逻辑”部分回到它。

如果没有弹出任何内容,则表示您的设置不正确。您应重新执行上述步骤,以确保您没有遗漏任何内容,但不要花费超过10分钟。最好在TA的帮助下解决设置问题,这意味着您应该在Ed上发布或去办公室。如果您在Ed上发布,请告诉我们您所做的/尝试的一切,以便我们可以清楚地了解错误是什么。包括所有截图,特别是您可能遇到的任何错误消息。

您可能会遇到的一个奇怪的问题是,代码编译和运行正确,但仍然在IntelliJ中得到红色下划线。转到Model类,并找到addTile方法。这是我们提供的一个方法,但您可能会看到tile变量被红色下划线标记,并显示以下错误消息:

image

但我们知道很明显它是正确的,因为1)代码已运行,2)它是起始代码!虽然IntelliJ非常强大,但有时会出现这种情况。为了解决这个问题,您应该转到文件>无效/重启,然后在以下窗口中单击“无效并重启”

image

这将需要一两分钟,因为IntelliJ正在重新索引您的JDK并从头设置您的项目。完成后,您应该在源文件中看不到红色下划线。

除非上述设置正确,否则您将无法在项目上工作,因此请尽快进行设置。

你的任务

您的任务是修改和完善Model类,具体是emptySpaceExists,maxTileExists,atLeastOneMoveExists和tilt方法。其他所有内容都已为您实现。我们建议按照这个顺序完成它们。前两个相对直接。第三个(atLeastOneMoveExists)更难,最后一个tilt方法可能会非常困难。我们预计tilt需要花费您3到10个小时的时间。前三种方法将处理游戏结束条件,而最后一个tilt方法将修改用户按键后的棋盘。您可以阅读checkGameOver方法的非常短的正文,以了解如何使用您的方法来检查游戏是否结束。

让我们首先看一下前三种方法:

public static boolean emptySpaceExists(Board b)

如果给定棋盘中的任何一个方块为空,则此方法应返回true。在此项目中,您不应以任何方式修改Board.java文件。对于此方法,您将需要使用Board类的tile(int col,int row)和size()方法。没有其他方法是必要的。

注意:我们使用特殊关键字private设计了Board类,这禁止您直接使用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时有一个按钮(向上、向下、向左或向右),用户按下该按钮会导致至少一个方块移动,则这样的按键被视为有效的移动。

有两种情况下会有有效的移动:

  • 棋盘上至少有一个空格。
  • 有两个相邻的值相同的方块。 例如,对于下面的棋盘,我们应该返回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中的测试。

主任务: 构建游戏逻辑

任务的第四部分也是最后一部分是实现倾斜(tilt)方法。只有在通过TestEmptySpace、TestMaxTileExists和TestAtLeastOneMoveExists中的所有测试后,才应该开始编写该方法。

计算机科学实际上只涉及一件事:管理复杂性。编写倾斜方法是一个重要的经验,让您有机会尝试这个过程。我必须警告您,这可能是一个令人沮丧的经验。您可能会尝试几种方法,最终都会失败,然后不得不重新开始。

在我们开始讨论倾斜应该如何工作之前,让我们尝试运行游戏。

打开Main类并单击运行按钮。您应该会看到游戏弹出。尝试按箭头键。您应该会发现没有任何反应。这是因为您还没有实现倾斜方法。当您完成编写倾斜方法后,您就可以玩游戏了。

public boolean tilt(Side side)

倾斜方法实际上是移动所有方块的工作。例如,如果我们有以下棋盘:

| 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。
  • 如果棋盘的任何内容发生更改,我们必须将changed本地变量设置为true。这是因为在倾斜的骨架代码的末尾,您可以看到我们调用了一个setChanged()方法:这通知GUI需要绘制一些东西。您不需要自己调用setChanged,只需修改changed本地变量即可。

所有棋盘上的方块移动都必须使用Board类提供的move方法完成。必须使用Board类提供的tile方法访问棋盘的所有方块。由于GUI实现的一些细节,每次调用tilt时,您应该仅对给定方块调用一次move。我们将在本文档的提示部分进一步讨论此约束。

这个视频提供了快速开始编写此方法的概述。

小提示

我们强烈建议您首先仅考虑向上的方向,即当提供的参数为Side.NORTH时。为了支持您,我们提供了一个名为TestUpOnly的类,其中包含四个测试:testUpNoMerge、testUpBasicMerge、testUpTripleMerge和testUpTrickyMerge。您会注意到这些测试仅涉及单个向上移动。

在考虑如何实现向上方向时,请考虑以下内容:

在给定的列中,顶行(第3行)的方块保持不变。第2行的方块可以向上移动,如果上面的空间为空,则可以向上移动一次,如果上面的空间具有与自身相同的值,则可以向上移动一次。换句话说,在迭代行时,从第3行开始向下迭代是安全的,因为没有办法让方块在移动一次后再次移动。

虽然听起来不会很难,但实际上确实很难。准备好拿出笔记本并解决一堆例子。尽量编写优雅的代码,尽管这个问题很难做到优雅。我们强烈建议创建一个或多个辅助方法,以使您的代码保持清洁。例如,您可能会有一个处理棋盘单个列的辅助函数,因为每个列都是独立处理的。或者您可能会有一个辅助函数,可以返回所需的行值。

提醒:您只应在给定的方块上调用一次move。换句话说,假设您有以下棋盘并按向上箭头键。

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

我们可以这样实现:

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

但是,GUI会感到困惑,因为同一个方块不应该在一次调用setChanged中多次移动。相反,您需要使用一次move完成整个移动,例如。

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

从某种意义上说,难点在于确定每个方块应该停留在哪一行。

为了测试您的理解,您应该完成此Google表单测验。这个测验(以及以下测验)是完全可选的(即没有分数),但强烈建议,因为它将找出您可能对游戏机制存在的概念误解。您可以尝试任意次数的测验。

要知道何时应该更新分数,请注意board.move(c,r,t)方法在将方块t移动到列c和行r会替换现有方块时返回true(即您有一个合并操作)。

更糟糕的是,即使在为向上方向工作后,您还必须为其他三个方向做同样的事情。如果您这样做,您将获得大量重复的,稍微修改的代码,并有充分的机会引入晦涩的错误。

对于这个问题,我们提供了一个干净的解决方案。这将使您只需额外添加两行代码即可处理其他三个方向!具体来说,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)处。如果我们使用正确实现的tilt调用board.tilt(Side.NORTH),则棋盘将变为:

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

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

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

请注意,在完成调用tilt之前,请务必使用board.setViewingPerpsective将视角设置回Side.NORTH,否则会发生奇怪的事情。

为了测试您的理解,请尝试这第三个也是最后一个Google表单测验。您可以尝试多次。

测试

虽然在未来我们希望您能够测试自己的程序,但是对于这个项目,我们为您提供了完整的测试套件。

测试分为5个文件:TestEmptySpace、TestMaxTileExists、TestAtLeastOneMoveExists、TestUpOnly和TestModel。每个文件测试代码的特定部分,除了TestModel之外,它测试了您编写的所有内容之间的协调。这样的测试称为集成测试,在测试中非常重要。虽然单元测试在隔离环境中运行,但集成测试将所有内容一起运行,旨在捕获由于不同函数之间的交互而导致的晦涩错误。

因此,在通过其余测试之前,请不要尝试调试TestModel!实际上,我们讨论测试的顺序就是您应该尝试的顺序。

现在我们将查看每个测试,并向您展示如何读取错误消息。

TestEmptySpace

这些测试将检查您的emptySpaceExists方法的正确性。如果您未通过其中一个测试,错误消息将如下所示:

TestEmptySpace all fail

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

testCompletelyEmpty

现在右侧是此测试的隔离错误消息。顶部有一个有用的消息:“Board is full of empty space”,后面是一个板子的字符串表示形式。您会发现它明显是空的,但是我们的emptySpaceExists方法返回false,导致此测试失败。代码顶部的javadoc注释还包含一些有用的信息,以防您未通过测试。

TestAtLeastOneMoveExists

这些测试将检查您的atLeastOneMoveExists方法的正确性。错误消息与上述两个测试类似。由于atLeastOneMoveExists方法依赖于emptySpaceExists方法,因此在通过TestEmptySpace中的所有测试之前,不应该期望通过这些测试。

TestUpOnly

这些测试将检查您的tilt方法的正确性,但仅限于向上(Side.NORTH)的方向。这些测试的错误消息不同,因此让我们看一个例子。假设我们运行所有测试,发现未通过testUpTrickyMerge测试。单击该测试后,我们将看到以下内容:

testUpTrickyMerge Error Message

第一行告诉我们被倾斜的方向(对于这些测试,它将始终是North),然后是倾斜前的板子,接着是我们期望的板子,最后是您的板子实际上看起来像什么。

您会发现,我们在单次tilt调用中两次合并了一个块,导致只有一个值为8的块,而不是两个值为4的块。因此,我们的分数也是不正确的,正如您在板子表示形式底部所看到的那样。

对于其他测试,可能很难立即注意到期望板子和实际板子之间的差异。对于这些情况,您可以单击错误消息底部的蓝色“单击以查看差异”文本,在一个单独的窗口中获得期望板子(在左侧)和实际板子(在右侧)的并排比较。以下是该测试的外观:

testUpTrickyMerge Comparison

调试这些可能有点棘手,因为很难确定您做错了什么。首先,您应该确定您违反了上述3条规则中的哪一条。在这种情况下,我们可以看到它是规则2,因为一个块正在多次合并。这些方法上的javadoc注释是很好的资源,因为它们明确列出了它们正在测试哪个规则/配置。您还可以通过查看前后板子来确定自己违反了哪个规则。然后,就来到了棘手的部分:重构您现有的代码以正确考虑该规则。我们建议先用纸笔写出代码所采取的步骤,以便您首先了解为什么您的板子看起来像这样,然后想出解决方法。这些测试只调用一次tilt,因此您不需要担心调试多次调用tilt。

TestModel

这些测试将检查所有内容的正确性。其中大部分测试类似于TestUpOnly中的测试,因为它们只调用一次tilt,但我们还有用于gameOver(测试所有emptySpaceExists、maxTileExists和atLeastOneMoveExists方法)以及在序列中进行多次tilt调用的测试。

这些测试的错误消息与TestUpOnly中的完全相同,并且javadoc注释同样有用,可以帮助您确定测试正在测试什么。

不必担心测试的实际代码:您不需要理解或修改任何这些内容,但是如果您感到非常有雄心壮志,可以阅读并了解测试编写的工作原理,并添加自己的一些测试。