Everlasting Pages

返回

Project 3:CS61BYoW

目录#

引言#

在项目 3 中,你将创建一个能够生成可探索世界的引擎。这是一个大型设计项目,需要你和一位搭档完成从构思到展示的整个开发流程。这个项目的目标,是让你学会在几乎没有起始代码的情况下管理一个规模更大的代码库,希望借此模拟类似产品开发周期的过程。

因此,本项目的评分方式也会和其他项目不同。世界的设计与实现并不存在唯一的“正确答案”,所以除了一个非常宽泛的自动评分器之外,我们还会用一种类似实习或工作中绩效评估的方式来评价你。虽然这意味着评分会带有一定主观性,但我们保证会做比较友善的老板,并像任何好老板尊重认真工作的员工一样尊重你。如果你觉得评分方案不公平,请和我们沟通。

这个项目需要你进行大量探索与实验。在整个过程中,经常上网搜索答案应当成为一种常规活动——但不要搜索或使用往届学期的完整解决方案。请记住,这是一个非常开放的项目,没有绝对的正确或错误答案。不过,有些实现方式和设计想法确实会比另一些更好。你很可能需要经历若干次迭代,最终才能确定一个自己满意的方案;这既是正常的,也是预期中的过程。

也就是说,这个项目的核心是软件工程

你不必使用课堂上讲过的任何高级数据结构或概念,例如 A*、最小生成树(MST)、并查集(Disjoint Sets)等。本项目考察的是软件工程,而不是数据结构或算法。课堂中学过的数据结构和算法可能会让你的代码显著更简单、更高效,但不要仅仅因为课程讲过,就强行把它们用进项目。只有当你确实理解并愿意在实现中使用这些工具时,再使用它们。

这里有一个来自 2018 年春季学期的视频播放列表,其中介绍了完成本项目的一些建议。请注意,API 后来有少量变化,但其中的核心思想仍然适用。这些视频所使用的幻灯片可以在这里找到。

本作业有几个关键截止时间:

  • 阶段 0——组队:必须在太平洋时间 3 月 31 日星期三晚上 11:59 之前提交 Project 3 Partnership form。之后你将不能更换搭档。
  • 开始作业前,请先阅读并理解合作指南
  • **阶段 1——世界生成:**4 月 16 日晚上 11:59 前提交到 Gradescope。
  • **阶段 2——交互性:**4 月 27 日晚上 11:59 前提交到 Gradescope。
  • **额外加分:**为你的世界加入有创意的机制,并在 4 月 30 日前制作一个公开的 YouTube 视频展示你的作品。

项目阶段 2 不能使用 slip days 延迟提交,因为它会在实验课验收中由助教评分,47b 的学生也一样。额外加分部分同样不能延迟。理论上,你可以晚交阶段 1 以及配套的 Lab 12 和 Lab 13,但阶段 2 是建立在这些任务之上的,因此如果你晚交这些前置任务,基本不太可能仍然按时完成阶段 2。

下面正式进入作业说明。

概述#

接下来的四周中,你的任务是设计并实现一个基于二维图块的世界探索引擎。

所谓“基于图块”,是指生成的世界由一个二维图块网格组成。所谓“世界探索引擎”,是指你的软件会创建一个世界,用户可以在这个世界里行走、探索,并与其中的对象互动。你的世界采用俯视视角。

作为一个远比本项目复杂的例子,NES 游戏《Zelda II》在某些场景下就是一个基于图块的世界探索引擎,而它碰巧也是一款电子游戏:

《Zelda II》俯视场景

你构建的系统可以使用图形图块,如上图所示;也可以使用文字图块,就像下面这个游戏一样:

Brogue 的文字图块界面

我们会提供:

  • 一个图块渲染器;
  • 一小组起始图块;
  • 几个必须实现的方法的声明。

这些方法会被你的世界引擎使用,也会被自动评分器调用。

本项目有两个主要截止时间。到第一个截止时间时,你应当能够生成符合下文要求的随机世界。到第二个截止时间时,用户应当能够探索这个世界并与之互动。

本项目最重要的目标,是让你有机会尝试管理构建大型系统时随之而来的巨大复杂度。先提醒一句:你构建的系统对普通用户来说大概不会特别好玩。对于初学者而言,三周时间远远不足以完成一款真正成熟的游戏。不过,我们仍希望你能从这个项目中获得成就感,而你生成的世界甚至可能会很漂亮。

骨架代码结构#

骨架代码中有两个你会使用的关键包:

  • byow.TileEngine
  • byow.Core

byow.TileEngine 提供一些基础的渲染方法、图块的基本代码结构,其中包括:

  • TERenderer.java:包含与渲染有关的方法。
  • TETile.java:用于表示世界中图块的类型。
  • Tileset.java:课程提供的图块库。

重要:不要修改 TETile.javacharacter 字段或 character() 方法,否则可能会导致自动评分结果异常。

另一个包 byow.Core 包含所有与图块本身无关的内容。我们建议你把本项目的大部分代码放在 byow.Core 包中,但这不是强制要求。byow.Core 包中自带以下类:

  • RandomUtils.java:提供一些方便的随机数相关工具方法。
  • Main.java:整个系统的用户入口。它读取命令行参数,并调用 Engine.java 中对应的函数。
  • Engine.java:包含用户与系统交互所需的两个方法。

byow.Core.Engine 提供了两个与系统交互的方法。

第一个方法是:

public TETile[][] interactWithInputString(String input)
java

这个方法接收一系列键盘输入,并在处理所有给定按键后,返回一个表示当前世界状态的二维 TETile 数组。具体行为将在后文说明。

第二个方法是:

public void interactWithKeyboard()
java

这个方法从键盘获取输入,并在每次按键后将结果绘制到屏幕。Lab 12 会介绍如何渲染图块,Lab 13 会介绍如何获取用户输入。

本项目会大量使用 StdDraw。这是一个具备基本图形渲染能力的库,同时也支持键盘和鼠标点击交互。项目进行过程中,你可能需要查阅 StdDraw 的 API 文档,地址在这里

你的项目只能使用:

  • Java 标准库,即从 java.* 导入的内容;
  • 仓库中课程提供的库。

不过,仅限额外加分视频,你可以按需使用外部库。提交给阶段 2 自动评分器以及最终验收的版本,不能使用骨架代码以外的任何外部库。

重要:除非变量带有 final,否则不要使用静态变量。2018 年有很多学生因为使用非 final 静态变量而遇到严重调试问题。非 final 静态变量会给系统增加巨大的复杂度。

另外,不要调用 System.exit(),否则自动评分器本身也会退出并判定失败。

阶段 1:世界生成#

如上所述,本项目的第一个目标是编写一个世界生成器。你的世界必须满足以下要求:

  • 世界必须是一个二维网格,并使用课程提供的图块引擎绘制。图块引擎会在 Lab 12 中介绍。
  • 世界必须使用伪随机方式生成。Lab 12 会介绍伪随机性。
  • 生成的世界必须包含彼此可区分的房间和走廊,也可以包含室外空间。
  • 至少一部分房间应当是矩形,但你也可以支持其他形状。
  • 世界生成器必须能够生成带转弯的走廊;等价地,也可以生成彼此相交的直走廊。
  • 世界中房间和走廊的数量应当是随机的。
  • 房间和走廊的位置应当是随机的。
  • 房间的宽度和高度应当是随机的。
  • 走廊宽度应为 1 或 2 个图块,长度应当随机。
  • 房间和走廊必须具有与地板在视觉上明显不同的墙壁。墙壁和地板也必须与未使用空间在视觉上明显不同。
  • 房间和走廊必须相互连通,也就是说,相邻房间或走廊的地板之间不能存在断层。
  • 所有房间都必须能够到达,不能存在完全没有入口的房间。
  • 每次生成的世界应当具有显著差异。不能总是生成同一种基本布局,仅仅改变一些容易预测的细节。

下面的图片展示了一个满足全部要求的世界示例。点击图片可以查看更高分辨率版本。在图中:

  • # 表示墙壁图块;
  • 点号表示地板图块;
  • 一段金色墙壁表示一扇上锁的门;
  • 所有未使用空间均为空白。

满足规范的世界示例

完成 Lab 12 后,你就可以开始编写世界生成算法。

你最后非常可能会把自己的第一版世界生成算法整个丢掉。

这很正常。在真实系统开发中,先实现几个完全不同的版本,再最终得到一个自己满意的方案,是很常见的事情。上图中的房间生成算法是作者的第三版,而且最终版本比前两个版本都简单得多。

你可以上网搜索一些很酷的世界生成算法。你不能直接从现有游戏或在线图形演示中复制粘贴代码,但可以参考网络代码获得灵感。务必使用 @source 标签标注你的参考来源。

你也可以试玩现有的二维图块游戏寻找灵感:

  • Brogue 是一个尤其优雅、漂亮的游戏。
  • Dwarf Fortress 则是一个极其复杂、甚至复杂到近乎荒诞的世界生成引擎。

默认图块集与图块渲染引擎#

课程提供的图块渲染引擎接收一个二维 TETile 对象数组,并将其绘制到屏幕上。暂时把这个数组称为:

TETile[][] world
java

world[0][0] 对应世界左下角的图块。

第一个坐标是 x 坐标。例如:

world[9][0]
java

表示从左下角向右移动 9 个位置的图块。

第二个坐标是 y 坐标,并且向上移动时数值增大。例如:

world[0][5]
java

表示从左下角向上移动 5 个位置的图块。

数组中的所有值都必须非 null。也就是说,在调用 renderFrame 之前,务必把整个数组填满。

请确保你真正理解世界网格的坐标方向。

如果你不确定,可以编写一些很短的示例程序,将简单内容绘制到网格上,以加深理解。如果你把 x 和 y,或者向上和向下弄反,调试过程会变得极其混乱。

我们在 Tileset.java 中提供了一小组默认图块,它们可以作为创建 TETile 对象的良好示例。我们强烈建议你也添加自己的图块。

图块引擎还支持图形图块。要使用图形图块,只需把图块图片的文件名作为 TETile 构造器的第五个参数传入即可。图片必须是 16 × 16 像素,并且最好使用 PNG 格式。网上有大量适用于图块游戏的开源图块集,你可以自由使用。

你创建的每个 TETile 对象都应当拥有一个其他图块没有使用的唯一字符。即使你使用自己的图片来渲染图块,每个 TETile 仍然必须有自己独立的字符表示。

如果你没有提供文件名,或者对应文件无法打开,图块引擎会改用你提供的 Unicode 字符。这意味着,如果其他人的电脑上没有你指定位置的图片文件,你的世界仍然能够显示,只不过会使用 Unicode 字符,而不是你选择的纹理。

图块渲染引擎依赖 StdDraw。除非你非常清楚自己在做什么,否则不建议使用 setXScalesetYScale 一类的 StdDraw 命令,因为这可能会大幅改变,甚至破坏系统的美——学——气——质。

启动你的程序#

程序将通过运行 Main 类的 main 方法启动。你会看到,这个方法会根据用户希望如何与程序交互,调用两个方法中的一个。

用户可以通过命令行参数向程序描述:

  • 如何生成随机世界;
  • 希望进行哪些探索操作。

在这种情况下,Main.main 会调用:

Core.Engine.interactWithInputString(String s)
java

通过输入字符串运行 Core.Main.main 时,命令行参数格式必须为:

-s inputString
text

其中 inputString 就是传给 interactWithInputString() 的输入。

如果没有提供命令行参数,则表示用户希望通过 GUI 菜单与程序交互,并用键盘探索世界。在这种情况下,Main.main 会调用:

Core.Engine.interactWithKeyboard()
java

在阶段 1 中,你的项目不需要支持 interactWithKeyboard(),但必须支持 interactWithInputString()

具体而言,你应当能够处理如下格式的输入:

N#######S
text

其中:

  • 每个 # 都是一个数字;
  • # 的数量可以任意多;
  • N 表示请求创建一个新世界;
  • 中间的数字表示随机种子;
  • S 表示种子输入已经完成。

我们建议你在进入项目阶段 2(交互性)之前,不要实现 Core.Engine.interactWithKeyboard(),不过你也可以随时提前实现。使用 interactWithInputString 测试和调试世界生成器会更方便。

我们也建议你在阶段 2 之前不要使用 Core.Main.main。完成阶段 1 时,更简单的做法是在你最常使用的类中编写自己的 main 方法,由它:

  1. 创建渲染器;
  2. 调用 interactWithInputString
  3. 绘制返回结果。

如果你编写自己的 main 方法,可以忽略 -s 标志,直接把输入字符串作为程序唯一的参数。

要在 IntelliJ 中为 main 方法提供命令行参数:

  • 如果你已经运行过这个 main 方法,请进入 Run → Edit Configurations
  • 然后在 Program Arguments 输入框中填写你的字符串,如下图所示。

在 IntelliJ 中设置程序参数

如果你还没有运行过 main 方法,可以右键点击 main 方法旁边的绿色箭头,然后选择 Modify Run Configurations,进入同一个设置窗口:

打开运行配置

最后,我们建议你尽量少修改 Core.Main 类。把程序中的实际工作委托给你自己创建的其他类,是更好的设计。

运行 Core.Engine.interactWithKeyboard() 时,程序必须显示一个主菜单。这个菜单至少应提供以下选项:

  • 创建新世界;
  • 加载以前保存的世界;
  • 退出。

主菜单必须能够完全通过键盘操作,并使用:

  • N:新世界(New World);
  • L:加载世界(Load World);
  • Q:退出(Quit)。

你可以自行添加更多选项或其他导航方式。

主菜单示例

用户在键盘上按 N 创建新世界后,程序应提示用户输入一个“随机种子”。该种子是用户选择的 long 值,之后会用于随机生成世界,具体方式将在后文以及 Lab 12 中介绍。

用户界面应显示用户目前已经输入的种子值。

用户输入种子的最后一位数字后,应按下 S,告诉系统整个种子已经输入完毕。

世界生成器必须能够处理从正数开始、最大不超过以下值的任意种子:

9,223,372,036,854,775,807
text

对于大于这个值的种子,行为不作规定。

“Load”命令的行为将在本说明后面介绍。

如果系统通过 Core.Engine.interactWithInputString() 启动,则:

  • 不应显示菜单;
  • 不应在屏幕上绘制任何内容;
  • 除此以外,系统应把输入字符串当作人类用户通过 Core.Engine.interactWithKeyboard() 逐个按下相应按键来处理。

例如:

Core.Engine.interactWithInputString("N3412S")
java

程序应使用种子 3412 生成一个世界,并返回生成的二维图块数组。

输入字符串中的字母可以是大写或小写。你的引擎必须同时接受两种形式。例如,Nn 都应开始世界生成流程。

使用 interactWithInputString() 时,不应渲染任何图块,也不应播放任何声音。

你可以为用户增加额外选项,例如:

  • 选择角色属性;
  • 指定世界生成参数;
  • 选择扮演的生物类型。

比如,如果你希望用户选择角色种类,可以在主菜单中增加第四个选项:

S:选择生物并创建新世界
text

这些额外选项可以具有你自行设计的行为。但是,NLQ 的行为必须严格符合本说明。

阶段 1 总结#

在阶段 1 中,你应当能够向 Main.main 提供输入字符串,让程序创建一个符合前文要求的世界。

调试时,你应通过自己编写的 main 方法渲染世界并检查结果。不过,对于自动评分器:

  • interactWithInputString() 不应渲染世界;
  • 它只应返回一个 TETile[][] 数组。

不同种子生成的世界应当在视觉上明显不同。

若要在 Office Hours 或 Gitbug 中获得帮助,你还需要维护一份反映当前项目状态的设计文档。下一节将介绍设计文档。

设计文档#

由于我们没有为项目 3 提供大量骨架代码,而且项目本身非常开放,不同学生的 BYOW 实现预计会有很大差异。

为了让课程工作人员能够为你提供项目支持,我们要求你维护一份反映项目当前状态的设计文档。

如果你没有提供最新设计文档,我们将无法处理你的 Gitbug。若你在 Office Hours 中提出与自己实现细节有关的问题,但没有最新设计文档,我们也无法提供充分帮助。否则,远程理解你的整个设计是不现实的。

因此,在提交 Gitbug 之前,请确保 proj3 文件夹中的设计文档准确反映项目当前状态。

请注意,这项练习的目的是帮助你,也帮助我们更好地支持你。我们强烈建议你在整个开发过程中持续更新设计文档。例如,在 Office Hours 中,有设计文档可以省去你向助教口头解释整个实现的大量时间。

设计文档是软件工程师的重要工具。简而言之,设计文档描述程序的结构,包括:

  • 程序由哪些概念模块组成;
  • 使用了哪些抽象,在面向对象程序中通常指类;
  • 每个类采用哪些实现策略,包括数据结构与算法;
  • 不同模块和抽象之间如何交互。

把程序设计写下来,可以帮助我们:

  • 提前规划项目;
  • 整理思路;
  • 在编写代码前发现战略层面的设计缺陷。

此外,在 BYOW 这样的大型项目中,开发过程中经常需要推理多个组件之间如何相互作用,设计文档也会在此时提供帮助。

开始编写任何代码之前,请按照下面的指南,为 BYOW 程序中的每项功能制定计划,并说服自己该设计是正确的。

设计文档的编写是一个迭代过程。提出最初设计后,你可能会发现其中存在缺陷,这时应重新审视设计,并根据新发现更新文档。

注意:设计文档永远不会被评分。

不过,如果你没有方便可用且保持更新的设计文档,我们很可能无法在 Gitbug 和 Office Hours 中帮助你。

设计文档指南#

请使用下面的格式编写 BYOW 设计文档。

设计文档应使用 Markdown 编写。Markdown 可以让你方便地格式化和排版文本文件。请组织好文档结构,使你自己或课程工作人员都能轻松阅读。

你应当在 proj3 文件夹中的 README.md 文件内完成设计文档。在 IntelliJ 中打开该文件时,右侧会自动显示排版后的预览。

设计文档各部分#

1. 类与数据结构#

在这一部分列出所有类定义。对于每个类,列出它的实例变量(如果有),并简要描述每个变量及其在类中的用途。

本节说明应尽可能简洁。完整解释留到后续章节。如果你发现文档太啰嗦,也可以适当缩短这一部分。

2. 算法#

这一部分用于说明你的代码如何工作。

对于每个类,给出其中各个方法的高层描述。不要逐行解释代码,而应写成类似方法上方 Javadoc 注释的内容,并包含你所考虑的边界情况。

课程工作人员也读过项目说明,因此不要重复或改写说明中已经写明的要求。本节应描述的是:你的代码如何实现说明中的要求。

你也可以在这里说明不同类如何通过各自的方法进行交互。

本节长度取决于任务和设计的复杂度,但我们更偏好简单清晰的说明。以下是一些排版建议:

  • 对于复杂任务,例如“在随机位置添加随机尺寸的房间”,建议把任务拆分为多个部分,每个部分单独描述算法。从最简单的组件开始,逐步搭建设计。不同子任务可能属于不同类。如果某个算法调用了另一类的方法来完成子任务,请明确指出。
  • 尽量使用空白、标题或其他符号,清晰区分类名和各部分内容。

3. 持久化#

你应当只在完成阶段 1 后再处理这一部分。

本节应描述程序如何按照说明要求:

  • 保存世界状态;
  • 重新加载世界状态。

同样,请尽量保持解释清楚、简短。列出程序会接触的所有组件,包括:

  • 类;
  • 具体方法;
  • 可能创建的文件。

示例#

为了说明上述要求,我们为 Capers Lab 创建了一份设计文档示例

阶段 2:交互性#

在项目的第二阶段,你将加入让用户真正与世界互动的能力,同时还要为世界添加用户界面(UI)元素,让整个体验更具沉浸感,也能向用户提供更多信息。

交互性要求如下:

  • 用户必须能够控制某种“角色”(avatar),并使用 WASD 键让它移动。Lab 13 会介绍如何加入交互功能。

    这里所说的“角色”,只是指屏幕上由用户控制的某种表示。例如,在作者的项目中,角色使用一个可以四处移动的 @ 符号表示。

  • 角色必须能够以某种方式与世界互动。

  • 系统必须是确定性的:对于同一个种子,只要按键序列相同,每一次运行都必须产生完全相同的行为。

    请注意,Random 对象可以保证在相同种子下,每次生成相同的随机数序列。

  • 为了支持保存和加载,程序需要在 proj3 目录中创建一些文件。具体细节会在本说明后文和骨架代码中介绍。

    你创建的文件只能使用 .txt 后缀,例如:

    savefile.txt
    text

    如果不遵守这一要求,自动评分器可能会出现问题。

你也可以选择加入允许用户获胜或失败的游戏机制,具体请参见后文的额外加分部分。

除了以上功能要求之外,系统还需要满足若干技术要求,下面会更详细地说明。

UI(用户界面)外观#

用户输入种子并按下 S 后,程序应显示世界以及用户界面。

项目的用户界面必须至少包含:

  • 一个二维图块网格,用于显示世界当前状态;
  • 一个 HUD(Heads Up Display,平视显示界面),用于提供可能对用户有帮助的额外信息。

HUD 最低限度必须包含一段文字,用来描述鼠标指针当前指向的图块。

作为最低实现标准,下面这个简单界面显示了一个图块网格,并在 HUD 中显示鼠标指针下方图块的描述。点击图片可查看高分辨率版本:

基础 HUD 示例

你也可以自行加入其他功能。

在下面的示例中,鼠标当前悬停在墙壁上,因此 HUD 右上角显示文本 wall。与此同时,这个 HUD 还使用 5 颗心表示角色的“生命值”。

请注意,这个示例世界并不符合前面规范中的世界要求,因为它是一个巨大、无规则的洞穴空间,而不是由走廊连接的房间。

带生命值的 HUD 示例

下面这个游戏示例会在 GUI 中列出其他有效按键,并在鼠标悬停于图块上时提供更加详细的信息,例如:

You see grass-like fungus.
text

也就是“你看到了一种像草一样的真菌”。

下图展示的是一款专业游戏,因此我们并不要求你的项目达到这种细节水平,不过我们鼓励你尝试实现一些有趣的视觉效果。

更复杂的 HUD 示例

关于如何指定 HUD 的位置,请参考 TERenderer 中的:

initialize(int width, int height, int xOffset, int yOffset)
java

也可以查看 Lab 13。

UI 行为#

世界生成后,用户必须能够控制显示在世界中的某种角色。

用户必须能够使用以下按键移动:

  • W:向上;
  • A:向左;
  • S:向下;
  • D:向右。

这些按键也可以同时触发其他行为,例如推动对象。

你可以在引擎中加入额外按键。

角色尝试走进墙壁时不应移动,程序也不能因此报错。

系统必须表现为伪随机。也就是说,给定某个种子后,相同的按键序列必须产生完全相同的结果。

除移动键以外,如果用户输入:

:Q
text

注意包含前面的冒号,程序应当保存并退出。

保存和加载行为会在下一节中说明。

该命令必须立即保存并退出。

完成这一操作不应再要求用户按下任何按键。例如,不要在退出前询问用户是否确定。

我们把“同时退出和保存”这一单一操作称为 quit/saving

这个命令不区分大小写,因此:

:q
text

也必须有效。

此外,冒号 : 后面跟着除 Q 之外的其他字母时,不应产生任何效果。

本项目使用 StdDraw 处理用户输入。这会带来几个重要限制:

  • StdDraw 不支持组合键。

    当我们说 :Q 时,意思是先按 :,再按 Q,而不是同时按下两个键。

  • 它只能识别会产生字符的按键。

    因此,任何 Unicode 字符都可以,但方向键、Escape 等不会产生字符的按键无法使用。

  • 在某些电脑上,如果不进行较大修改,它可能不支持长按按键。例如,你不能一直按住 E 来持续向东移动。

    如果你能找到一种同时兼容 interactWithInputString 的长按实现方式,也可以自行支持。

因为系统必须能够通过 interactWithInputString 接收字符串输入,所以引擎不能使用真实时间。

也就是说,系统不能存在任何依赖现实世界中过去多少时间的机制,因为这种时间信息无法记录在输入字符串中。这样一来,使用字符串输入和使用键盘输入就无法保证确定性。

不过,记录已经经过多少个“回合”完全合理,也可能成为一个有趣的机制。例如:

  • 每移动一步,世界颜色逐渐变暗;
  • 用户可以按空格键等待一个回合。

你可以自由加入其他类似按键。

如果你不知道如何开始实现用户交互,可以查看 InputDemo.java 获取灵感。

保存与加载#

有时你正在探索世界,突然发现该去看 CS 61B 课程视频了。在这种情况下,保存当前进度,并在之后重新加载,就非常有用。

系统必须能够:

  • 在探索世界时保存世界状态;
  • 随后将世界加载回上次保存时的精确状态。

在正在运行的 Java 程序中,我们使用变量存储和读取值。

请记住,程序结束后,所有变量都会离开作用域。因此,你需要通过程序创建的文件来持久化程序状态。

当用户重新启动 byow.Core.Main 并按下 L 时,世界必须恢复到程序终止前完全相同的状态

该状态还包括随机数生成器的状态。下一节会进一步说明这一点。

如果用户尝试加载,但不存在之前的存档,系统应当直接退出并关闭 UI,不能产生错误。

在基础要求中,命令:

:Q
text

应当保存并完全终止程序。

这意味着,包含 :Q 的输入字符串在它之后不应再有任何字符。

要加载一个世界,必须重新运行程序,并使用以 L 开头的输入字符串。

通过输入字符串进行交互与阶段 2#

你的:

Core.Engine.interactWithInputString(String s)
java

必须能够处理包含移动操作的输入字符串。

例如:

N543SWWWWAA
text

表示用户:

  1. 使用种子 543 创建一个新世界;
  2. 向上移动 4 次;
  3. 向左移动 2 次。

如果调用:

Core.Engine.interactWithInputString("N543SWWWWAA")
java

系统应当返回一个 TETile[][],其世界状态必须和使用 interactWithKeyboard 手动输入这些按键后得到的结果完全一致

由于系统对于给定种子和输入序列必须具有确定性,用户可以准确重放某次输入所发生的一切。

这也便于你测试代码,并方便自动评分器验证程序。

Core.Engine.interactWithInputString(String s) 还必须能够处理重放字符串中的保存和加载。

例如:

N25SDDWD:Q
text

表示:

  1. 使用种子 25 创建新世界;
  2. 向右移动;
  3. 再向右移动;
  4. 向上移动;
  5. 向右移动;
  6. 保存并退出。

该方法应返回保存时的二维 TETile[][] 数组。

之后,如果再次使用以下重放字符串启动引擎:

LDDDD
text

系统应:

  1. 加载刚才保存的世界;
  2. 向右移动 4 次;
  3. 返回第 4 次移动后的二维 TETile[][] 数组。

世界在保存前后不能发生任何额外变化。

以下几种场景中,最后一次 interactWithInputString 调用返回的 TETile[][] 必须完全相同:

interactWithInputString("N999SDDDWWWDDD")
java
interactWithInputString("N999SDDD:Q")
interactWithInputString("LWWWDDD")
java
interactWithInputString("N999SDDD:Q")
interactWithInputString("LWWW:Q")
interactWithInputString("LDDD:Q")
java
interactWithInputString("N999SDDD:Q")
interactWithInputString("L:Q")
interactWithInputString("L:Q")
interactWithInputString("LWWWDDD")
java

原说明随后给出的含义是:如果之后使用输入:

L:Q
text

再次调用 interactWithInputString,系统保存并返回的世界状态,应当和前一次调用中得到的世界状态完全一致。保存和加载本身不能改变世界。

你不需要处理包含多次保存的重放字符串。例如:

N5SDD:QD:QDD:Q
text

不被视为有效重放字符串,因为程序应当在第一个 :Q 时就已经终止。

你也不需要处理无效重放字符串。

可以假设自动评分器提供的每个重放字符串都会以下列形式之一开始:

N#S
text

或者:

L
text

其中 # 表示用户输入的种子数字。

interactWithInputString 的返回值不应取决于输入字符串是否以 :Q 结尾。

两者唯一的区别是:

  • :Q 结尾时,方法会额外产生“保存世界状态”这一副作用;
  • 不以 :Q 结尾时,不保存。

创意分数#

项目总分中有 360 分取决于你自行选择实现的功能,我们把这部分称为 ambition score(创意分数)

核心思想是:除了项目基础要求之外,我们希望你进一步打磨产品,并加入一些很酷的功能。

下面列出的功能分为两类:

  • 270 分的主要功能;
  • 90 分的次要功能。

为了获得创意分数的满分,你必须至少实现一个主要功能。

创意分数这一类别最多只计 360 分。

即使你实现了价值 450 分的功能,也不会因此获得额外加分。不过,如果你有时间和兴趣,仍然可以加入任意数量的功能。

你的项目仍然必须满足前文所述的基础要求。

例如,即使你允许用户用鼠标点击移动,项目仍必须支持基于键盘的移动。

在部分主要功能说明下,我们提供了能够获得相应创意项目满分的 GIF,帮助消除歧义。你的实现不需要和示例完全相同。

270 分主要功能#
  • 创建一个系统,使图块渲染器只显示位于角色视线范围内的图块。视线功能必须能够通过按键打开和关闭。

    下面的视线 GIF 是一种更复杂的实现,视线能够绕过拐角。我们也见过只在角色周围显示一个正方形亮区的版本。

视线系统示例

  • 加入光源影响世界渲染效果的能力,并至少实现一个可以通过按键开关的光源。

开关灯光示例

  • 加入使用课堂中某种搜索算法追逐角色或其他实体的实体,并提供一个开关,用于显示它们预计采用的路径。

敌人追踪示例

  • 创建“遭遇”系统:当角色与世界中的实体互动时,显示一个新的界面;遭遇结束后,将角色带回原来的界面。例如《Pokémon》中的遭遇。

遭遇系统示例

  • 让用户能够“重放”最近一次存档,从创建最近一个新世界开始,以可视化方式展示之后执行的所有操作。重放最终得到的状态必须和用户加载最近存档后得到的最终状态相同。

重放系统示例

  • 让用户能够改变视角,例如第一人称、2.5D 等。

    我们以前从未见过有人实现这个主要功能。Nintendo 64 游戏 《Kirby 64 - The Crystal Shards》可以作为 2.5D 世界的示例。

90 分次要功能#
  • 加入多个存档槽位。用户可以通过新的菜单选项访问它们,并使用新的键盘快捷键保存到 1 号槽位以外的位置。你仍应谨慎保留默认保存和加载行为,以满足重放字符串要求。

  • 加入无需关闭并重新打开项目即可创建新世界的能力。它可以是探索过程中可使用的特殊选项;如果你把世界做成游戏,也可以在进入“游戏结束”状态时提供该功能。

  • 添加一个菜单选项,用于改变角色外观。

  • 添加一个菜单选项,让用户为角色命名,并在 HUD 中显示名字。

  • 添加一个菜单选项,用于选择世界的环境或主题;也可以随机决定环境或主题。

  • 添加一个菜单选项,将界面中的全部文字切换为其他语言。默认语言必须是英语,并且必须提供切换回英语的方式。

  • 支持在主菜单中通过鼠标点击执行所有本来可以通过按键执行的操作。

  • 让引擎渲染图片,而不是 Unicode 字符。

  • 为菜单或探索界面加入很酷的音乐,并为角色与世界的互动加入音效。

  • 在界面某处加入小地图,显示整个地图以及角色当前位置。

    如果你还实现了一个大于屏幕、正常情况下无法一次看到全部内容的地图,这个功能会更有趣。

  • 加入旋转世界的能力,例如把棋盘旋转 90 度,并相应调整移动按键。

  • 在 HUD 中显示真实日期和时间。

  • 支持用户点击任意可见方格进行移动。你需要实现某种寻路算法。

  • 支持两名用户同时互动。这需要屏幕上有两个角色,它们可以移动,并且拥有彼此独立的控制方案。

  • 支持撤销一次移动,包括撤销发生在当前存档加载之前的移动。

    撤销移动应把世界恢复到最近一次按键之前的状态,但在重放字符串中应增加一个撤销命令,而不是从字符串中删除字符。

要求汇总#

以下是项目要求和限制的汇总。

请注意,本节不能代替阅读完整说明,因为许多细节没有包含在这里。

  • 使用 interactWithKeyboard 时,程序必须显示一个菜单,其中包含:

    • New World(N);
    • Load(L);
    • Quit(Q)。

    菜单必须可以通过键盘操作,并且按键不区分大小写。

  • 选择 New World 后,用户应输入一个整数种子,然后按 S。按下 S 后,应生成并显示世界。

  • 用户输入种子时,UI 应显示目前已经输入的数字。

  • 世界必须通过伪随机方式生成,并具有多样性。即,不同种子应生成不同世界。

  • 所有生成的世界都必须包含阶段 1 中描述的全部视觉特征。

  • 用户必须能够使用 WASD 在世界中移动。

  • 用户必须能够按下 :Q 退出。重新启动程序后,主菜单中的 L 选项应把世界加载回退出前完全相同的状态

  • 所有随机事件都必须是伪随机的。也就是说,给定同一个种子,程序行为必须确定。

  • 用户必须能够通过 interactWithInputString 交互。除了获取输入和向屏幕绘制以外,其行为必须与 interactWithKeyboard 相同。

  • interactWithInputString 必须返回一个 TETile[][],表示处理完输入字符串最后一个字符后世界的状态。

  • interactWithInputString 必须能够像 interactWithKeyboard 一样处理保存和加载。

  • 程序必须使用课程提供的 TileEngineStdDraw 显示图形。

  • 程序必须拥有 HUD,并在显示世界或图块的区域之外显示相关信息。

  • 鼠标悬停在图块上时,HUD 必须显示该图块的描述。

  • 程序不能使用真实时间。没有收到输入时,任何内容都不应自行移动。

  • 程序必须实现创意功能分类中合计 360 分的功能,且至少包括一个主要功能

额外加分#

为了获得额外加分,你应当让用户能够在世界中获胜或失败,也就是把它真正变成一个游戏。

在此过程中,你还应加入 3 个“创意机制”。

“机制”是指:

  • 游戏如何被控制;
  • 游戏结果如何计算;
  • 什么事情可以发生;
  • 什么事情不能发生。

游戏机制共同构成游戏规则,并决定可能出现的游戏状态。有趣的游戏通常拥有有趣的机制,以及能够产生大量不同游戏状态的机制交互。

为了获得额外加分,我们要求至少加入 3 个“创意机制”。

“创意机制”具体如何定义,由你决定。我们不会严格审查这一点,因此这部分主要依靠诚信原则。

添加机制和胜负条件后,请制作一个公开的 YouTube 视频,展示你的游戏,包括:

  • 创意机制;
  • 获胜条件;
  • 失败条件。

然后通过此表单提交视频链接。

只需要一名搭档提交表单。

你不必真的能够亲自通关自己的游戏。换句话说,即使游戏非常难,也没有问题。

下面是一些可能的“创意机制”示例。不过,在要求的 3 个机制中,至少 2 个必须由你自己想出。

这并不意味着你不能实现下面列表中的多个功能,只是其中最多只有一个可以计入你所需的 3 个创意机制:

  • 加入可以从菜单访问的背景设定,介绍游戏的世界观和故事。

  • 为每种图块加入风味文本。当鼠标悬停在图块上时,在图块名称旁边显示。

    风味文本是一段简短描述,可以:

    • 对图块作进一步说明;
    • 加入有趣的笑话或引用。
  • 在游戏中加入有趣的彩蛋或作弊码,让它们触发一些有趣效果。例如加入科乐美秘技

  • 加入会在世界中游荡并破坏图块的实体。

  • 修改游戏,使世界地图由多个屏幕组成。

    常见做法包括:

    • 使用楼梯前往具有不同布局的其他楼层;
    • 使用可滚动地图,当角色走到屏幕边缘时,地图随之移动。
  • 加入能够与角色互动的 NPC(Non-Player Characters,非玩家角色)。

    例如,可以加入 4 个追逐角色的幽灵;一旦角色被抓住,游戏结束。

  • 在世界中加入能够传送角色的传送门。

  • 加入排行榜或高分列表。

    这要求游戏具有:

    • 明确目标;
    • 某种分数概念。

    用户应能够输入名字,排行榜至少显示前 10 名及对应名字。

  • 加入动画。

  • 加入可以收集的物品。收集后,这些物品可以提供分数或改变角色能力。

  • 加入物品栏,用于存放角色可以使用或装备的物品。

  • 加入生命值机制,使游戏更具交互性。

提交与评分#

和往常一样,本项目会在 Gradescope 上提供评分器。

请记得把搭档添加为 Gradescope 提交中的小组成员。

完成项目后,你还需要提交这个表单

下一节会进一步介绍验收事项。

如果不提交这个表单,项目验收部分将得到 0 分。

两位搭档中只需要一人提交,但你们应当共同填写回答。

自动评分器:200 分#

  • 阶段 1:100 分;
  • 阶段 2:100 分。

搭档互评:160 分#

互评表单会在 Ed 上提供:

验收演示:1240 分#

通过提交此表单完成:

  • 创意功能:360 分;
  • 遵守阶段 1 和阶段 2 的基础规范:880 分。

额外加分:32 分#

你还需要指定一个 Commit,以便课程工作人员知道应该评分哪个版本。

请按照以下步骤操作:

  1. 找出你希望用于演示评分的 Commit SHA。

    这个 SHA 必须来自你提交到 Gradescope 的同一个版本。

    2021 年 4 月 23 日的补充说明指出:两者不必绝对相同,但建议使用相同 Commit,以确保它能够按照预期编译和运行。

    如果你为了通过自动评分器需要注释掉某些代码,而为了验收又需要取消注释,也可以使用不同 Commit。

    可以使用:

    git log
    bash

    找到 SHA。把它复制下来,保存在可靠的位置。

  2. 确保该 Commit 位于截止时间之前,并从这个 Commit 运行代码,再次确认它确实是你希望评分的版本。

    如果 Commit 晚于截止时间,你将受到 50% 的扣分。

  3. 仔细把 SHA 粘贴进提交表单。

  4. 不要忽略第 3 步。

    如果粘贴了错误的 SHA,我们会评分错误版本的代码。

    如果粘贴了无效 SHA,默认会评分 origin/HEAD Commit,而这很可能导致迟交扣分。

验收脚本与表单#

为了尽量让验收过程保持透明,可以点击这里查看教师验收项目时使用的准确脚本。

这一年采用异步验收。为了简化流程,每个人都需要提交一个表单,说明:

  • 尝试实现了哪些创意功能;
  • 应如何使用这些功能。

说明必须清楚而简洁,让评分者能够准确知道如何使用你的功能。

如果你发现功能使用方式很难解释,可以考虑简化用户使用它时需要完成的操作。

如果创意功能存在任何已知特殊行为,请在表单中说明。例如:

打开灯光后,需要先移动一步,灯光才会真正亮起。

同样重要的是,请再次阅读整个项目规范,并确保项目严格符合要求。由于要求很多,很容易漏掉某项规定。

在提交创意功能以及使用说明前,请再次完整检查说明。

准备提交前,请确保你已经:

  1. 完成项目;
  2. 再次阅读全部规范,确保没有遗漏;
  3. 找到希望被评分的 Commit;
  4. 只使用 library-sp21java.* 中的库;
  5. 确保所有文件操作都使用与操作系统无关的路径拼接方式。

完成这些检查后,就可以提交 Project 3 Checkoff Form

最后一条“与操作系统无关的路径拼接”,意味着不能把路径硬编码成:

String pathToImage = "images/image1.jpg";
java

因为验收人员可能使用路径分隔符不同的操作系统。

你应使用项目 2 Gitlet 的 Utils.java 中的 join 函数。

为了方便起见,这里再次列出:

import java.io.File;
import java.nio.file.Paths;

/**
 * 返回把 FIRST 和 OTHERS 拼接后得到的 File 路径,
 * 其行为类似 java.nio.file.Paths.get(String, String[])。
 */
static File join(String first, String... others) {
    return Paths.get(first, others).toFile();
}
java

自动评分器详情#

BYOW 有两个评分器:

  • 阶段 1 评分器;
  • 阶段 2 评分器。

阶段 1 评分器#

截止时间:4 月 16 日晚上 11:59。

分值:100 分。

评分器会测试:

  • interactWithInputString 是否返回一个世界;
  • 多次提供相同种子时,interactWithInputString 是否重新创建相同世界;
  • 提供不同种子时,interactWithInputString 是否创建不同世界。

这些测试中不会包含移动操作

阶段 2 评分器#

截止时间:4 月 27 日晚上 11:59。

分值:100 分。

评分器会测试:

  • 多次提供相同种子和相同移动操作时,interactWithInputString 是否重新创建相同世界;
  • 提供不同种子和不同移动操作时,interactWithInputString 是否创建不同世界;
  • 输入序列中包含保存和加载时,interactWithInputString 是否仍能创建相同世界。

提交到自动评分器时,请记得把搭档添加为小组成员。

Office Hours#

由于本项目非常开放,课程工作人员很难像其他项目那样帮助你调试。

因此,为了能够给到场学生分配足够时间,Office Hours 将采用以下流程:

  • 课程工作人员为每名学生提供的帮助最多约为 10 分钟。

  • 设计文档必须反映程序当前状态。

  • 代码必须有良好文档,包括你编写的所有方法,并遵循代码风格指南

    这样可以减少口头解释每个方法用途所花的时间。

  • 如果你的问题是调试问题,必须准备好:

    • 解释正在发生什么错误;
    • 提供一个可以方便复现该 Bug 的测试或输入。

    如果你只是说某个功能不能工作,却没有编写任何测试,也没有尝试使用调试器,我们不会帮助你。

  • 即使课程工作人员提供调试帮助,也可能只会提供高层建议,例如建议如何重新组织代码,让它更清晰、更容易调试。

    尝试在混乱、脆弱的代码中直接寻找 Bug,对你和助教来说都不是对时间的良好利用。

常见问题#

问:我想制作一个可以探索室外或洞穴的世界,而不是一堆房间,应该怎么办?#

答:没有问题。你可以使用种子为角色创建一座起始房屋,其中包含房间和走廊,并允许角色自由离开这座房屋。

问:我可以制作支持滚动或多个楼层(例如楼梯)的世界吗?#

答:可以。

在这种情况下,interactWithInputString 应只返回输入重放字符串最后一个字符时,屏幕上可见的那一部分世界。

问:我可以让用户在创建世界前自定义角色吗?#

答:可以,但你需要创建主菜单的第四个选项。

项目必须准确支持本说明中描述的 API。也就是说:

N23123S
text

必须始终使用种子 23123 创建新世界,不能再要求用户提供其他输入。

问:我看到两个 StdDraw 窗口,而不是一个,怎样避免?#

答:确保导入的是:

import edu.princeton.cs.introcs.StdDraw;
java

而不是:

import edu.princeton.cs.algs4.StdDraw;
java

问:为什么阶段 1 自动评分器提示 Could not initialize class edu.princeton.cs.introcs.StdDraw#

答:你的代码中的某处,在 interactWithInputString 方法执行期间尝试使用 StdDraw,而这是不允许的。

例如,如果调用:

TERenderer.initialize()
java

就意味着你正在使用 StdDraw

调用 interactWithInputString 时,不应打开任何 StdDraw 窗口。

我们见过一些学生的代码只会在某些种子下打开 StdDraw 窗口,因此请非常仔细地检查。

问:自动评分器出现由 Integer.parseInt 引起的 NumberFormatException,怎么办?#

答:Random 类接收的是 long,而我们提供的种子可能太大,无法放进 int

你需要使用 Long 类解析种子,而不是 Integer

例如:

long seed = Long.parseLong(seedString);
java

问:自动评分器说我的世界彼此不够不同,但我在本地运行种子时,看到的世界明明不同,为什么?#

答:检查你使用的每种图块是否都由不同字符表示。

如果你创建了新图块,这一点尤其重要。

问:我的类的两个实例本应相等,但程序认为它们不相等。另外,我把这个类的实例作为 HashMap 的键,却无法重新找到这个键,怎么办?#

答:如果你创建了自己的类,请同时重写:

equals()
java

和:

hashCode()
java

这样才能保证相等的两个实例具有相同哈希值。

问:我正在使用 byow/InputDemo 中的 InputSource,但无法让 HUD 显示鼠标当前悬停的图块,怎么办?#

答:byow/InputDemo 只是一个演示。

如果使用这种设计,你需要修改 KeyboardSource,因为其中的 getNextKey 方法在调用后会“阻塞”。

这里的“阻塞”是指:在用户输入一个按键之前,它不会返回。

因此,当用户没有移动时,该方法会一直停在循环中,什么也不做。

但如果用户只移动鼠标,你就无法检查鼠标悬停在哪个图块上,因为代码仍被困在等待键盘输入的循环里。

所以,如果选择这种实现方式,应修改这个循环,使它在等待按键的同时,也检查鼠标当前悬停的图块。


原始页面:https://sp21.datastructur.es/materials/proj/proj3/proj3