Greenfoot-创意指南-全-

Greenfoot 创意指南(全)

原文:zh.annas-archive.org/md5/a463fa25d7de14ca238342316a0d8de4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书旨在帮助你通过实践学习的方法快速学习如何编程游戏和其他交互式应用。与其他文本不同,它们从详细描述语言或开发平台的所有方面开始,我们将只涵盖完成任务所需的精确内容。随着你在书中的进步,你的编程技能和能力将随着你学习动画、碰撞检测、人工智能和游戏设计等主题而增长。基于项目的学习方法是一种经过验证的方法,并在基础教育、中等教育和高等教育中变得突出。它增强了学习过程并提高了知识保留。

本书所涉及的主题紧密跟随我在游戏设计课程中讲解的内容。通过多年的教学经验,我发现基于项目的学习方法可以迅速让学生成功地进行编程,并创造出有趣的游戏和应用。我希望你们也能对在短时间内能完成多少工作感到惊讶。

我们将用 Java 编写我们的游戏。Java 是世界上最受欢迎和最强大的编程语言之一,在金融行业、游戏公司和研究机构中得到广泛应用。我们将使用 Greenfoot(www.greenfoot.org)进行编程——一个交互式 Java 开发环境。这个环境允许新手和经验丰富的程序员快速创建视觉上吸引人的应用。它提供了一个安全的环境进行实验,并允许你在各种平台上分享你的工作。

为了最大限度地利用本书,你应该:

  • 在阅读本书的同时打开 Greenfoot 进行编码

  • 在完成一个章节后尝试你自己的代码

  • 了解一些章节中没有涵盖的细节将在接下来的章节中解决

  • 为你的成就感到自豪,并与朋友、家人和 Greenfoot 社区分享。

学习不是一个被动的活动。深入研究每一章,进行实验,加入你自己的独特风格,然后编写一些真正属于你自己的代码。我迫不及待地想看看你能做什么。

本书涵盖的内容

第一章,让我们直接进入…,带你完成一个创建简单游戏的完整教程,包括介绍屏幕、游戏结束屏幕、得分、鼠标输入和声音。这个教程的目的是向你介绍 Greenfoot 基础知识、Java 基础和良好的编程实践。

第二章,动画,讨论了如何在 Greenfoot 中执行动画。动画需要适当的及时图像交换以及在屏幕上的真实运动。阅读给定主题并看到示例后,你将应用学到的动画技术到你在第一章,让我们直接进入…中创建的游戏中。

第三章,碰撞检测,讨论了为什么碰撞检测对于大多数模拟和游戏来说是必要的。您将学习如何使用 Greenfoot 的内置碰撞检测机制,然后学习更精确的碰撞检测方法。您将使用基于边界的和隐藏精灵的碰撞检测方法来创建僵尸入侵模拟。

第四章,投射物,讨论了在创意 Greenfoot 中,演员的运动通常可以最好地描述为被发射。足球、子弹、激光、光束、棒球和烟花都是这类对象的例子。您将学习如何实现这种类型的推进运动。您还将通过实现一个综合平台游戏来学习如果存在重力,它如何影响它。

第五章,交互式应用程序设计和理论,讨论了在 Greenfoot 中创建引人入胜和沉浸式的体验,这比将编程效果集合到一个应用程序中要复杂得多。在本章中,您将学习如何通过理解用户选择与结果之间的关系、对用户进行条件化以及将适当复杂度纳入您的作品中来吸引用户。您将看到一个经过验证的迭代开发过程,它有助于您将理论付诸实践。

第六章,滚动和映射世界,讨论了如何创建比单个屏幕能容纳的还要广阔的世界。在本章的开始,您将编写一个滚动探险游戏,到本章结束时,您将把它扩展成一个大型映射游戏。

第七章,人工智能,讨论了尽管人工智能是一个深奥且复杂的话题,但您仍然可以学习一些简单的技术,以在您的世界中创造出具有智能和自主性的演员的错觉。首先,您将学习如何有效地使用随机行为。接下来,您将实现简单的启发式算法来模拟智能行为。最后,您将学习 A*搜索算法,以允许游戏演员在屏幕上的两个位置之间移动时智能地绕过障碍物。

第八章,用户界面,讨论了如何将界面添加到您的 Greenfoot 场景中。在本章中,您将学习如何通过按钮、标签、菜单和抬头显示与用户进行通信。

第九章, Greenfoot 中的游戏手柄,讨论了游戏手柄设备的功能,然后教你如何设置 Greenfoot 以与之协同工作。然后,你将为我们创建的游戏添加游戏手柄支持,该游戏在第一章, 让我们直接进入…,和第二章, 动画中介绍。

第十章, 接下来要探索什么…,为你提供了一个反思在这本书的学习过程中所学技能的机会。然后,我将继续建议你应该尝试的项目,以便继续你的编程和交互式应用程序作者的旅程。

你需要这本书什么

对于这本书,你需要从www.greenfoot.org/door下载 Greenfoot 并将其安装到你的电脑上。Greenfoot 是免费的,可在 Windows、Mac 和 Linux 上运行。Greenfoot 网站提供了易于遵循的安装说明。安装后,你应该完成在www.greenfoot.org/doc上找到的六个简单教程。这些教程可以在不到两小时内完成,并将为你提供从这本书中获得最大价值所需的所有知识。

这本书适合谁阅读

如果你准备好探索创意编程的世界,那么你会欣赏这本书中描述的方法、技巧和流程。本书适合所有水平的 Java 程序员(从新手到专家),它系统地引导你了解构建引人入胜的交互式应用程序的关键主题。你将学习如何通过指导编程练习来构建游戏、模拟和动画。

术语

在这本书中,你会找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“正如你所见,我们对Enemy类进行了一些非常简单的修改。”

代码块设置如下:

private void increaseLevel() {
  int score = scoreBoard.getValue();

  if( score > nextLevel ) {
    enemySpawnRate += 2;
    enemySpeed++;
    nextLevel += 100;
  }
}

当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

  public void act() {
    if( Greenfoot.mouseClicked(this) ) {
      AvoiderWorld world = new AvoiderWorld(pad);
      Greenfoot.setWorld(world);
    }
  }

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“在新类弹出窗口中点击确定按钮,然后在主场景窗口中点击编译按钮。”

注意

警告或重要提示将以这样的框显示。

小贴士

小贴士和技巧如下所示。

读者反馈

我们欢迎读者的反馈。请告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者的反馈对我们开发你真正能从中获得最大价值的标题非常重要。

要发送一般反馈,只需将电子邮件发送到<feedback@packtpub.com>,并在邮件主题中提及书名。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您已经是 Packt 图书的骄傲拥有者,我们有许多事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

下载本书的彩色图像

我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从:www.packtpub.com/sites/default/files/downloads/B00626_ColorImages.pdf下载此文件。

勘误

尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。您可以通过从www.packtpub.com/support选择您的标题来查看任何现有勘误。

盗版

网络上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上遇到我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过发送链接到疑似盗版材料至<copyright@packtpub.com>与我们联系。

我们感谢您在保护我们的作者和我们提供有价值内容的能力方面的帮助。

问题

如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。

第一章。让我们直接进入…

*"无论你走得有多慢,只要你不停止。"
--孔子

在本章中,你将构建一个简单的游戏,玩家通过鼠标控制角色,尝试躲避迎面而来的敌人。随着游戏的进行,敌人变得越来越难以躲避。这个游戏包含了创建交互式 Greenfoot 应用程序所需的基本元素。具体来说,在本章中,你将学习如何:

  • 创建介绍和游戏结束屏幕

  • 显示用户得分

  • 使用鼠标控制角色的移动

  • 播放背景音乐

  • 动态生成敌人并在适当的时候移除它们

  • 创建游戏关卡

在本章中,我们将学习基本的编程概念,并熟悉 Greenfoot 开发环境。随着你的学习,思考所提出的概念以及你如何在你的项目中使用它们。如果你是 Java 的新手,或者有一段时间没有编写 Java 程序了,请确保花时间查阅可能让你感到困惑的内容。Java 是一种成熟的编程语言,有无数的在线资源可以查阅。同样,本书假设对 Greenfoot 有最低限度的了解。在需要时,请务必查看www.greenfoot.org上的简单教程和文档。尝试代码并尝试新事物——你会很高兴你这么做的。换句话说,遵循本章第一行引用的孔子的建议。

本书中的许多章节都是独立的;然而,大多数章节都依赖于本章。本章提供了创建我们将继续使用并在后续章节中参考的 Greenfoot 应用程序的框架。

Avoider 游戏教程

这个教程主要基于 Michael James Williams 的AS3 Avoider 游戏教程。在那个教程中,你将构建一个游戏,游戏会从屏幕顶部生成笑脸敌人。玩家的目标是避开这些敌人。你避开它们的时间越长,你的得分就越高。我们将使用 Greenfoot 构建相同的游戏,而不是 Flash 和 ActionScript。与 Michael James Williams 的教程一样,我们将从小处着手,逐渐添加功能。我们将经常暂停以考虑最佳实践和良好的编程实践。享受这些学习机会!

我们将首先构建 Avoider 游戏的基本组件,包括初始场景、游戏环境、敌人和英雄。然后,我们将添加额外的功能,例如得分、介绍和游戏结束屏幕以及关卡的概念。

如前言所述,我们假设你已经下载了 Greenfoot 并已安装。如果你还没有,请现在就做。前往www.greenfoot.org获取下载和安装 Greenfoot 的简单说明。当你在那里时,确保你至少熟悉www.greenfoot.org/doc上提供的所有教程。

基本游戏元素

所有游戏都有一个游戏发生的环境,其中对象进行交互。在 Greenfoot 中,环境由World类表示,而在环境中交互的对象由Actor类表示。在本章的这一部分,我们将创建一个世界,向世界添加敌人,并添加一个将由玩家控制的英雄。

创建场景

启动 Greenfoot,通过点击 Greenfoot 菜单栏中的场景然后点击新建…来创建一个新的场景。你会看到图 1中显示的窗口。将文件名输入为AvoiderGame,然后点击创建按钮。

创建场景

图 1:这是 Greenfoot 的新场景窗口

创建我们的世界

接下来,我们需要为我们的游戏创建一个世界。我们通过在场景窗口中右键单击(或在 Mac 上按 ctrl 键单击)世界类,并在出现的弹出菜单中选择新建子类...来完成此操作(参见图 2)。

创建我们的世界

图 2:这是关于在世界类上右键单击以创建子类

新建类弹出窗口中,将类命名为AvoiderWorld,选择背景图像类别,然后选择space1.jpg库图像作为新类的图像。完成这些操作后,弹出窗口应类似于图 3

小贴士

一旦你将一个图像与新的World类或Actor类关联,该图像将被复制到 Greenfoot 项目的images目录中。我们将在后面的章节中依赖这一点。

创建我们的世界

图 3:这显示了新建类弹出窗口

新建类弹出窗口中点击确定按钮,然后在主场景窗口中点击编译按钮。现在你应该有一个看起来像图 4中所示的场景。

创建我们的世界

图 4:这显示了编译了 AvoiderWorld 类的 AvoiderGame 场景

现在我们有了自己的世界,名为AvoiderWorld,我们将很快在其中添加演员。

小贴士

在本章的后面部分,我们将向我们的游戏添加两个World类的子类——一个用于我们的介绍屏幕,另一个用于我们的游戏结束屏幕。那些说明将被简略。如果你需要关于子类化World类的详细说明,请务必参考本节。

创建我们的英雄

让我们创建玩家在玩游戏时将控制的角色。Greenfoot 使这变得非常简单。我们将遵循之前创建World类时使用的相同步骤。首先,在场景窗口中右键单击Actor类(见图 5),然后选择新建子类...菜单项。

创建我们的英雄

图 5:此图显示了在 Actor 类上右键单击以继承它

新类弹出窗口中,将类命名为Avatar,并选择symbols->skull.png作为新的类图像。在主场景窗口中,点击编译按钮。

现在,要创建一个敌人,您只需执行与英雄相同的步骤,只是选择symbols->Smiley1.png作为图像,并将类名选择为Enemy。同样,完成此操作后,再次点击编译按钮。

现在,您应该有一个看起来像图 6所示的场景。

创建我们的英雄

图 6:此图显示了创建世界并添加两个演员后的 Avoider Game 场景

我们刚才做了什么?

Greenfoot 将场景视为包含ActorWorldWorld的主要职责是从屏幕上添加和删除每个 Actor,并定期调用每个Actoract()方法。每个Actor的职责是实现它们的act()方法来描述它们的行为。Greenfoot 为您提供了实现通用WorldActor行为的代码。(您之前已经右键点击过这些实现。)作为一名游戏程序员,您必须为WorldActor编写特定的行为代码。您通过继承提供的WorldActor类来创建新类,并在其中编写代码。您已经完成了继承,现在是时候添加代码了。

小贴士

查看www.greenfoot.org/files/javadoc/以了解更多关于WorldActor类的信息。

Oracle 在docs.oracle.com/javase/tutorial/java/concepts/提供了关于面向对象编程概念的优秀概述。如果您认真学习 Java 并编写好的 Greenfoot 场景,您应该阅读这些材料。

添加我们的英雄

最后,我们需要将我们的英雄添加到游戏中。为此,右键单击Avatar类,从弹出菜单中选择new Avatar(),将鼠标指针旁边出现的头骨图片拖到屏幕中央,然后点击鼠标左键。现在,在任何黑色背景上右键单击(不要在头骨上右键单击)并选择弹出菜单中的保存世界

执行此操作将永久将我们的英雄添加到游戏中。如果您在 Greenfoot 的场景窗口中点击重置按钮,您应该仍然看到您放置在屏幕中间的头骨。

使用鼠标作为游戏控制器

让我们在Avatar类中添加一些代码,这样我们就可以使用鼠标来控制它的移动。双击Avatar以打开代码编辑器(你也可以右键单击类并选择打开编辑器)。

你将看到一个代码编辑窗口出现,其外观如图 7 所示。

使用鼠标作为游戏控制器

图 7:这是我们 Avatar 类的代码

你可以看到我们之前讨论过的act()方法。因为里面没有代码,所以当我们运行场景时,Avatar不会移动或显示任何其他行为。我们希望的是让Avatar跟随鼠标。如果有一个我们可以使用的followMouse()方法会怎么样?让我们假装有!act()方法中,输入followMouse();。你的act()方法应该看起来像图 8。

使用鼠标作为游戏控制器

图 8:显示了添加了 followMouse()函数的 act()方法

为了好玩,让我们编译一下看看会发生什么。你认为会发生什么?点击编译按钮来找出答案。你看到了像图 9 中显示的那样的事情吗?

使用鼠标作为游戏控制器

图 9:这是关于在 Greenfoot 中查看编译错误

如果你查看图 9 的底部,你会看到 Greenfoot 为我们提供了一个有用的错误信息,甚至突出显示了有问题的代码。正如我们所知,我们假装方法followMouse()存在。当然,它不存在。然而,我们很快就会编写它。在整个手册的编写过程中(以及任何 Java 编码过程中),你都会犯错误。有时,你会犯一个“打字错误”,有时,你会使用一个不存在的符号(就像我们之前做的那样)。你还会犯其他一些常见的错误。

注意

帮助!我刚刚犯了一个编程错误!

不要慌张!你可以做很多事情来解决这个问题。我会在这里列出一些。首先,你使用的编码过程可以大大帮助你调试代码(查找错误)。你应该遵循的过程被称为增量开发。只需遵循以下步骤:

  • 编写几行代码。(真的!!不要编写更多代码!)

  • 保存并编译。

  • 运行并测试你的代码。(真的!!试试看!)

  • 重复。

现在,如果你遇到错误,它一定是由于你刚刚编写的最后 2-5 行代码造成的。你知道确切的位置在哪里。将此与编写 30 行代码然后测试它们进行比较。你将会有累积的难以找到的错误。以下是一些其他调试技巧:

  • 非常仔细地阅读你得到的错误信息。虽然它们可能很晦涩,但它们确实会指向错误的位置(有时甚至给出行号)。

  • 有时,你会得到多个、长篇的错误信息。不用担心。只需从顶部开始阅读并处理第一个。通常,通过修复第一个,许多其他问题也会得到解决。

  • 如果你实在找不到,让其他人帮你阅读代码。别人能多快地发现你的错误真是令人惊讶。

  • 打印一些信息。你可以使用System.out.println()来打印变量,并检查你正在查看的代码是否实际在运行。

  • 学习如何使用调试器。这是一个非常有用的工具,但超出了本书的范围。了解调试器是什么,并使用它。*Greenfoot 有一个内置的调试器,你可以使用它*。

在极其罕见的情况下,如果 Greenfoot 程序中存在错误,请按照www.greenfoot.org/support中找到的说明进行报告。

创建followMouse函数

好的,让我们回到我们的英雄。我们上一次离开我们的英雄(Avatar 类)时,有一个错误,因为实际上没有followMouse()方法。让我们来修复它。在Avatar类的act()方法之后添加以下代码中的方法:

private void followMouse() {
  MouseInfo mi = Greenfoot.getMouseInfo();
  if( mi != null ) {
    setLocation(mi.getX(), mi.getY());
  }
}

我们现在有了followMouse()方法的实现。保存文件,编译 Greenfoot 场景,并尝试运行代码。头骨的图片应该会跟随你的鼠标。如果出了问题,仔细查看调试窗口(如图 9 所示)以查看 Java 给你提供的关于错误的线索。你是不是打错了什么?验证一下你的Avatar类中的代码是否与图 10 中的代码完全一致。*遵循之前提供的调试提示*。

创建函数

图 10:此图显示了完成followMouse()方法的 Avatar 类

嘿,等等!我是怎么想出followMouse()方法的代码的?我是带着这些信息出生的吗?不,我实际上只是查阅了 Greenfoot 文档(www.greenfoot.org/files/javadoc/),并看到有一个名为MouseInfo的类。我点击了它,并阅读了它所有的方法。

小贴士

现在去阅读 Greenfoot 文档。实际上它相当简短。只有七个类,每个类大约有 20 个或更少的方法。

代码分解

让我们分解这段代码。首先,我们通过Greenfoot.getMouseInfo()获取一个表示鼠标数据的对象。然后,我们使用该对象通过getX()getY()获取鼠标的位置,然后使用setLocation(x,y)设置我们的英雄的xy位置。我是怎么知道要使用setLocation()的?再次,它是在Actor类的 Greenfoot 文档中。这是 Greenfoot 为所有演员提供的一个方法。最后,我们必须包含if(mi != null)部分,因为如果你不小心将鼠标移动到 Greenfoot 窗口之外,将没有鼠标信息,尝试访问它将导致错误(查看图 10中的代码注释,第 22 行)。

由于followMouse()方法在act()方法中被调用,我们的英雄(Avatar 类)将持续移动到鼠标的位置。

小贴士

当在 Greenfoot 中输入方法时,你可以按Ctrl + 空格键,Greenfoot 将显示一个可能尝试编写的潜在方法的列表。从列表中选择一个方法,Greenfoot 将为你自动完成该方法,包括方法参数的占位符。

添加敌人

我们将分两步向我们的游戏添加敌人。首先,我们需要编写Enemy类的代码,然后我们将向我们的世界AvoiderWorld添加代码来创建一支永无止境的敌人军队。这两个步骤都非常简单。

敌人代码

双击Enemy类并更改其act()方法,使其看起来像以下代码片段:

public void act() {
  setLocation(getX(), getY() + 1);
}

记得在Avatar类中使用过setLocation()吗?我们在这里再次使用它,每次调用act()方法时将敌人向下移动一个像素。在 Greenfoot 中,屏幕的左上角是坐标(0,0)。x坐标随着向右移动而增加,y坐标随着向下移动而增加。这就是为什么我们将敌人的x位置设置为当前的x坐标值(我们不会向左或向右移动)以及其y位置设置为当前的y坐标加一(我们向下移动一个像素。)

保存你的Enemy类,然后编译你的场景。运行场景,右键单击Enemy类,并在弹出菜单中选择new Enemy()。将这个敌人添加到屏幕上,并观察它向下移动。

创建一支军队

现在我们已经完成了Enemy类的编写,我们可以用它来创建一支军队。为此,我们将向AvoiderWorld类的act()方法中添加代码。通过双击AvoiderWorld或右键单击它并在弹出菜单中选择打开编辑器来打开AvoiderWorld的编辑器。如果你查看AvoiderWorld的代码,你会注意到 Greenfoot 不会自动为你创建act()方法。没问题,我们只需添加它。在AvoiderWorld中放入以下代码:

public void act() {
  // Randomly add enemies to the world
  if( Greenfoot.getRandomNumber(1000) < 20 ) {
    Enemy e = new Enemy();
    addObject(e, Greenfoot.getRandomNumber(getWidth()-20)+10, -30);
  }
}

act()方法首先检查一个在 0 到 1000 之间(包括 0 但不包括 1000)随机生成的数字是否小于 20。从长远来看,这段代码将在act()方法被调用的 2%的时间内运行。这足够了吗?嗯,act()方法通常每秒调用 50 次(范围从 1 到 100,取决于速度滑块的位置),所以 2%的 50 次是 1。因此,平均每秒将创建一个敌人。这对于我们游戏的起始级别来说感觉是合适的。

if语句内部,我们创建一个敌人并将其放置在世界的特定位置,使用addObject()方法。addObject()方法接受三个参数:要添加的对象、对象的x坐标和对象的y坐标。y坐标是恒定的,选择它使得新创建的敌人从屏幕顶部开始,并随着它缓慢向下移动而出现。x坐标更复杂。它是动态生成的,以便敌人可以出现在屏幕上的任何有效的x坐标。以下是我们正在讨论的代码:

Greenfoot.getRandomNumber( (getWidth() – 20) + 10, -30);

图 11展示了生成的x坐标值的范围。在这个图中,矩形代表给定代码的x坐标可能值的集合。在 Greenfoot 中,为屏幕坐标生成值范围的这种方法是常见的。

创建一支军队

图 11:这是代码生成的 x 坐标值的范围

编译并运行场景;你应该会看到一个连续的敌人洪流沿着屏幕向下移动。

无界世界

运行场景后,你会注意到敌人最终会堆积在屏幕底部。在 Greenfoot 中,你可以创建有界(演员不允许穿过屏幕边界)和无界(演员允许退出屏幕)的世界。默认情况下,Greenfoot 创建的是有界世界。然而,将世界改为无界非常容易。双击AvoiderWorld以打开代码编辑器。找到以下代码行:

super(600, 400, 1);

将前面的代码更改为以下代码行:

super(600, 400, 1, false);

查看 Greenfoot 文档中的World类,我们会注意到有两个构造函数(有关这些构造函数的详细信息,请参阅www.greenfoot.org/files/javadoc/greenfoot/World.html):一个接受三个参数,另一个接受四个。具有四个参数的构造函数与接受三个参数的构造函数具有相同的参数,再加上一个额外的boolean参数,表示世界是有界还是无界。我们的代码更改添加了第四个布尔参数并将其设置为false(世界中没有边界。)

现在,编译并运行场景。敌人会按照要求从屏幕底部掉落。

所有这些敌人都去哪里了?我们将在下一节中处理这个问题。

内存管理

在 Greenfoot 应用程序中,你会创建成百上千的演员。当我们完成一个演员,比如当它被杀死或离开屏幕时,我们希望移除该对象,并且它不再消耗任何系统资源。Java 通过一个称为垃圾回收的方法来管理内存资源。使用此方法,Java 试图自动确定你是否不再需要演员,如果你不需要,它将删除该演员并释放与其相关的所有资源。在 Greenfoot 中,你可以通过使用removeObject()方法从World中移除演员来让 Java 知道你已经完成了演员。这就是我们在成功避开它并且它已经离开屏幕后想要对Enemy演员做的事情。

在敌人离开屏幕后,移除Enemy的最方便的地方是在Enemy类本身中。将以下代码作为Enemy类中act()方法内的最后一行代码添加:

checkRemove();

现在,我们需要添加checkRemove()方法。将此方法的定义放在act()方法下方。以下是定义:

private void checkRemove() {
  World w = getWorld();
  if( getY() > w.getHeight() + 30 ) {
    w.removeObject(this);
  }
}

你的Enemy类的代码应该看起来像图 12中所示的那样。

内存管理

图 12:这显示了如何添加代码以移除如果敌人从屏幕底部移出

现在,编译并运行场景。敌人像以前一样从屏幕底部落下,但你可以放心,它们很快就会从世界中移除,垃圾回收也会进行。

你的作业

学习不是被动的,你真的需要参与这个过程。在继续本章的下一部分之前,你应该:

  1. 确保你的 Avoider Game 版本可以正常工作,在 Greenfoot 的主应用程序菜单中点击场景,然后选择另存为…来创建 Avoider Game 的实验副本。让我们把这个实验副本命名为AvoiderGameIExperimentation

  2. 在你的实验副本上玩玩。改变敌人的出生率。改变敌人下降的速度。

  3. turn(5);添加到Enemy类的act()方法中。编译并运行。发生了什么?尝试用不同的值代替5作为turn()的输入参数。

如果事情变得太疯狂,删除你的实验副本,并从我们的原始 Avoider Game 创建一个新的副本来玩耍。没有造成伤害,也没有任何不当行为。

小贴士

在整本书中,采取这种通过实验代码的方法。在玩耍的过程中会发生很多学习。思考如何更改代码的行为本身就会给你的大脑提供一种新的处理和理解它的方式。在受控环境中犯错误将更好地为你准备以后处理错误。你将开始熟悉 Greenfoot 的错误信息。

接下来...

到目前为止做得很好!我们已经建立了游戏的基础,接下来我们将添加一些东西,比如介绍屏幕、游戏结束屏幕和分数,使它看起来和感觉更像一个游戏。

使其成为一个游戏

在本节中,我们将添加一个游戏结束屏幕、一个介绍屏幕和一些背景音乐。但在我们做所有这些之前,我们需要知道我们的英雄何时接触到敌人。这将是我们结束游戏的提示。确定两个角色何时接触的行为称为碰撞检测。碰撞检测用于判断子弹是否击中敌人,玩家在跳跃后是否落在平台上,或者判断一片落叶是否落在地面上。我们将在下一节讨论这个重要话题,并在接下来的章节中花费大量时间讨论。

检测碰撞

Greenfoot 提供了几个Actor方法,您可以使用它们来确定您是否接触到了另一个Actor。这些方法没有特定的顺序,包括:getIntersectingObjects()getNeighbors()getObjectsAtOffset()getObjectsInRange()getOneIntersectingObject()getOneObjectAtOffset()。它们都提供了确定碰撞的不同方法。对于我们的游戏,我们将使用getOneIntersectingObject()。此方法的原型如下:

protected Actor getOneIntersectingObject(java.lang.Class cls)

此方法接受一个参数,即您想要检查碰撞的对象类别。此方法将碰撞定义为边界框;边界框是能够包围图形中所有像素的最小矩形。此方法既高效又快速,但不是最精确的。在图 12中,我们可以看到一幅头骨的图片和一幅笑脸的图片。尽管这两幅图片的像素没有重叠,但我们可以看到它们的边界框是重叠的;因此,getOneIntersectingObject()会报告这两个角色正在接触。在第三章碰撞检测中,我们将探讨更高级的碰撞检测方法。

检测碰撞

图 13:此图显示了两个角色的边界框

带着这些新信息,我们将向我们的Avatar类添加碰撞检测。如果我们的英雄接触到敌人之一,我们将将其从游戏中移除。(在本章的后面部分,我们将在移除我们的英雄后显示游戏结束屏幕。)双击Avatar类以打开其编辑窗口。将其act()方法更改为以下内容:

public void act() {
 followMouse();
 checkForCollisions();
}

然后,在act()方法下添加此checkForCollisions()方法的定义:

private void checkForCollisions() {
  Actor enemy = getOneIntersectingObject(Enemy.class);
  if( enemy != null ) {
    getWorld().removeObject(this);
    Greenfoot.stop();
  }
}

Avatar类应该看起来像图 14 中所示的那样。

检测碰撞

图 14:添加了碰撞检测的Avatar类。

让我们仔细检查checkForCollisions()方法中正在发生的事情。我们首先调用getOneIntersectionObject()并将它的返回值保存在变量enemy中。如果这个对象没有接触到任何敌人,这个变量将是null,在这种情况下,if语句中的表达式将评估为false,我们不会执行其内部的语句。否则,我们接触到了一个类型为Enemy的对象,并将执行if语句的内容。

if语句中只有两行代码。在第一行中,我们使用getWorld()方法,该方法在Actor类中实现,来获取我们所在世界的World实例的引用。我们不是将引用保存在一个变量中,而是立即调用WorldremoveObject()方法,将关键字this作为参数传递以移除我们的英雄。最后,我们使用Greenfoot实用类中的stop()方法暂停我们的游戏。

现在,编译并运行这个场景。敌人应该从屏幕顶部流下来并在底部退出。你应该能够通过移动鼠标来控制英雄,它是Avatar类的一个实例。如果我们的英雄接触到任何一个敌人,游戏应该停止。

添加游戏结束屏幕

首先,你需要使用你喜欢的图形设计/绘图程序,比如 GIMP、CorelDRAW、Inkscape、Greenfoot 内置的图形编辑器,甚至是 Windows Paint,绘制整个游戏结束屏幕。我使用 Adobe Illustrator 创建了图 15中显示的屏幕。

添加游戏结束屏幕

图 15:我的 AvoiderGame 游戏结束屏幕;尝试设计你自己的。

无论你使用什么来绘制你的图像,确保你可以以PNGJPG格式保存它。其大小应该是 600 x 400(与你的世界大小相同)。将此图像保存在你的AvoiderGame场景的images文件夹中。

使用与创建AvoiderWorld相同的步骤(避免者游戏教程部分),创建另一个世界;命名为AvoiderGameOverWorld,并将你之前创建的图像与之关联。在你的场景的世界类区域,你应该看到图 16中所示的内容。

添加游戏结束屏幕

图 16:添加 AvoiderGameOverWorld 后的世界类区域

场景切换

现在,我们想要显示游戏结束屏幕,如果我们的英雄接触到敌人。为此,我们需要执行以下三个步骤:

  1. 检测我们与敌人发生碰撞,然后通过调用一个方法通知我们的世界,AvoiderWorld,游戏已经结束。

  2. 在我们的AvoiderWorld类中,我们需要实现Avatar将用来信号世界末日结束的游戏结束方法。

  3. 在我们的游戏结束方法中,将世界设置为AvoiderGameOverWorld,而不是AvoiderWorld

让我们从第一步开始。之前,在本节的检测碰撞子节中,你编写了代码,如果英雄接触到敌人,就会从游戏中移除英雄。这段代码包含在checkForCollisions()方法中。为了实现第一步,我们需要将该方法更改为以下方法:

private void checkForCollisions() {
  Actor enemy = getOneIntersectingObject(Enemy.class);
  if( enemy != null ) {
    AvoiderWorld world = (AvoiderWorld) getWorld();
    world.endGame();
  }
}

唯一的区别是if语句内的代码。我希望你能理解我们现在要求世界结束游戏,而不是移除英雄对象。可能让人困惑的部分是将AvoiderWorld替换为World以及添加(AvoiderWorld)部分。问题是,我们将在AvoiderWorld中实现endGame(),而不是World。因此,我们需要一种方法来指定getWorld()的返回值将被视为AvoiderWorld,而不仅仅是普通的World。用 Java 术语来说,这被称为类型转换

现在,让我们看看第二步和第三步。这是你需要添加到AvoiderWorld中的代码。

切换场景

图 17:这显示了添加到 AvoiderWorld 中的 endGame()方法

我们已经更改并添加了最小量的代码,但如果你仔细跟随着,你应该能够保存、编译并运行代码。看到我们的英雄接触到敌人时出现的游戏结束界面吗?(如果没有,请回过头来重新追踪你的步骤。你可能输入了错误的内容。)

注意

三个 P:计划,计划,再计划

编程是复杂的事情。当你有一个问题要解决时,你不想只是坐下来,对着电脑开始乱敲,直到你敲出一个解决方案。你想要坐下来,用笔和 ePad(在我那个时代是笔和纸)来规划。我在编写显示游戏结束界面的三个步骤时给你提供了一个小的例子。帮助你设计解决方案的最佳方法之一是自顶向下的设计(也称为分而治之)。

在自顶向下的设计中,你从非常高的层面开始思考问题的解决方案,然后反复将这个解决方案分解成子解决方案,直到子解决方案变得小且易于管理。

添加“再玩一次”按钮

游戏结束界面很棒,但我们不想整天都盯着它看。好吧,那么让我们设置一下,点击游戏结束界面就可以重新开始游戏。AvoiderGameOverWorld需要持续检查鼠标是否被点击,然后将世界状态重置为AvoiderWorld,这样我们就可以再次玩游戏了。查看 Greenfoot 文档,我们可以看到mouseClicked()函数。让我们在AvoiderGameOverWorldact()方法中使用这个方法,以及更改世界状态的代码。将以下代码添加到AvoiderGameOverWorld中:

public void act() {
  // Restart the game if the user clicks the mouse anywhere
  if( Greenfoot.mouseClicked(this) ) {
    AvoiderWorld world = new AvoiderWorld();
    Greenfoot.setWorld(world);
  }
}

这段代码应该对你来说非常熟悉。if语句内的代码几乎与我们添加到AvoiderWorld类中的endGame()方法中的代码相同,只是这次我们创建并切换到AvoiderWorld

新的部分是检查用户是否点击了屏幕上的任何位置。如果用户刚刚点击了其参数中提供的对象,Greenfoot.mouseClicked() 方法返回 true。我们提供了 this 变量,它代表 AvoiderGameOverWorld 实例的整体。

编译并运行。做得好!我们的游戏进展得很顺利!

添加一个介绍屏幕

添加一个介绍屏幕非常简单,我们只需要执行我们在创建游戏结束屏幕时所做的许多相同步骤。首先,我们需要使用你想要的任何图形编辑器程序创建一个介绍屏幕图像。我创建的图像显示在图 18中。

添加介绍屏幕

图 18:我们游戏的介绍屏幕图像。

确保图像是 PNG 或 JPG 格式,并且像素大小为 600 x 400。将此图像保存在你的 AvoiderGame 场景的 images 文件夹中。

创建一个新的世界(通过继承 World),命名为 AvoiderGameIntroScreen,并将其与刚刚创建的图像关联起来。当你完成这个步骤后,你的场景的世界类区域应该看起来像图 19中所示的截图。

添加介绍屏幕

图 19:这些都是你在 AvoiderGame 中创建的所有世界。

设置初始屏幕

我们显然希望我们的新介绍屏幕在玩家第一次开始游戏时首先显示。要选择 AvoiderGameIntroScreen 世界作为我们的起始 World,我们需要在世界类区域中右键单击它,并在出现的弹出窗口中选择 new AvoiderGameIntroScreen() 菜单选项(见图 20)。

设置初始屏幕

图 20:这是关于选择我们的起始世界

让我们确保一切连接正确。编译并运行你的 Greenfoot 应用程序。你应该从你刚刚创建的介绍屏幕开始,但无法做太多其他事情。我们现在将解决这个问题。

添加“播放”按钮

我们将重复我们在从游戏结束屏幕实现游戏重启时所做的完全相同的步骤。

将以下代码添加到 AvoiderGameIntroScreen

public void act() {
  // Start the game if the user clicks the mouse anywhere 
  if( Greenfoot.mouseClicked(this) ) {
    AvoiderWorld world = new AvoiderWorld();
    Greenfoot.setWorld(world);
  }
}

这段代码应该对你来说非常熟悉。这正是我们添加到 AvoiderGameOverWorld 类中的代码。

编译并运行。享受乐趣。看看你能坚持多久!

到目前为止一切顺利,但确实缺少一些关键的游戏元素。

添加背景音乐

在本教程的这一部分,你需要在网上搜索一首你希望在游戏中播放的歌曲(.mp3)。

注意

获取音乐

每次你在游戏中添加资源(音乐或图形)时,确保你这样做是合法的。互联网上有许多网站提供免费使用提供的音乐或图片。永远不要使用专有音乐,并且始终引用你获取资源的来源。我从 newgrounds.com 获取了添加到游戏中的音乐,并在我的代码中为作者提供了信用。

我们只希望在开始玩游戏时播放音乐,而不是在介绍或游戏结束屏幕上播放。因此,我们在显示 AvoiderWorld 时开始播放音乐,在显示 AvoiderGameOverWorld 之前关闭它。我们只想播放一次音乐,所以不想在 act() 方法中添加播放音乐的代码——想象一下那样做的噪音!我们需要的是一个在对象创建时只被调用一次的方法。这正是类的构造函数所提供的。(如果你需要回顾类和对象是什么,请参阅 What have we just done? 部分的资料框)

注意

构造函数是什么?

在 Java 编程(以及其他面向对象的语言)中,我们在类中编写代码。一个描述了我们想要在程序中创建的对象的方法和属性。你可以把类看作是构建对象的蓝图。例如,我们的 Enemy 类描述了出现在我们的 Avoider 游戏中的每个敌人对象的行为和属性。每个 都有一个 构造函数,它执行每个创建的对象所需的全部初始化。你可以很容易地识别类的构造函数。构造函数的名称与它们所在的类完全相同,并且没有返回类型。作为一个快速测试,找到我们的 AvoiderWorld 类中的构造函数。找到了吗?

我们每次创建新对象时都会调用构造函数。在 Greenfoot 中,右键单击 Enemy 类,你会看到顶部的菜单选项是 new Enemy()Enemy() 部分是构造函数。new 关键字创建新对象,而 Enemy() 初始化该新对象。明白了吗?

以下是一些你应该阅读的好资源,以了解更多关于构造函数函数的信息:

docs.oracle.com/javase/tutorial/java/javaOO/constructors.html

java.about.com/od/workingwithobjects/a/constructor.htm

编写音乐代码

现在我们知道了代码应该放在哪里(大家说 constructor),我们需要知道要写什么代码。Greenfoot 提供了一个用于播放和管理音乐的类,称为 GreenfootSound。这个类使得播放音乐变得非常简单。在我向你展示要放入构造函数中的代码之前,你应该查看 GreenfootSound 的文档,看看你是否能弄清楚要写什么。

小贴士

不,真的!去阅读文档!自己尝试去做真的会对你有帮助。

这里是你需要添加到 AvoiderWorld 构造函数中的代码。

编写音乐代码

图 21:这是 AvoiderWorld 的构造函数

分析音乐代码

让我们看看AvoiderWorld构造函数中的每一行代码。首先,你有调用超类构造函数的调用,正如之前所述,这是为了正确初始化你的游戏世界。接下来,我们有这一行:

bkgMusic = new GreenfootSound("sounds/UFO_T-Balt.mp3");

这创建了一个新的GreenfootSound对象,并将对它的引用保存在bkgMusic变量中。你需要更改前面的代码,而不是使用sounds/UFO_T-Balt.mp3,你需要使用一个字符串来给出你下载以播放的音乐文件名(你需要将音乐保存在你的 Greenfoot 项目文件夹中的sounds文件夹中)。我们还需要声明在构造函数中使用的bkgMusic变量。为此,你需要在类的顶部添加一个变量声明,如图 22 所示。通过在类的顶部声明变量,它将可以访问你的类中的所有方法。这将在我们添加停止播放音乐的代码时变得很重要。

分析音乐代码

图 22:这显示了 AvoiderWorld 类中 bkgMusic 变量的声明

我们接下来要讨论的代码行是这一行:

bkgMusic.playLoop();

这行代码开始播放音乐,并在结束时重新开始播放。如果我们只做了bkgMusic.play(),那么这首歌只会播放一次。

构造函数中的最后一行是非常重要的一行,它是 Greenfoot 自动添加的。记得在本书的添加我们的英雄部分,我指导你将Avatar类(我们的英雄)的实例放置在屏幕中央,右键点击,并选择保存世界菜单选项吗?当你这样做时,Greenfoot 创建了这个prepare()方法。如果你查看这个方法的内容,你会看到其中包含了创建Avatar对象并将其添加到屏幕的代码。然后,它在构造函数中添加了对prepare()的调用。如果你再次选择保存世界菜单选项,这个prepare()方法将会更新。

好的,保存、编译并运行。它工作了吗?如果没有,回去找到错误。

停止音乐

如果你运行了你的代码,你在游戏中会有音乐,但是当你死亡并进入游戏结束屏幕时,音乐并没有关闭。我们必须在显示AvoiderGameOverWorld之前显式地关闭音乐。这很简单!我们只需要在之前添加到AvoiderWorld中的endGame()方法的开头添加以下代码行:

bkgMusic.stop();

现在,保存、编译并运行。它应该按照计划工作。

注意

私有、受保护和公共

Java 关键字privateprotectedpublic修改了 Java 中变量、方法或类的访问方式。良好的编程实践规定,你应该将所有类的实例变量设置为private,并且只通过方法访问该变量。对于方法,你希望只在你自己的private类中访问它们;否则,将其设置为public。关键字protected用于使方法对类的子类可用,但对外部类不可用。有关更多信息,请参阅以下链接:

你的任务

在继续之前执行以下操作:

  • 一旦显示游戏结束屏幕,播放音乐。你打算播放欢快的音乐来提振玩家的精神,还是播放悲伤和忧郁的音乐来真正打击他们?确保在切换到AvoiderWorld之前将其关闭。

  • 我们敌人的动作相当平淡。你能让它变得更有趣吗?一些想法是让敌人角色具有可变速度,左右漂移,或从顶部或底部进入。你将想出什么?

在尝试这些挑战之前,请记住创建AvoiderGame的备份副本。

接下来...

几乎完成了!我们已经构建了游戏的基础,接下来将添加一些内容使其更具挑战性。

提高可玩性

在本章的最后部分,我们将添加代码来提高游戏的可玩性。首先,我们将添加一个分数。接下来,我们需要随着时间的推移增加游戏的挑战性。随着玩家在游戏中的进步,我们希望提高挑战性;我们将添加一个等级系统来实现这一点。

游戏评分

我们的游戏正在发展;然而,我们需要一种方法来判断我们在游戏中的表现如何。有许多方法可以判断游戏表现,例如,完成的关卡、时间、进度等——但最常见的方法是为玩家分配分数。我们将在游戏中添加一个评分系统,奖励玩家避免的敌人数量。

添加 Counter 类

在游戏中计数并在屏幕上显示计数是如此常见,以至于 Greenfoot 为你提供了一个Counter类。要访问此类,你需要将其导入到你的场景中。为此,在 Greenfoot 的主菜单中选择编辑,然后选择导入类…子菜单选项。你将看到一个窗口,就像图 23 中显示的那样。确保在左侧选中Counter框,然后点击导入按钮。

添加 Counter 类

图 23:这是 Greenfoot 的导入类窗口

这将把Counter类添加到你的演员类列表中,以便在我们的游戏中使用,如图 24 所示。

添加 Counter 类

图 24:你的场景窗口中的 Actor 类部分现在包括 Counter 类

我们希望分数能立即在游戏中显示。在 Greenfoot 网站上的教程 4 (www.greenfoot.org/doc/tut-4) 中,你被介绍了“拯救世界”,以便自动将Actor放置在你的世界中。我将描述如何手动将Actor放置在你的世界中;具体来说,你将向你的AvoiderWorld世界添加一个Counter类的实例。

我们讨论了 Greenfoot 已经在你的AvoiderWorld()构造函数中添加了对prepare()方法的调用。在AvoiderWorld类中找到这个方法的定义。将其更改为以下代码:

private void prepare() {
  Avatar avatar = new Avatar();
  addObject(avatar, 287, 232);
  scoreBoard = new Counter("Score: ");
  addObject(scoreBoard, 70, 20);
}

这个方法的前两行已经存在。最后两行在游戏屏幕上放置了一个分数显示。scoreBoard = new Counter("Score: "); 这段代码创建了一个带有标签Score:的新Counter对象,并将其引用存储在scoreBoard变量中(我们尚未声明这个变量,但很快就会声明。)下一行代码将我们的Counter添加到游戏屏幕的左上角。

最后,我们需要在类的顶部声明scoreBoard变量。在构造函数上方添加private Counter scoreBoard;,如图图 25所示。

添加 Counter 类

图 25:在 AvoiderWorld 类中声明 scoreBoard 变量。

编译、运行并测试你的场景。

随着时间的推移提高分数

我们需要做最后一件事。我们需要在scoreBoard变量上调用setValue()来随时间增加我们的分数。一个我们可以这样做的地方是在AvoiderWorld中创建敌人时。思考一下,对于每个创建的敌人,你将得到一些分数,因为你最终需要避开它。以下是你在AvoiderWorld中的act()方法应该如何更改:

public void act() {
  // Randomly add enemies to the world
  if( Greenfoot.getRandomNumber(1000) < 20) {
    Enemy e = new Enemy();
    addObject( e, Greenfoot.getRandomNumber(getWidth()-20)+10, -30);
    // Give us some points for facing yet another enemy
    scoreBoard.setValue(scoreBoard.getValue() + 1);
  }
}

我所做的唯一改变是添加了关于分数的注释,并在scoreBoard上添加了对setValue()的调用。这段代码使用getValue()获取当前分数,将其加 1,然后使用setValue()设置新值。Counter类的典型用法也在Counter类的顶部注释中提供。查看它!

编译你的AvoiderGame场景并尝试运行。你是否得到了增加的分数?

添加等级

到目前为止,我们的游戏并不具有很大的挑战性。我们可以做的一件事是,让游戏随着时间的推移变得更加具有挑战性。为此,我们将在 Avoider 游戏中加入等级的概念。我们将通过定期增加敌人生成的速率和敌人移动的速度来增加游戏的挑战性。

增加生成速率和敌人速度

AvoiderWorld中添加两个变量,enemySpawnRateenemySpeed,并给它们设置初始值;我们将使用这两个变量来增加难度。你的AvoiderWorld类的顶部应该看起来像图 26

增加出生率和敌人速度

图 26:这显示了 AvoiderWorld 中的变量

根据得分增加难度

接下来,我们需要添加一个方法,根据玩家的得分增加游戏的难度。为此,我们需要将以下方法添加到AvoiderWorld中:

private void increaseLevel() {
  int score = scoreBoard.getValue();

  if( score > nextLevel ) {
    enemySpawnRate += 2;
    enemySpeed++;
    nextLevel += 100;
  }
}

increaseLevel()中,我们引入了一个新的变量nextLevel,我们需要在AvoiderWorld类的顶部添加其声明。以下是您需要添加到enemySpawnRateenemySpeed变量声明旁边的声明:

private int nextLevel = 100;

increaseLevel()函数中的代码可以看出,随着玩家得分的增加,我们同时增加了enemySpawnRateenemySpeed。我们需要做的最后一件事是在AvoiderWorldact()方法中使用enemySpawnRateenemySpeed变量来创建敌人,并从AvoiderWorldact()方法中调用increaseLevel()。以下是新的act()方法:

public void act() {
  // Randomly add enemies to the world
  if( Greenfoot.getRandomNumber(1000) < enemySpawnRate) {
    Enemy e = new Enemy();
    e.setSpeed(enemySpeed);
    addObject( e, Greenfoot.getRandomNumber(getWidth()-20)+10, -30);
    // Give us some points for facing yet another enemy
    scoreBoard.setValue(scoreBoard.getValue() + 1);
  }
  increaseLevel();
}

实现敌人速度增加

我现在很想大声喊出编译并运行!,但还有一个细节。在act()方法中,我们使用e.setSpeed(enemySpeed);这一行来改变敌人的速度;然而,我们从未在Enemy类中实现过该方法。此外,我们还需要对Enemy类进行一些修改,以便使用新设置的速度。

图 27给出了Enemy类的完整代码。

实现敌人速度增加

图 27:这显示了完成的 Enemy 类

如您所见,我们对Enemy类进行了一些非常简单的修改。我们添加了setSpeed()方法,该方法简单地接受一个整数参数,并使用该值来设置在类顶部声明的speed变量。在act()方法中,我们使用speed变量的值在setLocation()调用中;我们不断地将speed添加到当前的y坐标。

编译并运行,享受你的新游戏!

你的任务

由于这是 Avoider 游戏的结束说明。我将给你一些挑战性任务。祝你好运!尝试实现以下内容:

  • 一旦玩家的得分超过 600,除了我们现在拥有的敌人外,还需要添加一个新敌人。新敌人应该在外观上与我们的现有敌人非常不同。如果你觉得可以,让新敌人的移动方式也与现有敌人不同。

  • 定期生成一个提供英雄特殊能力的道具。例如,这个道具可以使英雄暂时无敌,允许英雄杀死三个敌人,或者缩小英雄的大小,使其更容易躲避敌人。

  • 在游戏结束屏幕上显示玩家的最终得分。

这些挑战肯定需要一些时间,你不应该感到必须尝试它们。我只是想给那些真正感兴趣的人提供一个继续在 Avoider 游戏上工作的方法。你不需要完成这些挑战就可以进入下一章。

接下来…

恭喜!你做到了!祝你好玩。玩你的新游戏。

摘要

本章展示了如何制作一个有趣且引人入胜的游戏。我们包含了鼠标控制、一个英雄角色、敌人、得分以及介绍和游戏结束屏幕。

由于本书假设您在 Greenfoot 中已有一些工作经验,因此本章也起到了刷新您对如何在 Greenfoot 中编程的记忆的作用。

在接下来的章节中,我们将探讨 Greenfoot 中的高级编程概念,这些概念将使您能够创建有趣、创新且引人入胜的应用程序。这些章节将假设您已经掌握了本章中的内容。

第二章:动画

*"没有欲望的学习会损害记忆,它所吸收的什么也保留不住。"
--莱昂纳多·达·芬奇

在 Greenfoot 场景中通过处理键盘或鼠标事件并适当地使用setLocation()方法来移动角色相对简单。然而,我们可以做得更好。通过进一步动画化我们的角色,我们可以赋予它们生命。我们可以给我们的玩家/用户一个充满活力、生机勃勃的世界的错觉。

从本质上讲,编程动画是一种幻觉艺术。通过在适当的时候添加微小的动作或图像变化,我们诱使用户相信我们的创作不仅仅是屏幕上的静态像素。在本章中,你将学习以下用于动画 Greenfoot 角色的技术:

  • 图像交换和移动

  • 定时和同步

  • 缓动

Greenfoot 是一个创建交互性和吸引人的应用程序的绝佳平台,您可以在互联网上共享或用作桌面应用程序。正是您创建这些类型应用程序的愿望使您来到这里,根据达芬奇的看法,正是这种愿望将帮助您无限期地保留这本书中的信息。

重温避免者游戏

在本章中,我们将继续完善我们在第一章“让我们直接进入…”中创建的避免者游戏。如果您跳过了那一章,或者只是想从一份全新的副本开始,您可以从 Packt Publishing 网站上的本书产品页面下载这个游戏的代码:www.packtpub.com/support。我在本章中略过的大部分概念很可能在前一章中已经详细讨论过;如有需要,请务必参考那一章。现在,打开 Greenfoot 中的AvoiderGame场景并继续阅读。

图像交换和移动

图像交换是古老的动画技术。也许在小时候,你在纸垫的角落画了一个棒状人物,并在每一页上稍作改变。当你快速翻阅页面时,你的棒状人物就活了起来。图 2展示了我尝试的这种动画。

图像交换和移动

图 1:这展示了传统的棒状人物动画

在 Greenfoot 中,我们将通过快速切换图像来动画化角色,以达到图 1中显示的纸动画相同的效果。我们将学习如何使用 Greenfoot 的setImage()方法来实现这一点。

使用 setImage()

当我们通过从Actor类或我们的Actor子类之一派生新Actor时,Greenfoot 会提示我们输入新类的名称并为其选择一个图像。Greenfoot 还允许我们在场景运行时动态设置Actor对象的图像,使用 Greenfoot 的Actor类提供的setImage()方法。以下是从 Greenfoot 文档中摘录的内容:

public void setImage(java.lang.String filename)
throws java.lang.IllegalArgumentException

Set an image for this actor from an image file. The file may be in jpeg, gif or png format. The file should be located in the project directory.

Parameters:
filename - The name of the image file.

如你所见,setImage()允许我们通过指定任何JPEGGIFPNG文件的路径来设置演员的图像。默认情况下,Greenfoot 会在你的 Greenfoot 项目中包含的images文件夹中查找。你应该将你将在场景中使用的所有图像放置在这个文件夹中。

让我们使用这种方法来为 Avoider 游戏中的敌人添加动画效果。

让敌人不那么开心

Avoider 游戏中的敌人太开心了。让我们让它们变得悲伤和失望,因为它们意识到我们的英雄将避开它们。

查找资源

我们需要做的第一件事是找到一组合适的笑脸图像,我们可以将其切换到我们的场景中的Enemy演员。通常,你需要使用 Greenfoot 内置的图像编辑器或像 GIMP 或 Adobe Illustrator 这样的工具来创建自己的图像资源,或者你可以从互联网上下载图像;有很多免费图像可供选择。幸运的是,Greenfoot 的默认安装已经包含了我们需要的所有图像。在 OSX 上,图像位于以下文件夹中:

/Applications/Greenfoot 2.3.0/Greenfoot.app/Contents /Resources/Java/greenfoot/imagelib/symbols

在 Windows 上,图像位于以下文件夹中:

C:/Program Files/Greenfoot/lib/greenfoot/imagelib/symbols

为了方便起见,我已经将所有笑脸图像放在了这本书的文件存储库中,可以在 Packt Publishing 网站上找到,网址为www.packtpub.com/sites/default/files/downloads/0383OS_ColoredImages.pdf

你需要将文件smiley1.pngsmiley3.pngsmiley4.pngsmiley5.png放入你的AvoiderGame目录下的images文件夹中。完成此操作后,你的图像文件夹应包含图 2中显示的文件。

查找资源

图 2:这是你的 AvoiderGame 项目中的图像文件夹内容。

现在我们已经有了可用的图像,我们可以开始编码了。

小贴士

注意,一旦你将演员的图像设置为 Greenfoot 在创建时提供的图像,例如图 2中的skull.png,Greenfoot 会自动将图像放置在你的images文件夹中。因此,你不必从磁盘上的位置复制笑脸图像,你可以创建一个新的演员,然后依次将这个演员的图像设置为每个笑脸。然后,你可以简单地删除这个新演员。你会发现你的图像文件夹看起来就像图 2中显示的那样。

根据演员位置调用 setImage()

在 Greenfoot 主场景窗口的演员类部分双击Enemy演员以开始编辑Enemy代码。我们练习良好的功能分解,并在Enemyact()方法中简单地添加对changeDispositon()的调用;我们很快就会编写这个方法。现在你的act()方法应该看起来像这样:

public void act() {
  setLocation(getX(), getY() + speed);
  changeDisposition();
  checkRemove();
}

现在,我们将实现changeDisposition()方法。在这个方法中,我们想要改变敌人的状态,因为他们逐渐意识到他们不会得到英雄。让我们假设我们的敌人直到达到屏幕中间都保持乐观。之后,我们将逐渐让他们陷入绝望。

changeDisposition()方法的实现中,我们将使用一个实例变量来跟踪我们需要显示的下一张图片。您需要在速度实例变量的声明下方添加这个变量声明初始化(在类顶部任何方法之外):

private int timeToChange = 1;

在此基础上,我们现在可以查看changeDisposition()的实现。以下是我们的代码:

private void changeDisposition() {
  int ypos = getY();
  int worldHeight = getWorld().getHeight();
  int marker1 = (int) (worldHeight * 0.5);
  int marker2 = (int) (worldHeight * 0.75);
  int marker3 = (int) (worldHeight * 0.90);
  if( timeToChange == 1 && ypos > marker1) {
    setImage("smiley4.png");
    timeToChange++;
  }
  else if( timeToChange == 2 && ypos > marker2) {
    setImage("smiley3.png");
    timeToChange++;
  }
  else if( timeToChange == 3 && ypos > marker3) {
    setImage("smiley5.png");
    timeToChange++;
  }
}

这段代码背后的逻辑很简单。我们想要在敌人下落的过程中选择特定的位置来更改图片。一个复杂的问题是敌人的速度可以通过setSpeed()方法来改变。我们在AvoiderWorld类中使用这个方法来增加敌人的速度,以增加游戏的难度。因此,我们不能简单地使用像if( ypos == 300)这样的代码来更改敌人的图片,因为演员可能永远不会有一个精确的y位置为300。例如,如果敌人的速度是 7,那么它下落时的y位置如下:7, 14, 21, …, 294, 301, 308,等等。

如我们所见,敌人永远不会有一个精确的y位置为 300。你可能接下来想要尝试像if( ypos > 300 )这样的代码;然而,这并不是最优的,因为这会导致图片在超过 300 的每个 y 位置上持续被设置。因此,我们应该采用changeDisposition()中展示的方法,并使用timeToChange来控制一次性的、顺序的图片更改。

现在我们已经理解了changeDisposition()背后的逻辑,让我们逐行分析。我们首先创建变量来保存我们想要更改敌人图片的位置。这些位置基于场景的高度;marker1位于高度的 50%,marker2位于高度的 75%,而marker3位于敌人从屏幕底部退出之前的一个稍微靠前的位置。if语句在更改演员图片之前测试两个条件。它检查是否使用timeToChange来更改特定图片,以及演员是否已经通过了一个给定的y位置。

小贴士

在之前的代码中,有一些行将十进制数字(类型为double)转换为整数(类型为int),例如这一行:

int marker1 = (int) (worldHeight * 0.5)

关于将一个变量转换为另一个变量(也称为类型转换)的更多信息,请参阅以下链接:

docs.oracle.com/javase/specs/jls/se7/html/jls-5.html

编译你的 Greenfoot 场景并玩游戏。看看你是否能获得超过 250 分的分数!完全坦白:在写下最后一句话后,我连续玩了四次游戏,得到了以下分数:52,33,28,254。哇!254!

注意

功能分解

功能分解与自顶向下的设计密切相关,这是一个通过将问题重新定义为更小、更简单的子问题来反复定义问题的过程。当你为程序中的特定动作或功能编写代码时,尝试思考你可以编写的更小的方法,你可以将它们组合起来解决更大的问题。

通常,你希望编写少于 40 行代码的方法,并且只实现一个定义良好的任务。实际上,如果可能的话,我更喜欢做得更小。你会发现,如果你遵循这个实践,代码编写、调试和修改都会更容易。在这本书中,我使用了功能分解。你会发现,书中所有的 act() 方法主要包含对其他方法的调用序列。

使用 setLocation()

setImage() 方法是 Greenfoot 中用于动画角色的最有用的方法;然而,以某些方式移动角色也可以产生有趣的效果。我们已经使用 setLocation() 来移动敌人和我们的英雄;现在让我们用它来动画背景星系,使其看起来像我们在穿越太空。

创建星系

我们将提供各种大小、以不同速度在背景中移动的星星,以产生高速穿越太空的效果。创建星系非常简单,我们已经有非常相似的代码。想象一下,如果我们的敌人有一个小光点的图像,而不是笑脸,而我们有很多这样的敌人。哇!你就有了一个星系。

一张白纸

如果我们要创建自己的动态星系,那么我们就不再需要与 AvoiderWorld 关联的当前背景图像。然而,如果我们把这个类改为没有与之关联的图像,那么我们将会得到一个白色背景——这不是外太空的一个很好的表现。

解决方案是创建一个新的纯黑色、600 x 400 像素的图像,然后将其作为 AvoiderWorld 类的背景图像。启动你最喜欢的图像编辑器或使用 Greenfoot 内置的编辑器,创建一个大黑矩形,将其保存为 PNG 文件到你的 Avoider 项目的 images 文件夹中,然后将 AvoiderWorld 设置为使用这个新图像作为背景。

星类

对于我们的星星,我们将做一些不同的事情。我们不会将星星的图像设置为包含图形的文件,而是将动态绘制图像。由于光点并不复杂,这将很容易做到。

要创建我们的星星演员,在Actor 类部分右键单击Actor类,并选择新建子类…。在弹出的新建类窗口中,将新类名称输入为Star,并将新类图像选择为无图像

小贴士

记住,我们在第一章中讲解了如何创建新的演员,让我们直接进入…

打开一个新的代码编辑器窗口,为你的新Star类添加以下构造函数:

public Star() {
  GreenfootImage img = new GreenfootImage(10,10);
  img.setColor(Color.white);
  img.fillOval(0,0,10,10);
  setImage(img);
}

这个构造函数动态创建了一个用于Star类图像的图像。首先,我们创建了一个宽度为10像素、高度为10像素的新图像。接下来,我们设置用于在这个图像中绘制任何内容的颜色。我们通过在类文件顶部添加以下import语句来获取对Color类的访问权限(有关更多信息,请参阅下面的信息框):

import java.awt.Color;

在设置颜色后,我们使用fillOval()方法绘制一个椭圆形。fillOval()的第一个两个参数指定了我们正在绘制的形状的左上角相对于我们图像左上角的偏移量。图 3显示了这种映射。fillOval()的下一个两个参数指定了包含我们的椭圆形的边界框的宽度和高度。由于我们的宽度和高度相同,fillOval()将绘制一个圆。最后,我们将演员的图像设置为刚刚创建的新图像。

Star 类

图 3:这显示了使用 fillOval()的第一个两个参数值为 8 和 5 的效果

注意

处理颜色

Star()构造函数中,我们进行了一个涉及颜色的操作。计算机(以及基本上任何带有屏幕的东西)上有几种不同的方式来表示颜色,我们将使用 RGBA 颜色模型。如果你对此好奇,你可以在en.wikipedia.org/wiki/RGBA_color_space上了解更多关于它的信息。

幸运的是,我们不需要了解太多关于理论的知识。Java 提供了一个名为Color的类,它为我们管理了大部分的复杂性。要将这个Color类引入到你的代码中,你需要在文件顶部添加一个import语句。这个import语句是import java.awt.Color;。如果你没有将这个添加到上面的代码中,你会得到编译错误。

要了解更多关于这个Color类的信息,请查看官方文档docs.oracle.com/javase/7/docs/api/java/awt/Color.html

我们接下来要为Star类添加的是act()方法。我们只需要慢慢将这个演员向下移动到屏幕底部,然后一旦它从屏幕底部移出就将其移除。我们使用setLocation()来完成前者,使用checkRemove()方法来完成后者。以下是act()checkRemove()方法完成的代码:

public void act() {
  setLocation(getX(), getY()+1);
  checkRemove();
}

private void checkRemove() {
  World w = getWorld();
  if( getY() > w.getHeight() + 30 ) {
    w.removeObject(this);
  }
}

checkRemove()方法与在Enemy类中使用并解释过的代码完全相同,请参阅第一章,“让我们直接进入…”。实际上,Star类和Enemy类之间有很多相似之处,以至于我认为我们应该提前将Enemy类中的setSpeed()方法添加到Star类中,因为在我们的移动星星场实现中,我们很可能需要它。将此方法添加到Star类中:

public void setSpeed( int s) {
  speed = s;
}

正如我们在Enemy类中所做的那样,我们需要在类的顶部添加实例变量speed。以下是变量声明的代码:

int speed = 1;

我们应该在act()方法中再进行一次修改,现在使用speed变量来移动Star对象。将act()方法中的setLocation()代码修改为如下:

setLocation(getX(), getY() + speed);

Star类的完整代码在图 4中展示。

星星类

图 4:这显示了完成的Star类实现

这将是编译场景并确保你没有拼写错误的好时机。我们还没有在我们的游戏中添加星星,所以你不会注意到游戏中的任何区别。添加星星是我们接下来要做的。

创建移动场

我们将在AvoiderWorld类中生成我们的星星。打开这个类的编辑器窗口,并在act()方法中添加一行代码来调用我们尚未编写的generateStars()方法,但很快就会编写。你的act()方法现在应该看起来像这样:

public void act() {
  generateEnemies();
  generateStars();
  increaseLevel();
}

generateStars()方法以类似于generateEnemies()创建新敌人的方式创建新的星星。以下是generateStars()的代码:

private void generateStars() {
  if( Greenfoot.getRandomNumber(1000) < 350) {
    Star s = new Star();
    addObject( s, Greenfoot.getRandomNumber(getWidth()-20)+10, -1);
  }
}

if语句决定了我们是否想在此时创建一个星星。有 35%的概率我们会创建一个星星,这最终会创建一个相当密集的星星场。在if语句内部,我们创建一个新的Star对象并将其添加到World中。添加此代码并编译运行游戏,看看你的想法。你喜欢星星吗?它们还可以,但看起来有点像在下雨的高尔夫球。我们可以做得更好。

使用视差

视差是近处的物体似乎相对于远处的物体在观看角度上处于不同位置的效果。例如,如果你曾经从汽车窗户向外看,并观察树木移动,你会注意到靠近你的树木似乎比背景中的树木移动得更快。我们可以利用这种现象给我们的星星场带来深度感。

让我们将generateStars()方法修改为创建两种类型的星星。一些会靠近,一些会远离。靠近的星星会移动得更快,亮度也会比远离的星星高,但我们将会生成更多远离的星星。如果你将我们的屏幕想象成一个通向太空的窗口,我们将看到更远处的物体,而不是近处的物体。因此,我们需要更多的星星。图 5展示了这一点。

使用视差

图 5:这表明,通过窗户看去,对于更远处的物体,你的视野更宽。

最后,我们希望添加一些随机的变化到星星中,这样生成的星星场就不会看起来太均匀。这是我们的视差增强的 generateStars() 方法:

private void generateStars() {
  if( Greenfoot.getRandomNumber(1000) < 350) {
    Star s = new Star();
    GreenfootImage image = s.getImage();
    if( Greenfoot.getRandomNumber(1000) < 300) {
      // this is a close bright star
      s.setSpeed(3);
      image.setTransparency(
      Greenfoot.getRandomNumber(25) + 225);
      image.scale(4,4);
    } else {
      // this is a further dim star
      s.setSpeed(2);
      image.setTransparency(
      Greenfoot.getRandomNumber(50) + 100);
      image.scale(2,2);
    }
    s.setImage(image);
    addObject( s, Greenfoot.getRandomNumber(
    getWidth()-20)+10, -1);
  }
}

我们添加了访问当前星星图像、更改图像并将其设置为新的星星图像的功能。内部的 if-else 语句处理了附近和远处的星星的变化。有 30%的几率星星会是近处的。附近的星星速度更快(setSpeed())、亮度更高(setTransparency())和更大(scale())。

setTransparency() 方法接受一个整数参数,用于指定图像的透明度。对于完全不透明的物体,你应输入值 255;对于完全透明的物体,输入 0。我们使远处的星星更透明,这样更多的黑色背景就会透过来,使其不那么明亮。GreenfootImages 上的 scale() 方法用于改变图像的大小,以便它适合由该方法的前两个参数定义的边界框。正如我们在代码中所看到的,附近的星星被缩放到一个 4 x 4 像素的图像中,而远处的星星被缩放到一个 2 x 2 像素的图像中。

我们离完成星星场已经非常接近了。编译并运行场景,看看你现在对这个场景的看法。

星空看起来很棒,但仍然有两个问题。首先,当游戏开始时,背景是完全黑色的,然后星星开始下落。为了真正保持你在太空中的错觉,我们需要游戏从星星场开始。其次,星星正在生成在敌人、我们的英雄和得分计数器上方;这真的破坏了它们远处的错觉。让我们来修复这个问题。

解决星星在屏幕上其他角色前面的问题只需要一行代码。这是你需要添加到 AvoiderWorld 构造函数中的代码行:

setPaintOrder(Avatar.class, Enemy.class, Counter.class);

setPaintOrder() 方法定义在 World 类中,AvoiderWorld 是其子类。这个方法允许你设置屏幕上显示的类的顺序。因此,我们首先列出 Avatar 类(它将在所有东西的顶部),然后是 Enemy 类,最后是 Counter 类。按照这种顺序,例如,我们的敌人将显示在得分上方。任何未列出的类都将绘制在所有已列出的类之后;因此,我们的星星将位于屏幕上所有角色的后面。

如果我们对 generateStars() 方法进行小的修改,绘制初始的星星场就很容易了。目前,我们的星星由于这一行而硬编码为从 -1y 坐标开始:

addObject( s, Greenfoot.getRandomNumber(getWidth()-20)+10, -1);

如果我们将 generateStars() 修改为接受一个整数参数,该参数指定绘制星星的 y 值,那么我们可以使用这个方法来创建初始的星星场。看 generateStars() 的第一行:

private void generateStars() {

改成这样:

private void generateStars(int yLoc) {

取方法中的最后一行:

addObject( s, Greenfoot.getRandomNumber(getWidth()-20)+10, -1);

改成这个:

addObject( s, Greenfoot.getRandomNumber(getWidth()-20)+10, yLoc);

这两个行更改使我们能够为我们的星星指定任何起始y值。由于这个更改,我们需要在act()方法中将generateStars()的调用更改为以下代码行:

generateStars(-1);

如果你编译并运行场景,你应该看到的唯一区别是星星现在真正在背景中。我们仍然需要添加一个简单的方法定义和调用来绘制初始星星场。方法定义如下:

private void generateInitialStarField() {
  for( int i=0; i<getHeight(); i++ ) {
    generateStars(i);
  }
}

如果我们游戏的高度是四百,那么这种方法会调用generateStars()四百次。每次,它都会提供一个不同的y值来绘制星星。我们将通过在构造函数中添加这一行来用星星填满屏幕:

generateInitialStarField();

我们对AvoiderWorld类的定义进行了很多更改,这使得你可能在错误的地方放置了代码的可能性越来越大。以下是你可以用来检查你的代码的AvoiderWorld类的完整列表:

import greenfoot.*;

public class AvoiderWorld extends World {
  private GreenfootSound bkgMusic;
  private Counter scoreBoard;
  private int enemySpawnRate = 20;
  private int enemySpeed = 1;
  private int nextLevel = 100;

  public AvoiderWorld() {
    super(600, 400, 1, false);
    bkgMusic = new GreenfootSound("sounds/UFO_T-Balt.mp3")
    // Music Credit:
    //	http://www.newgrounds.com/audio/listen/504436 by T-balt
    bkgMusic.playLoop();
    setPaintOrder(Avatar.class, Enemy.class, Counter.class);
    prepare();
    generateInitialStarField();
  }

  public void act() {
    generateEnemies();
    generateStars(-1);
    increaseLevel();
  }

  private void generateEnemies() {
    if( Greenfoot.getRandomNumber(1000) < enemySpawnRate) {
      Enemy e = new Enemy();
      e.setSpeed(enemySpeed);
      addObject( e, Greenfoot.getRandomNumber(
      getWidth()-20)+10, -30);
      scoreBoard.setValue(scoreBoard.getValue() + 1);
    }
  }

  private void generateStars(int yLoc) {
    if( Greenfoot.getRandomNumber(1000) < 350) {
      Star s = new Star();
      GreenfootImage image = s.getImage();
      if( Greenfoot.getRandomNumber(1000) < 300) {
        // this is a close bright star
        s.setSpeed(3);
        image.setTransparency(
        Greenfoot.getRandomNumber(25) + 225);
        image.scale(4,4);
      } else {
        // this is a further dim star
        s.setSpeed(2);
        image.setTransparency(
        Greenfoot.getRandomNumber(50) + 100);
        image.scale(2,2);
      }
      s.setImage(image);
      addObject( s, Greenfoot.getRandomNumber(
      getWidth()-20)+10, yLoc);
    }
  }

  private void increaseLevel() {
    int score = scoreBoard.getValue();
    if( score > nextLevel ) {
      enemySpawnRate += 2;
      enemySpeed++;
      nextLevel += 100;
    }
  }

  public void endGame() {
    bkgMusic.stop();
    AvoiderGameOverWorld go = new AvoiderGameOverWorld();
    Greenfoot.setWorld(go);
  }

  private void prepare() {
    Avatar avatar = new Avatar();
    addObject(avatar, 287, 232);
    scoreBoard = new Counter("Score: ");
    addObject(scoreBoard, 70, 20);
  }

  private void generateInitialStarField() {
    int i = 0;
    for( i=0; i<getHeight(); i++ ) {
      generateStars(i);
    }
  }
}

编译并运行你的游戏。这已经很不错了。你的游戏应该看起来像图 6A中显示的截图。

使用视差

图 6A:这显示了到目前为止的游戏

使用 GreenfootImage

等一下。我是怎么知道 Greenfoot 的GreenfootImage类以及它包含的setColor()fillOval()方法的?答案是简单的,因为我阅读了文档。我了解到 Greenfoot 提供了GreenfootImage类来帮助处理和操作图像。一般来说,Greenfoot 提供了一套有用的类来帮助程序员创建交互式应用程序。我们在第一章中学习了World类和Actor类,让我们直接进入…图 6B显示了 Greenfoot 提供的所有类。

使用 GreenfootImage

图 6B:这显示了 Greenfoot 提供的类,以帮助你编写应用程序。此截图直接来自 Greenfoot 的帮助文档。

你可以通过访问 Greenfoot 的网站来访问 Greenfoot 的文档,正如我在第一章中建议的那样,让我们直接进入…。如果你不在网上,你可以通过在 Greenfoot 的主菜单中选择帮助菜单选项,然后从下拉菜单中选择Greenfoot 类文档来访问文档。这将使用默认的网页浏览器打开 Greenfoot 的类文档。

小贴士

Greenfoot 的类文档非常简短和简洁。你应该花 20-30 分钟阅读 Greenfoot 提供的每个类以及这些类中包含的每个方法。这将是一个非常值得的时间投资。

时间和同步

在 Greenfoot 中创建逼真的动画时,时间安排非常重要。我们经常需要让演员对事件做出临时的动画反应。我们需要一种方式来允许(或阻止)某些事物持续一定的时间。使用 Greenfoot 提供的SimpleTimer类(你可以像在第一章中导入Counter类一样将其导入你的场景中),你可以等待特定的时间;然而,等待特定的时间很少是正确的选择。

为什么呢?好吧,Greenfoot 为玩家/用户提供了一种通过位于 Greenfoot 主场景窗口底部的速度滑块来加快或减慢场景的能力。如果你在代码中等待了 2 秒钟,然后玩家加快了游戏速度,那么相对于其他所有事物的速度,2 秒钟的等待时间在游戏中会持续更长;如果用户减慢了场景,则会产生相反的效果。我们希望使用一种在 Greenfoot 中“等待”的方法,该方法与游戏速度成比例。

我们将探讨在 Greenfoot 中计时事件的三种不同方法:延迟变量、随机动作和触发事件。

延迟变量

延迟变量与计时器的概念非常相似。然而,我们不会计算秒数(或毫秒数),而是计算调用act()方法的次数。这将与速度滑块精确成比例,因为该滑块控制act()方法调用之间的时间。接下来,我们将查看使用延迟变量的示例。

伤害头像

我们的游戏有点苛刻。如果你触碰到敌人一次,你就会死亡。让我们改变游戏,这样你每次被击中都会受到伤害,而杀死我们的英雄需要四次打击。我们需要做的第一件事是创建一个实例变量,该变量将跟踪我们英雄的健康状况。将此实例变量添加到Avatar类的顶部,任何方法之外:

private int health = 3;

每当我们的英雄接触到敌人时,我们将从这个变量中减去一个。当这个变量为0时,我们将结束游戏。

当我们的英雄被敌人击中时,我们希望向玩家提供视觉反馈。我们可以通过在游戏顶部添加健康条或生命指示器来实现这一点;然而,让我们只是让我们的英雄看起来受伤。为此,我们需要创建skull.png图像的副本,该图像用于表示Avatar类的实例,并增强它们以看起来受损。你可以使用图像编辑器,如 GIMP、Adobe Illustrator 或其他编辑器来做出这些更改。图 7显示了受损的skull.png图像的版本。确保你将你的头骨图像命名为与我完全相同的方式。第一个图像skull.png已经在图像文件夹中;其他三个需要命名为skull1.pngskull2.pngskull3.png。为什么以这种方式命名如此重要,很快就会变得明显。

伤害头像

图 7:这是我的四个skull.png副本,显示了增加的伤害。它们分别命名为 skull.png、skull1.png、skull2.png 和 skull3.png。

目前,我们的Avatar类中的act()方法如下所示:

public void act() {
  followMouse();
  checkForCollisions();
}

我们将修改checkForCollisions()函数的实现,以处理我们的英雄拥有生命并看起来受损的情况。目前的代码片段如下所示:

private void checkForCollisions() {
  Actor enemy = getOneIntersectingObject(Enemy.class);
  if( enemy != null ) {
    getWorld().removeObject(this);
    Greenfoot.stop();
  }
}

我们需要将其更改为:

private void checkForCollisions() {
  Actor enemy = getOneIntersectingObject(Enemy.class);
  if( hitDelay == 0 && enemy != null ) {
    if( health == 0 ) {
      AvoiderWorld world = (AvoiderWorld) getWorld();
      world.endGame();
    }
    else {
      health--;
      setImage("skull" + ++nextImage + ".png"););
      hitDelay = 50;
    }
  }
  if( hitDelay > 0 ) hitDelay--;
}

如我们所见,我们添加了相当多的代码。第一个if语句检查在受到敌人伤害之前需要满足的两个条件:首先,自上次我们受到敌人伤害以来已经过去了足够的时间,其次,我们现在正在接触Enemy类的一个实例。当英雄接触到敌人并受到伤害时,我们希望给我们的英雄一段短暂的不可伤害时间,以便移动,而不会在每次调用act()方法时继续受到伤害。如果我们不这样做,英雄会在你眨眼之前受到四次打击。我们使用hitDelay整型变量来计算等待时间。如果我们已经受到打击,我们将hitDelay设置为50,如内层if-else语句的else部分所示。函数中的最后一个if语句继续递减hitDelay。当hitDelay减到0时,我们可以被敌人击中,并且不再递减hitDelay

注意

Java 增量与递减运算符

在最后一段代码中,我们大量使用了 Java 的增量(++)和递减(--)运算符。它们简单地分别从它们应用的变量中加一或减一。然而,在使用它们时有一些微妙之处需要你注意。看看以下代码:

int x = 0, y=0, z=0;
y = ++x;
z = x++;

注意,增量运算符可以应用于变量之前(前缀)或之后(后缀)。在这段代码完成后,x2y1z1。你可能惊讶z1而不是2。原因是后缀增量运算符将在变量递增之前返回变量的值。有关更多信息,请参阅以下链接:

docs.oracle.com/javase/tutorial/java/nutsandbolts/op1.html

在内层if-else语句中,我们知道我们已经受到敌人的打击。我们检查我们的health是否为0;如果是,我们就死了,游戏就像以前一样结束。如果我们还有health,我们就减少我们的health,更改我们的图像,并设置hitDelay

我们将图像更改为下一个更损坏的图像的方式是基于我们之前如何命名文件。我们通过将skull字符串与一个整数连接,然后再与.png字符串连接来构建文件名。这种方法为我们提供了一种简短且易于程序化的更改图像的方法。另一种选择是使用switch语句,根据health的值调用带有不同文件名的setImage()。在我们的新版本checkForCollisions()中,我们使用了两个新的实例变量;我们仍然需要声明和初始化这些变量。在添加本节开头添加的health变量下方添加这些行:

private int hitDelay = 0;
private int nextImage = 0;

现在,编译你的场景并验证你的英雄需要受到四次攻击才能死亡。

小贴士

hitDelay变量是延迟变量的一个好例子。在本书的其余部分,我们将使用延迟变量来计时各种活动。在继续之前,请确保你理解我们如何使用hitDelay

随机动作

随机动作是模拟简单智能或自然现象的最有效方法之一。它以不可预测的方式重复动作,并为游戏增添了悬念和挑战。我们已经在随机生成英雄需要躲避的敌人流。我们现在将使用它们来改进我们的星系动画。

闪烁

星星已经看起来很棒,并为游戏提供了真正的运动感。我们将通过让它们像真正的星星一样闪烁来增强它们。为此,我们使用setTransparency()方法使星星完全透明,并使用延迟变量等待一段时间后再将星星再次变得不透明。我们将使用 Greenfoot 的随机数生成器来确保星星的闪烁不频繁。首先,我们在Star类的act()方法中添加一个方法调用checkTwinkle()

public void act() {
  setLocation(getX(), getY()+speed);
  checkRemove();
  checkTwinkle();
}

我们需要在speed变量声明下添加以下延迟变量以及用于存储对象顶部当前透明度的变量:

int twinkleTime = 0;
int currentTransparency = 0;

以下是对checkTwinkle()的实现:

private void checkTwinkle() {
  GreenfootImage img = getImage();
  if( twinkleTime > 0 ) {
    if( twinkleTime == 1) {
      img.setTransparency(currentTransparency);
    }
    twinkleTime--;
  } else {
    if( Greenfoot.getRandomNumber(10000) < 10) {
      twinkleTime = 10;
      currentTransparency = img.getTransparency();
      img.setTransparency(0);
    }
  }
}

让我们看看外部if-else语句的else部分。以很小的随机概率,我们将twinkleTime(我们的延迟变量)设置为10,保存星星当前透明度以便稍后恢复,然后将透明度设置为0

初始if-else语句的if部分,如果twinkleTime大于0,则递减twinkleTime,当twinkleTime等于1时恢复我们星星的透明度。因为twinkleTime只设置为10,所以星星将只在极短的时间内不可见。这种短暂的闪烁给人一种星星闪烁的错觉。

编译并运行场景,看看你是否能捕捉到星星的闪烁。如果你在验证这一点上有困难,请改变闪烁发生的频率并再次尝试。

触发事件

当某个事件发生时触发演员的变化是另一种动画方式。例如,你可能有一个敌人演员,只有当你进入一定范围内时,它才会追逐你。你也可能有一个演员对键盘事件或位置做出响应。

在本节中,我们将给我们的英雄添加眼睛。显然,我们的英雄非常关心附近的敌人,肯定想密切关注他们。

小贴士

给演员添加动画眼睛是赋予该演员个性的一种极好的方式。眼睛非常富有表情,可以轻松地表达兴奋、悲伤或恐惧。不要犹豫,添加动画眼睛。

添加眼睛

这可能看起来有点奇怪,但我们将创建一个单独的 Eye 角色演员。我们这样做有几个原因。首先,要让眼睛四处看需要相当多的代码。我们可以将这段代码封装在 Eye 类中,并使我们的 Avatar 类更加简洁。其次,将眼睛作为独立的实体意味着我们可以将它们添加到未来的演员中,即使我们改变了 Avatar 类的图像,它们仍然可以正常工作。

另一种选择是为我们想要看的每个方向创建一个带有眼睛的头骨图像。我们为英雄创建不同图像以显示不同等级的伤害的事实将进一步复杂化问题。因此,我们将创建一个单独的 Eye 角色演员。

创建一个新的 Actor 子类,命名为 Eye。不要将图像与这个 Actor 类关联。我们将动态绘制一个眼睛的图像,并在需要朝不同方向看时适当地重新绘制它。以下是 Eye 类的实现:

import greenfoot.*; 
import java.awt.Color;
import java.util.List;

public class Eye extends Actor {

  public Eye() {
    drawEye(2,2);
  }

  public void act() {
    lookAtEnemies();
  }

  public void lookAtEnemies() {
    List<Enemy> eList = getObjectsInRange(120, Enemy.class);
    if( !eList.isEmpty() ) {
      Enemy e = eList.get(0);
      if( e.getX() < getX() ) {
        if( e.getY() < getY() ) {
          drawEye(1,1);
        } else {
          drawEye(1,3);
        }
      } else {
        if( e.getY() < getY() ) {
          drawEye(3,1);
        } else {
          drawEye(3,3);
        }
      }
    }
  }

  private void drawEye(int dx, int dy) {
    GreenfootImage img = new GreenfootImage(10,10);
    img.setColor(Color.white);
    img.fillOval(0,0,10,10);
    img.setColor(Color.black);
    img.fillOval(dx,dy,6,6);
    setImage(img);
  }
}

这个类的主要有两个方法:drawEye() 方法和 lookAtEnemies() 方法。drawEye() 图像使用与我们在 Star 类中绘制星星图像相同的方法来绘制眼睛。对于眼睛,我们只需要绘制一个额外的黑色圆圈作为瞳孔。drawEye() 方法接受两个整数参数,提供瞳孔在眼睛中的位置。fillOval() 的偏移部分在 图 3 中进行了演示。总结来说,第一个 fillOval() 命令绘制了眼睛较大的白色部分,第二个 fillOval() 命令在给定的偏移量处绘制了小的黑色瞳孔,以模拟朝某个方向注视。

lookAtEnemies() 方法会在眼睛给定距离内找到所有敌人,并使用 drawEye() 方法注视它找到的第一个敌人。通过使用 if 语句比较敌人的 xy 位置与自己的位置,眼睛将敌人分类为四个象限之一:左上,左下,右上和右下。利用这些信息,drawEye() 方法分别使用整数参数 (1,1)(1,3)(3,1)(3,3) 被调用。图 8 展示了敌人所在的象限与 drawEye() 调用之间的相关性。

添加眼睛

图 8:这显示了敌人位置与调用 drawEye() 的映射

lookAtEnemies() 中,我们使用了一种新的碰撞检测方法,称为 getObjectsInRange()。此方法与 getOneIntersectingObject() 有两种不同之处。首先,它不是使用调用 Actor 类的边界框来确定是否发生碰撞,而是在调用 Actor 类周围绘制一个半径由 getObjectsInRange() 的第一个参数定义的圆。此方法返回该圆中找到的所有敌人,而不仅仅是单个敌人。敌人以 Java List 数组的形式返回。在 Eye 类的顶部,我们需要包含 import java.util.List; 代码以使用 List 数据类型。我们一次只能盯着一个敌人,所以我们选择使用 get() 方法并传递整数值 0 来访问列表中的第一个敌人。以下是 Greenfoot 关于 getObjectsInRange() 的文档:

protected java.util.List getObjectsInRange(int radius, java.lang.Class cls)

上一行代码返回围绕此对象半径为 radius 的所有对象。一个对象如果在范围内,意味着其中心与该对象中心的距离小于或等于 radius

getObjectsInRange() 方法的参数描述如下:

  • radius:这是圆的半径(以单元格为单位)

  • cls:这是要查找的对象的类(传递 null 将查找所有对象)

给我们的英雄赋予视力

现在我们有一个名为 EyeActor 类,我们只需要对 Avatar 类进行一些修改,以便为我们的英雄添加眼睛。我们需要创建两个眼睛,将它们放在我们的英雄身上,然后我们需要确保每次我们的英雄移动时眼睛都保持在原位。我们首先向 Avatar 类添加实例变量:

private Eye leftEye;
private Eye rightEye;

然后我们通过添加此方法在头骨图像上创建并放置这些眼睛:

protected void addedToWorld(World w) {
  leftEye = new Eye();
  rightEye = new Eye();
  w.addObject(leftEye, getX()-10, getY()-8);
  w.addObject(rightEye, getX()+10, getY()-8);
}

初始时,你可能认为我们可以在 Avatar 类的构造方法中创建眼睛并添加它们。通常,这会是运行一次的代码的理想位置。问题是,在我们可以将眼睛添加到世界中之前,Avatar 类的实例需要存在于一个世界中。如果我们查看 AvoiderWorld 中的代码,添加我们的英雄,我们会看到这个:

Avatar avatar = new Avatar();
addObject(avatar, 287, 232);

我们英雄的创建是一个两步的过程。首先,创建Avatar类的一个实例(第一行),然后我们将这个实例添加到世界中(第二行)。注意,构造函数在对象放置到世界中之前运行,所以我们不能通过getWorld()方法访问我们所在的世界实例。Greenfoot 的开发者意识到一些角色将需要访问它们所在的世界以完成初始化,因此他们在Actor类中添加了addedToWorld()方法。当初始化需要访问世界时,Actor类会重写此方法,并且每当一个角色被添加到世界中时,Greenfoot 都会调用它。我们在Avatar类中使用此方法来将眼睛放置在我们的英雄身上。

我们现在已经创建了眼睛并将它们添加到了我们的英雄身上。现在,我们只需要确保眼睛在英雄移动时始终伴随着它。为此,我们在Avatar类的followMouse()函数中添加以下几行代码:

leftEye.setLocation(getX()-10, getY()-8);
rightEye.setLocation(getX()+10, getY()-8);

以下代码添加在以下代码行之后:

setLocation(mi.getX(), mi.getY());

为什么setLocation()调用中的 10s 和 8s 会对应leftEyerightEye?这些是正确放置眼睛在英雄眼窝中的值。我是通过试错法确定这些值的。图 9展示了详细信息。

赋予我们的英雄视力

图 9:展示了眼睛位置是如何确定的

现在是时候享受乐趣了。编译并运行你的游戏,享受你的劳动成果。你的游戏应该看起来像图 10中所示的截图。

赋予我们的英雄视力

图 10:我们的游戏有动画敌人、移动的背景星系(带有闪烁)以及当被击中时视觉上会变化的英雄

缓动

在本章的最后一个大节中,我们将探讨使用缓动方程以有趣的方式移动我们的角色。缓动函数使用缓动方程来计算作为时间函数的位置。几乎你见过的每一个网页、移动设备或电影中的动画,在某个时间点都使用了缓动。我们将在游戏中添加三个新的角色,它们根据三种不同的缓动函数移动:线性、指数和正弦。

加速和减速

加速是添加新挑战和平衡玩家技能的绝佳方式。加速为玩家提供速度、力量、健康或其他与游戏相关的技能的短暂提升。它们通常随机出现,可能不在最方便的位置,因此需要玩家快速做出实时决策,权衡移动到加速器与它的有益效果之间的风险。

同样,我们可以创建随机出现的游戏对象,这些对象会负面影响玩家的表现。我称这些为减弱效果。它们也要求玩家做出快速、实时的决策,但现在他们需要在避开它们和保持当前轨迹并承受负面影响之间做出选择。

我们将在游戏中添加两个新的角色作为减弱效果,以及一个新角色作为增强效果。所有这三个角色都将使用缓动进行移动。我们首先介绍一个新的Actor类,它将包含所有关于缓动和作为增强或减弱效果的公共代码。我们的增强和减弱效果将从这个类继承。使用继承和多态来编写简洁、灵活和可维护的代码是良好的面向对象编程实践。

基类

为我们的增强效果创建一个经过深思熟虑的基类将提供轻松创建新增强效果和增强现有效果的途径。在我们讨论新类的代码之前,我们需要将一个新的 Greenfoot 提供的类导入到我们的项目中,就像我们在第一章中导入Counter类一样,让我们直接进入…。我们将导入的类是SmoothMover。我们需要这个类,因为它更准确地跟踪Actor的位置。以下是其文档的摘录:

public abstract class SmoothMover extends greenfoot.Actor

A variation of an actor that maintains a precise location (using doubles for the co-ordinates instead of ints). This allows small precise movements (e.g. movements of 1 pixel or less) that do not lose precision.

要导入这个类,请点击 Greenfoot 主菜单中的编辑,然后在出现的下拉菜单中点击导入类…。在随后出现的导入类窗口中,在左侧选择SmoothMover,然后点击导入按钮。

现在我们已经在项目中有了SmoothMover,我们可以创建PowerItems类。右键点击SmoothMover并选择新建子类…。您不需要为此类关联图像,因此在场景图像部分选择无图像

让我们来看看PowerItems(我们为增强和减弱效果而创建的新基类)的实现:

import greenfoot.*;

public abstract class PowerItems extends SmoothMover
{
  protected double targetX, targetY, expireTime;
  protected double origX, origY;
  protected double duration;
  protected int counter;

  public PowerItems( int tX, int tY, int eT ) {
    targetX = tX;
    targetY = tY;
    expireTime = eT;
    counter = 0;
    duration = expireTime;
  }

  protected void addedToWorld(World w) {
    origX = getX();
    origY = getY();
  }

  public void act() {
    easing();
    checkHitAvatar();
    checkExpire();
  }

  protected abstract double curveX(double f);

  protected abstract double curveY(double f);

  protected abstract void checkHitAvatar();

  protected void easing() {
    double fX = ++counter/duration;
    double fY = counter/duration;
    fX = curveX(fX);
    fY = curveY(fY);
    setLocation((targetX * fX) + (origX * (1-fX)),
    (targetY * fY) + (origY * (1-fY)));
  }

  private void checkExpire() {
    if( expireTime-- < 0 ) {
      World w = getWorld();
      if( w != null ) w.removeObject(this);
    }
  }
}

我们首先需要讨论这个类的所有实例变量。共有七个。其中两个用于跟踪起始坐标(origXorigY),另外两个用于跟踪结束坐标(targetXtargetY)。实例变量expireTime指定这个演员在移除自己之前应该执行多少次act()方法的调用。换句话说,它指定了演员的生命周期。duration实例变量简单地保存expireTime的初始值。expireTime变量会不断递减,直到达到 0,但我们需要知道其原始值用于缓动方程。counter变量记录这个演员移动了多少次。图 11展示了这些变量的图形表示。

基类

图 11:此图以图形方式展示了 PowerItems 中实例变量的含义

实例变量在构造函数中初始化,除了origXorigY,它们在addedToWorld()方法中初始化(该方法在本章前面已经讨论过),这样我们就可以将它们设置为 actor 当前的xy位置。

由于我们明智地使用了功能分解,act()方法很容易理解。首先,它通过调用easing()来移动 actor。接下来,调用checkHitAvatar()来查看它是否与我们的英雄发生了碰撞。这个方法是abstract的,这意味着它的实现留给这个类的子类。这样做是因为每个子类都希望在它们发生碰撞时对我们的英雄应用其独特的效果。最后,它检查act()方法是否被调用expireTime次。如果是这样,PowerItem已经达到了其期望的生命周期,是时候移除它了。我们将在下一节讨论easing()checkHitAvatar()checkExpire()的具体实现。

easing()方法实际上是这个类的关键方法。它包含了一个缓动方程的通用形式,足够灵活,允许我们定义许多不同类型的有趣运动。该方法将 actor 移动到起点和终点之间的一定比例的位置。它首先计算在当前时间点,我们需要在x方向上从原始值到目标值之间移动的距离的百分比,以及y方向上的类似计算,并将这些值分别保存在局部变量fXfY中。接下来,我们使用curveX()curveY()函数来操纵这些百分比,然后我们使用这些百分比在调用setLocation()时。与checkHitAvatar()一样,curveX()curveY()也是abstract的,因为它们的细节取决于从PowerItems派生的类。我们将在下一节讨论abstract方法checkHitAvatar()curveX()curveY(),并提供一个详细的示例。

在此之前,让我们快速看一下PowerItemsact()方法中的最后一个方法。最后一个方法checkExpire(),当expireTime达到 0 时,简单地移除 actor。

注意

抽象类

抽象类是共享几个相关类之间代码和实例变量的有效方式。在抽象类中,你可以实现尽可能多的代码,而不需要包含在子类(子类)中的特定知识。对我们来说,PowerItems类是一个抽象类,它包含了我们所有增强和减弱的通用代码。有关抽象类的更多信息,请访问docs.oracle.com/javase/tutorial/java/IandI/abstract.html

线性缓动

我们将要添加到游戏中的第一个减权是如果被触摸会暂时使我们的英雄昏迷。遵循我们游戏的主题,其中好事(笑脸)是坏事,我们将我们的新减权设计成一个蛋糕。要创建我们的新Actor,在 Greenfoot 主场景窗口的Actor 类部分右键点击PowerItems,并从出现的菜单中选择新建子类…。将类命名为Cupcake,并选择位于食物类别的松饼(对我来说它看起来像蛋糕!)图片。

在编辑器窗口中打开Cupcake类,使其看起来像这样:

import greenfoot.*;

public class Cupcake extends PowerItems
{  
  public Cupcake( int tX, int tY, int eT) {
    super(tX, tY, eT);
  }

  protected double curveX(double f) {
    return f;
  }

  protected double curveY(double f) {
    return f;
  }

  protected void checkHitAvatar() {
    Avatar a = (Avatar) getOneIntersectingObject(Avatar.class);
    if( a != null ) {
      a.stun();
      getWorld().removeObject(this);
    }
  }
}

因为我们从PowerItems的代码中继承,Cupcake相当简短和简洁。这个类的构造函数仅仅将其参数传递给PowerItems中的构造函数。由于PowerItems是一个抽象类,我们需要在这里实现PowerItems中的抽象方法(curveX()curveY()checkHitAvatar())。

Cupcake类将成为我们线性缓动的例子。它将从起始位置以恒定的线性步骤移动到结束位置。因为它线性,我们的curveX()curveY()方法非常简单。它们根本不改变输入参数。

线性缓动

图 12:这是一个展示 Cupcake 类实例如何在屏幕上线性移动的例子

让我们看看图 12中展示的例子。在这个例子中,Cupcake被调用到目标位置(150, 100)并且设置了过期时间4,并被添加到位置(10,10)的世界中。位置(a)显示了对象的初始值。位置(b)、(c)、(d)和(e)分别显示了在act()方法调用一次、两次、三次和四次后与对象关联的值。正如我们所见,这个演员沿着直线移动。为了更好地理解线性缓动,让我们讨论一下为什么在位置(b)的值是这样的。在初始化(在位置(a)显示)之后,act()方法中的函数(从PowerItems继承而来)被调用。easing()方法将counter设置为 1,然后将fXfY设置为 0.25,如以下代码所示:

double fX = ++counter/duration; // counter is incremented to 1 
double fy= counter/duration;  // counter remains 1

Cupcake中的curveX()curveY()方法不改变fXfY。对于给定的值,setLocation()的第一个参数的第一个参数值为 45 ((150 * 0.25) + (10 * 0.75)),第二个参数值为 32.5 ((1000.25) + (10 * 0.75))*。

easing()之后,act()方法中接下来调用的方法是checkHitAvatar()。这个方法简单地调用Avatar(我们的英雄)实例上的stun()方法,如果与之碰撞。stun()方法将在所有加权和减权讨论之后展示。此时,我们将展示对Avatar类所需的所有更改。

指数缓动

现在我们已经讨论了大多数关于能力提升和能力降低的理论,我们可以快速讨论剩下的。接下来我们要添加的是一种能力提升。它将从英雄承受的部分伤害中恢复。考虑到我们游戏的主题,这个有益的演员看起来必须很糟糕。我们将它做成一块石头。

要创建我们的新Actor类,在 Greenfoot 主场景窗口的演员类部分右键点击PowerItems,然后从出现的菜单中选择新建子类…。将类命名为Rock,并选择位于自然类别的rock.png图片。

在编辑器窗口中打开Rock类,并将其更改为如下所示:

import greenfoot.*;

public class Rock extends PowerItems
{

  public Rock( int tX, int tY, int eT ) {
    super(tX, tY, eT);
  }

  protected double curveX(double f) {
    return f; 
}

  protected double curveY(double f) {
    return f * f * f; 
}

  protected void checkHitAvatar() {
    Avatar a = (Avatar) getOneIntersectingObject(Avatar.class);
    if( a != null ) {
      a.addHealth();
      getWorld().removeObject(this);
    }
  }
}

Cupcake类和Rock类之间的两个主要区别是curveY()的实现以及checkHitAvatar()调用addHealth()而不是stun()。我们将在稍后描述addHealth(),如前所述。curveY()的变化给这个演员一个曲线方向,通过立方它所给的值。这种效果在图 13的示例中得到了展示。比较每个位置的y位置的变化。y值呈指数增长。首先,它只移动 1.4 像素(从位置(a)到位置(b)),最后,大约跳过 52 像素(从位置(d)到位置(e))。

指数曲线

图 13:这是一个示例,展示了 Rock 类的实例如何在屏幕的 y 方向上以指数方式移动

正弦曲线

我们即将添加的最后一种能力是Clover。它将暂时减慢我们的英雄速度,并使用正弦曲线。要创建这个类,在 Greenfoot 主场景窗口的演员类部分右键点击PowerItems,然后从出现的菜单中选择新建子类…。将类命名为Clover,并选择位于自然类别的shamrock图片。在编辑器窗口中打开它,并将其更改为如下所示:

import greenfoot.*;
import java.lang.Math;

public class Clover extends PowerItems
{
  public Clover(int tX, int tY, int eT) {
    super(tX, tY, eT);
  }

  protected double curveX(double f) {
    return f;
  }

  protected double curveY(double f) {
    return Math.sin(4*f);
  }

  protected void checkHitAvatar() {
    Avatar a = (Avatar)
    getOneIntersectingObject(Avatar.class);
    if( a != null ) {
      a.lagControls();
      getWorld().removeObject(this);
    }
  }
}

Rock类一样,Clover类在其curveY()方法中执行一些独特的事情。它在类顶部导入 Java 的数学库,并在curveY()的实现中使用Math.sin()。这使得y运动像正弦波一样振荡。

Clover中,checkHitAvatar()调用与Avatar类实例碰撞的lagControls(),而不是stun()addHealth()。在下一节中,我们将实现Avatar类中的stun()addHealth()lagControls()

对 Avatar 类的更改

为了适应我们新能力物品的效果,Avatar类需要实现一些方法并更改一些现有方法。这些方法是stun()addHealth()lagControls()

小贴士

在继续本章之前,这里有一个额外的挑战。尝试自己实现这些方法。仔细思考每一个,并在纸上草拟它们。尝试这个的最坏情况是你会学到很多。

stun()lagControls()的实现涉及添加延迟变量并使用它们来影响移动。在Avatar类中,所有移动都在followMouse()方法中处理。为了使我们的英雄昏迷,我们只需要暂时禁用followMouse()方法一小段时间。以下是修改此方法的步骤:

private void followMouse() {
  MouseInfo mi = Greenfoot.getMouseInfo();
  if( stunDelay < 0 ) {
    if( mi != null ) {
      setLocation(mi.getX(), mi.getY());
      leftEye.setLocation(getX()-10, getY()-8);
      rightEye.setLocation(getX()+10, getY()-8);
    }
  } else {
    stunDelay--;
  }
}

我们还需要在类的顶部定义stunDelay实例变量:

private int stunDelay = -1;

这遵循了我们在本章开头添加的实例变量hitDelay的使用模式。它是我们的延迟变量示例。现在,我们实现stun()

public void stun() {
  stunDelay = 50;
}

每次调用stun()时,followMouse()方法将无法工作 50 个周期(act()方法的调用次数)。

实现lagControls()的方式类似,除了我们需要暂时改变移动,而不是阻止它。再次,我们需要更改followMouse()方法:

private void followMouse() {
  MouseInfo mi = Greenfoot.getMouseInfo();
  if( stunDelay < 0 ) {
    if( mi != null ) {
      if( lagDelay > 0 ) {
        int stepX = (mi.getX() - getX())/40;
        int stepY = (mi.getY() - getY())/40;
        setLocation(stepX + getX(), stepY + getY());
        --lagDelay;
      } else {
        setLocation(mi.getX(), mi.getY());
      }
      leftEye.setLocation(getX()-10, getY()-8);
      rightEye.setLocation(getX()+10, getY()-8);
    }
  } else {
    stunDelay--;
  }
}

让我们先添加实例变量lagDelay,然后讨论它在followMouse()中的使用。在类的顶部stunDelay下面添加此行:

private int lagDelay = -1;

lagDelay的值大于 0 时,它将实现延迟控制。在上面的方法内部if-else语句中,通过只将我们的英雄移动到鼠标位置的四分之一处来实现延迟。这使得我们的英雄缓慢地向鼠标位置爬行。延迟变量lagDelay递减,直到小于 0。它是如何变成大于 0 的?它是在Clover类调用的lagControls()方法中设置的。以下是该方法的代码:

public void lagControls() {
  lagDelay = 150;
}

现在我们需要实现addHealth()方法。以下是代码:

public void addHealth() {
  if( health < 3 ) {
    health++;
    if( --nextImage == 0 ) {
      setImage("skull.png");
    } else {
      setImage("skull" + nextImage + ".png");
    }
  }
}

此方法简单地撤销我们在击中敌人时发生的伤害。如果我们已经处于满血状态,则此方法不执行任何操作;否则,它增加health实例变量,减少nextImage,以便与我们要显示的图像保持同步,并将Avatar的图像设置为之前的、损坏较少的图像。非常酷!

我们对Avatar类进行了重大修改。以下是它的完整代码:

import greenfoot.*;
public class Avatar extends Actor {
  private int health = 3;
  private int hitDelay = 0;
  private int stunDelay = -1;
  private int lagDelay = -1;
  private int nextImage = 0;
  private Eye leftEye;
  private Eye rightEye;

  protected void addedToWorld(World w) {
    leftEye = new Eye();
    rightEye = new Eye();
    w.addObject(leftEye, getX()-10, getY()-8);
    w.addObject(rightEye, getX()+10, getY()-8);
  }

  public void act() {
    followMouse();
    checkForCollisions();
  }

  public void addHealth() {
    if( health < 3 ) {
      health++;
      if( --nextImage == 0 ) {
        setImage("skull.png");
      } else {
        setImage("skull" + nextImage + ".png");
      }
    }
  }

  public void lagControls() {
    lagDelay = 150;
  }

  public void stun() {
    stunDelay = 50;
  }

  private void checkForCollisions() {
    Actor enemy = getOneIntersectingObject(Enemy.class);
    if( hitDelay == 0 && enemy != null ) {
      if( health == 0 ) {
        AvoiderWorld world = (AvoiderWorld) getWorld();
        world.endGame();
      }
      else {
        health--;
        setImage("skull" + ++nextImage + ".png");
        hitDelay = 50;
      }
    }
    if( hitDelay > 0 ) hitDelay--;
  }

  private void followMouse() {
    MouseInfo mi = Greenfoot.getMouseInfo();
    if( stunDelay < 0 ) {
      if( mi != null ) {
        if( lagDelay > 0 ) {
          int stepX = (mi.getX() - getX())/40;
          int stepY = (mi.getY() - getY())/40;
          setLocation(stepX + getX(), stepY + getY());
          --lagDelay;
        } else {
          setLocation(mi.getX(), mi.getY());
        }
        leftEye.setLocation(getX()-10, getY()-8);
        rightEye.setLocation(getX()+10, getY()-8);
      }
    } else {
      stunDelay--;
    }
  }
}

我们离尝试所有这些功能已经很近了。我们只需要在AvoiderWorld类中随机创建并添加能量提升和降低项。

AvoiderWorld类的更改

我们需要在AvoiderWorld类的顶部创建三个新的实例变量,以指定我们用于生成我们的新能量物品的概率。在nextLevel的声明和初始化下面添加这些代码行:

private int cupcakeFrequency = 10;
private int cloverFrequency = 10;
private int rockFrequency = 1;

初始时,这些物品的创建不会非常频繁,但我们将通过在increaseLevel()函数中增加它们来改变这一点。以下是代码:

private void increaseLevel() {
  int score = scoreBoard.getValue();

  if( score > nextLevel ) {
    enemySpawnRate += 3;
    enemySpeed++;
    cupcakeFrequency += 3;
    cloverFrequency += 3;
    rockFrequency += 2;
    nextLevel += 50;
  }
}

act()方法中,我们调用一个生成敌人的函数和另一个生成星星的函数。遵循这个模式,在act()方法中添加此行:

generatePowerItems();

因为所有的能量物品类都继承自PowerItems,我们可以使用多态来编写一些相当简洁的代码。以下是generatePowerItems()的实现:

private void generatePowerItems() {
  generatePowerItem(0, cupcakeFrequency); // new Cupcake
  generatePowerItem(1, cloverFrequency); // new Clover
  generatePowerItem(2, rockFrequency); // new Health
}

很好,我们可以使用一个方法来创建我们新的力量物品——generatePowerItem()。此方法接受一个整数,描述我们想要创建的力量物品类型,以及生成这些特定物品的频率。以下是实现:

private void generatePowerItem(int type, int freq) {
  if( Greenfoot.getRandomNumber(1000) < freq ) {
    int targetX = Greenfoot.getRandomNumber(
    getWidth() -80) + 40;
    int targetY = Greenfoot.getRandomNumber(
    getHeight()/2) + 20;
    Actor a = createPowerItem(type, targetX, targetY, 100);
    if( Greenfoot.getRandomNumber(100) < 50) {
      addObject(a, getWidth() + 20,
      Greenfoot.getRandomNumber(getHeight()/2) + 30);
    } else {
      addObject(a, -20,
      Greenfoot.getRandomNumber(getHeight()/2) + 30);
    }
  }
}

此方法看起来很像我们其他生成演员的方法。它将在给定的随机速率下生成一个物品,并将这些物品放置在屏幕的左侧或右侧,向屏幕内部随机生成的位置移动。局部变量targetX将是屏幕上的任何有效x坐标,除了屏幕左右两侧的40像素宽的边界。我们只想确保它移动足够长,以便可以看到,并且对游戏有影响。变量targetY有稍微严格的约束。我们只想在屏幕上半部分生成y值,加上初始的20像素,以防止演员移动得太靠近屏幕顶部。内部的if-else语句只是简单地选择将对象放置在屏幕的左侧或右侧作为其初始位置。

与我们生成其他演员的方式相比,这里真正的区别在于对createPowerItem()的调用。由于我们使用此方法来生成三种任意一种力量物品,我们不能硬编码创建特定物品的过程,例如,new Cupcake();。我们使用createPowerItem()来创建与generatePowerItems()的类型参数匹配的正确对象。以下是createPowerItem()的实现:

private Actor createPowerItem(int type, int targetX, int targetY, int expireTime) {
  switch(type) {
    case 0: return new Cupcake(targetX, targetY,
    expireTime);
    case 1: return new Clover(targetX, targetY,
    expireTime);
    case 2: return new Rock(targetX, targetY,
    expireTime);
  }
  return null;
}

此方法根据类型创建一个新的CupcakeCloverRock力量物品。

我们真的为这个游戏添加了很多内容,现在是时候编译并测试它了。通常情况下,你不会在没有测试代码的小部分的情况下添加这么多代码。例如,我们本可以完全实现Rock力量提升并测试它,然后再添加其他力量物品。出于教学目的,我们继续这样做是有意义的。我希望你在编译代码时不会遇到太多的错误。通过有系统地检查你的代码与本章中的代码,并密切注意编译错误信息,你应该能够快速消除任何错误。

小贴士

如果你需要刷新一下 Java switch 语句的工作方式,请参考以下链接:

docs.oracle.com/javase/tutorial/java/nutsandbolts/switch.html

编译、调试和玩。这个游戏变得越来越好。查看我的图 14截图。

AvoiderWorld 类更改

图 14:这是包含力量提升、力量降低和各种闪亮元素的完整避免者游戏

避免者游戏

我们的避免者游戏变得越来越完整,玩起来更有趣。在第五章《交互式应用设计理论》中,我们将探讨游戏设计理论,了解如何构建有趣且引人入胜的游戏。那时,我们将重新审视我们的游戏并提高其可玩性。

你的作业

当一个Avatar对象被击中时,它会在短时间内对再次被击中免疫。不幸的是,我们没有为玩家提供任何视觉反馈来指示这一事件的发生或何时结束。你的任务是让英雄在不能被击中时眨眼。查看Star类以获取如何使对象眨眼的提示。

摘要

在本章中,我们涵盖了大量的内容。你学习了几个重要的动画角色技术,包括图像交换、延迟变量、视差和缓动。我们的敌人、我们的英雄和背景都更加生动。你应该在创建游戏、模拟、动画镜头或教育应用时使用本章的所有技术。

第三章。碰撞检测

*"像明天就要死去一样生活。像永远都要活着一样学习。"
--圣雄甘地

通常,在 Greenfoot 中,你需要确定两个或多个对象是否接触。这被称为碰撞检测,对于大多数模拟和游戏都是必要的。检测算法范围从简单的边界框方法到非常复杂的像素颜色分析。Greenfoot 提供了一系列简单的方法来实现碰撞检测;在第一章,“让我们直接进入…”,和第二章,“动画”中,你已经接触到了其中的一些。在本章中,你将学习如何使用 Greenfoot 的其他内置碰撞检测机制,然后学习更精确的方法来使用它们进行碰撞检测。虽然像素完美的碰撞检测超出了本书的范围,但基于边界的和隐藏精灵的碰撞检测方法对于大多数 Greenfoot 应用来说已经足够了。本章将涵盖以下主题:

  • Greenfoot 内置方法

  • 基于边界的检测方法

  • 隐藏精灵方法

我们将暂时放下 Avoider Game 的开发,使用一个简单的僵尸入侵模拟来展示我们的碰撞检测方法。僵尸似乎很适合这一章。从他的引言中判断,我认为甘地希望你像僵尸一样学习。

ZombieInvasion 交互式模拟

在第一章,“让我们直接进入…”和第二章,“动画”中,我们一步一步地构建 Avoider Game,并在每个章节结束时得到可玩的游戏版本。在僵尸模拟中,我们将看到一群僵尸突破墙壁,向另一边的家园前进。用户可以通过在模拟中放置爆炸来与模拟互动,这将摧毁两种类型的僵尸和墙壁。对于我们的僵尸模拟,我将在一开始就提供大部分代码,我们将集中精力实现碰撞检测。所有提供的代码都使用了我们在前两章中介绍的概念和技术,应该看起来非常熟悉。我们在这里将只提供一个代码的概述讨论。图 1提供了我们场景的图片。

ZombieInvasion 交互式模拟

图 1:这是 ZombieInvasion 的屏幕截图

让我们创建一个新的场景,称为ZombieInvasion,然后逐步添加并讨论World子类和Actor子类。或者,你也可以在www.packtpub.com/support下载ZombieInvasion的初始版本。

在 ZombieInvasionWorld 中动态创建演员

这个类有两个主要职责:将世界中的所有演员放置好,并在鼠标点击时创建爆炸。大部分情况下,用户只能观察场景,并且只能通过创建爆炸与之互动。ZombieInvasionWorld 类相当简单,因为我们正在创建一个交互式模拟而不是游戏。以下是完成此任务的代码:

import greenfoot.*;

public class ZombieInvasionWorld extends World {
  private static final int DELAY = 200;
  int bombDelayCounter = 0; // Controls the rate of bombs

  public ZombieInvasionWorld() {  
   super(600, 400, 1); 
   prepare();
  }

  public void act() {
   if( bombDelayCounter > 0 ) bombDelayCounter--;
   if( Greenfoot.mouseClicked(null) && (bombDelayCounter == 0) ) {
     MouseInfo mi = Greenfoot.getMouseInfo();
     Boom pow = new Boom();
     addObject(pow, mi.getX(), mi.getY());
     bombDelayCounter = DELAY;
   }
  }

  private void prepare() {
   int i,j;
   for( i=0; i<5; i++) {
     Wall w = new Wall();
     addObject(w, 270, w.getImage().getHeight() * i);
   }
   for( i=0; i<2; i++) {
     for( j=0; j<8; j++) {
      House h = new House();
      addObject(h, 400 + i*60, (12 +h.getImage().getHeight()) * j);
     }
   }
   for( i=0; i<2; i++) {
     for( j=0; j<8; j++) {
      Zombie1 z = new Zombie1();
      addObject(z, 80 + i*60, 15 + (2 +z.getImage().getHeight()) * j);
     }
   }
   for( i=0; i<2; i++) {
     for( j=0; j<7; j++) {
      Zombie2 z = new Zombie2();
      addObject(z, 50 + i*60, 30 + (3 +z.getImage().getHeight()) * j);
     }
   }
  }
}

当你在场景屏幕上右键单击并从弹出菜单中选择 保存世界 时,Greenfoot 将自动为你创建 prepare() 方法,并供应适当的代码以添加屏幕上的每个 Actor。这创建了你的场景的初始状态(用户首次运行你的场景时看到的那个)。在 ZombieInvasionWorld 中,我们手动实现 prepare() 方法,并且可以比 Greenfoot 以更紧凑的方式实现。我们使用循环来添加我们的演员。通过这种方法,我们添加了 WallHouseZombie1Zombie2。我们将在本章后面实现这些类。

act() 方法负责监听鼠标点击事件。如果鼠标被点击,我们将在鼠标的当前位置添加一个 Boom 对象。Boom 是我们创建的用于显示爆炸的演员,我们希望它正好放置在鼠标点击的位置。我们使用延迟变量 boomDelayCounter 来防止用户快速创建过多的爆炸。记住,我们在上一章(第二章,动画)中详细解释了延迟变量。如果你想让用户能够快速创建爆炸,那么只需简单地移除延迟变量。

创建障碍物

我们将为我们的僵尸群创建两个障碍:房屋和墙壁。在模拟中,House 对象没有任何功能。它只是为僵尸演员提供一个障碍:

import greenfoot.*;

public class House extends Actor {
}

House 类的代码非常简单。它的唯一目的是将房屋图像(buildings/house-8.png)添加到 Actor 中。它没有其他功能。

墙壁比房屋更复杂。随着僵尸敲打墙壁,墙壁会慢慢破碎。Wall 类的大多数代码都实现了这种动画,如下面的代码所示:

import greenfoot.*; 
import java.util.List;

public class Wall extends Actor {
  int wallStrength = 2000;
  int wallStage = 0;

  public void act() {
   crumble();
  } 

  private void crumble() {
   // We will implement this in the next section…
  }

}

Wall 类的破碎动画实现与我们在上一章(第二章,动画)中看到的 Avatar 类受到伤害的实现非常相似。有趣的代码都包含在 crumble() 方法中,该方法从 act() 方法中反复调用。图 1 展示了墙壁在不同程度的衰变状态。我们将在 检测与多个对象的碰撞 部分详细实现并解释 crumble() 方法。

创建我们的主要演员框架

Zombie类包含了描述我们模拟中僵尸行为的所有代码。僵尸不断地笨拙地向前移动,试图到达房子里的人类。他们会敲打并最终摧毁任何挡道的墙壁,如下面的代码所示:

import greenfoot.*; 
import java.util.*;

public class Zombie extends Actor {
  int counter, stationaryX, amplitude;

  protected void addedToWorld(World w) {
   stationaryX = getX();
   amplitude = Greenfoot.getRandomNumber(6) + 2;
  }

  public void act() {
   shake();
   if( canMarch() ) {
     stationaryX = stationaryX + 2;
   }
  } 

  public void shake() {
   counter++;
   setLocation((int)(stationaryX + amplitude*Math.sin(counter/2)), getY());
  }

  private boolean canMarch() {
   // We will implement this in the next section… 
   return false; // Temporary return value 
  }
}

这个类中的两个重要方法是shake()canMarch()shake()方法实现了僵尸的来回笨拙移动。它调用setLocation()并保持y坐标不变。它将x坐标改为正弦运动(来回)。它来回移动的距离由amplitude变量定义。这种运动也被用于第二章中描述的一种电源关闭,动画,并在图 2中显示。

创建我们的主要演员框架

图 2:这是使用正弦波在僵尸对象中产生来回运动的插图。我们从一个标准的正弦波(a)开始,将其旋转 90 度(b),并减少在 y 方向上的移动量,直到达到期望的效果(在 y 方向上不移动)。呼叫(c)和(d)显示了减少 y 方向移动的效果。

我们将在检测与多个对象的碰撞部分中完全实现并解释canMarch()canMarch()方法检查周围的演员(房子、墙壁或其他僵尸),以查看是否有任何阻碍僵尸向前移动。作为一个临时措施,我们在canMarch()的末尾插入以下行:

return false;

这允许我们编译和测试代码。通过始终返回falseZombie对象将永远不会向前移动。这是一个简单的占位符,我们将在本章后面实现真正的响应。

我们有两个Zombie类的子类:Zombie1Zombie2

public class Zombie1 extends Zombie {
}
public class Zombie2 extends Zombie {
}

这使得我们能够拥有两种不同外观的僵尸,但只需编写一次僵尸行为的代码。我选择了一个蓝色(people/ppl1.png)僵尸和一个黄色橙色(people/ppl3.png)僵尸。如果你有任何艺术技巧,你可能想创建自己的PNG图像来使用。否则,你可以继续使用 Greenfoot 提供的图像,就像我这样做。

创建爆炸

这里是我们在ZombieInvasionWorld类描述中之前讨论过的Boom类的实现。Boom类将立即绘制一个爆炸,这将清除爆炸范围内的所有内容,然后短暂停留,之后消失。我们使用以下代码创建爆炸:

import greenfoot.*;
import java.awt.Color;
import java.util.List;

public class Boom extends Actor {
  private static final int BOOMLIFE = 50;
  private static final int BOOMRADIUS = 50;
  int boomCounter = BOOMLIFE;

  public Boom() {
    GreenfootImage me = new GreenfootImage
    (BOOMRADIUS*2,BOOMRADIUS*2);
    me.setColor(Color.RED);
    me.setTransparency(125);
    me.fillOval(0 , 0, BOOMRADIUS * 2, BOOMRADIUS*2);
    setImage(me);
  }

  public void act() {
    if( boomCounter == BOOMLIFE)
    destroyEverything(BOOMRADIUS);
    if( boomCounter-- == 0 ) {
      World w = getWorld();
      w.removeObject(this);
    }
  }

  private void destroyEverything(int x) {
    // We will implement this in the next section…
  }
}

让我们讨论构造函数(Boom())和act()方法。Boom()方法使用GreenfootImage的绘图方法手动创建一个图像。我们就是这样使用这些绘图方法在上一章中展示的AvoiderGame中绘制星星和眼睛,我们在上一章中介绍了它,第一章, 让我们直接进入…,和第二章, 动画。构造函数通过使用setImage()将这个新图像设置为演员的图像来结束。

act()方法使用了延迟变量的有趣用法。不是等待一定的时间(以act()方法的调用次数来衡量)后才允许事件发生,而是使用boomCounter延迟变量来控制这个Boom对象存活的时间。经过短暂的延迟后,对象将从场景中移除。

我们将在后面的部分讨论destroyEverything()方法的实现。

测试一下

你现在应该有一个几乎完整的僵尸入侵模拟。让我们编译我们的场景,确保在添加代码时消除任何引入的错别字或错误。这个场景不会做很多事情。僵尸会来回移动,但不会取得任何进展。你可以在运行中的场景的任何地方点击,看到Boom爆炸;然而,它现在还不会摧毁任何东西。

让我们使用 Greenfoot 的碰撞检测方法使这个场景更有趣。

内置的碰撞检测方法

我们将遍历 Greenfoot 提供的所有碰撞检测方法。首先,我们将回顾一些方法并讨论它们的预期用途。然后,我们将基于更高级的碰撞检测方法(基于边界的和隐藏精灵)讨论剩余的方法。我们已经在 Avoider Game 的实现中使用了几个碰撞检测方法。我们在这里将简要描述这些特定方法。最后,我们不会讨论getNeighbors()intersects(),因为这些方法仅适用于包含使用大于一个单元格大小创建的世界 Greenfoot 场景。

注意

单元格大小和 Greenfoot 世界

到目前为止,我们只创建了设置了World构造函数的cellSize参数为1的世界(AvoiderWorldZombieInvasionWorld)。以下是从 Greenfoot 关于World类的文档中摘录的内容:

public World(int worldWidth, int worldHeight, int cellSize)

Construct a new world. The size of the world (in number of cells) and the size of each cell (in pixels) must be specified.

Parameters:
worldWidth - The width of the world (in cells).
worldHeight - The height of the world (in cells).
cellSize - Size of a cell in pixels.

Greenfoot 网站上提供的简单教程主要使用大单元格大小。这使得游戏移动、轨迹和碰撞检测非常简单。另一方面,我们希望创建更灵活的游戏,允许平滑的运动和更逼真的动画。因此,我们将我们的游戏单元格定义为 1 x 1 像素(一个像素),相应地,我们将不会讨论针对具有大单元格大小的世界的方法,例如getNeighbors()intersects()

在我们讨论的过程中,请记住,我们有时会向我们的 ZombieInvasion 场景添加代码。

检测单个对象的碰撞

getOneIntersectingObject() 方法非常适合简单的碰撞检测,通常用于检查子弹或其他类型的敌人是否击中了游戏的主要主角,以便减去健康值、减去生命值或结束游戏。这是我们使用并在 第一章 中解释的方法,即 Let's Dive Right in…,来构建 Avoider Game 的第一个工作版本。我们在此处不再讨论它,只会在下一节中提及它,作为说明 isTouching()removeTouching() 的使用方法。

isTouching() 和 removeTouching()

以下是一个使用 getOneIntersectingObject() 的常见模式:

private void checkForCollisions() {
  Actor enemy = getOneIntersectingObject(Enemy.class);
  if( enemy != null ) { // If not empty, we hit an Enemy
    AvoiderWorld world = (AvoiderWorld) getWorld();
    world.removeObject(this);
  }
}

我们在 Avoider Game 中多次使用了这个基本模式。isTouching()removeTouching() 方法提供了一种更紧凑的方式来实现前面的模式。以下是一个使用 isTouching()removeTouching() 而不是 getOneIntersectingObject() 的等效函数:

private void checkForCollisions() {
  if( isTouching(Enemy.class) ) { 
    removeTouching(Enemy.class);
  }
}

如果你只是要移除与对象相交的对象,那么你只需要 isTouching()removeTouching() 方法。然而,如果你想要对相交的对象执行某些操作,这需要调用对象的类方法,那么你需要将相交的对象存储在命名变量中,这需要使用 getOneIntersectingObject() 方法。

小贴士

通常,始终使用 getOneIntersectingObject() 而不是 isTouching()removeTouching()。它更灵活,并且提供的代码更容易在未来扩展。

检测多个对象的碰撞

碰撞检测方法 getIntersectingObjects() 返回一个列表,包含所有被调用演员接触到的给定类别的演员。当需要针对接触特定演员的每个对象采取行动,或者需要根据接触该演员的对象数量来改变演员的状态时,需要使用此方法。当使用 getOneIntersectingObject() 时,你只关心至少被一个指定类型的对象接触。例如,在游戏 PacMan 中,每次你接触到幽灵时都会失去一条生命。无论你撞到的是一个、两个还是三个,最终结果都会相同——你会失去一条生命。然而,在我们的僵尸模拟中,Wall 演员根据当前敲打它的僵尸数量受到伤害。这是 getIntersectingObjects() 的完美应用。

在上面提供的 Wall 代码中,我们省略了 crumble() 方法的实现。以下是该代码:

private void crumble() {
  List<Zombie> army = getIntersectingObjects(Zombie.class);
  wallStrength = wallStrength - army.size();
  if( wallStrength < 0 ) {
    wallStage++;
    if( wallStage > 4 ) {
      World w = getWorld();
      w.removeObject(this);
    }
    else {
      changeImage();
      wallStrength = 2000;
    }
  }
}

private void changeImage() {
  setImage("brick"+wallStage+".png");
}

让我们快速回顾一下之前看到的内容。在第二章 动画 的 伤害角色 部分,我们每次角色被敌人触摸时都会改变角色的图像,使其看起来受损。我们在这里使用相同的动画技术来使其看起来像墙壁正在受损。然而,在这段代码中,我们给墙壁赋予了一个由 wallStrength 变量定义的耐久性属性。wallStrength 的值决定了墙壁在明显看起来更加破碎和裂缝之前可以承受多少次僵尸的撞击。

wallStrength 变量实际上是我们在上一章 第二章. 动画 中讨论的延迟变量的一个例子。这个变量不是延迟一定的时间,而是延迟一定数量的僵尸撞击。当 wallStrength 小于 0 时,我们会使用 changeImage() 方法更改图像,除非这是我们第四次破碎,这将导致我们完全移除墙壁。图 3 展示了我为这个动画创建并使用的墙壁图像。

检测与多个对象的碰撞

图 3:这些是用于动画墙壁破碎的四个图像

现在,让我们讨论碰撞检测方法 getIntersectingObjects()。当被调用时,此方法将返回所有与调用对象相交的给定类的对象。您可以通过将类作为此方法的参数提供来指定您感兴趣的类。在我们的代码中,我提供了参数 Zombie.class,因此该方法只会返回所有接触墙壁的僵尸。由于继承,我们将得到所有 Zombie1 对象和所有 Zombie2 对象,它们都与对象相交。您可以使用在 List 接口中定义的方法访问、操作或遍历返回的对象。对于我们来说,我们只想计算我们碰撞了多少个僵尸。我们通过在从 getIntersectingObjects() 返回的 List 对象上调用 size() 方法来获取这个数字。

注意

Java 接口和 List

碰撞检测方法 getIntersectingObjects() 第一次让我们了解了 List 接口。在 Java 中,接口用于定义两个或多个类将共有的方法集。当 Java 类实现接口时,该类承诺它实现了该接口中定义的所有方法。因此,由 getIntersectingObjects() 返回的 Actor 对象集合可以存储在数组、链表、队列、树或其他任何数据结构中。无论用于存储这些对象的数据结构是什么,我们知道我们可以通过 List 接口中定义的方法访问这些对象,例如 get()size()

更多信息,请参阅以下链接:docs.oracle.com/javase/tutorial/java/IandI/createinterface.html

在我们的ZombieInvasion模拟中,我们需要再次使用getIntersectingObjects()。在我们查看Zombie类的代码时,我们留下了canMarch()方法的实现未完成。现在让我们使用getIntersectingObjects()来实现该方法。以下是代码:

private boolean canMarch() {
  List<Actor> things = getIntersectingObjects(Actor.class);
  for( int i = 0; i < things.size(); i++ ) {
    if( things.get(i).getX() > getX() + 20 ) {
      return false;
    }
  }
  return true;
}

此方法检查是否有任何演员阻碍了该对象向前移动。它通过首先获取所有接触该对象的Actor类的对象,然后检查每个对象是否位于该对象的前面来完成此操作。我们不关心Actor是否在顶部、底部或后面接触调用对象,因为这些演员不会阻止该对象向前移动。canMarch()中的这一行代码为我们提供了所有相交演员的列表:

List<Actor> things = getIntersectingObjects(Actor.class);

然后,我们使用for循环遍历演员列表。要访问列表中的项目,您使用get()方法。get()方法有一个形式参数,指定了列表中您想要的对象的索引。对于列表中的每个演员,我们检查其x坐标是否在我们前面。如果是,我们返回false(我们不能移动);否则,我们返回true(我们可以移动)。

我们已经将crumble()方法的实现添加到了Wall类中(别忘了还要添加changeImage()),并将canMarch()方法的实现添加到了Zombie类中。让我们编译我们的场景并观察发生了什么。我们的模拟几乎完成了。唯一缺少的是Boom类中destroyEverything()方法的实现。我们将在下一节中查看该实现。

检测范围内的多个对象

我们需要实现的最后一个方法来完成我们的模拟是destroyEverything()。在这个方法中,我们将使用 Greenfoot 碰撞检测方法getObjectsInRange()。此方法接受两个参数。我们在所有其他碰撞检测方法中都已经看到了第二个参数,它指定了我们正在测试碰撞的演员的类。第一个参数提供了一个围绕演员绘制的圆的半径,该半径定义了搜索碰撞的位置。图 4显示了radius参数与搜索区域之间的关系。与getIntersectingObjects()不同,getObjectsInRange()返回一个列表,其中包含调用对象指定的范围内的演员。

检测范围内的多个对象

图 4:这显示了getObjectsInRange()方法中半径参数的作用

现在我们已经了解了getObjectsInRange()方法,让我们看看destroyEverything()方法的实现:

private void destroyEverything(int x) {
  List<Actor> objs = getObjectsInRange(x, Actor.class);
  World w = getWorld();
  w.removeObjects(objs);
}

这种方法简短而强大。它调用getObjectsInRange(),带有半径x,这是在调用destroyEverything()时传递给它的值,以及Actor.class,在 Greenfoot 术语中意味着一切。所有在半径定义的圆内的对象都将由getObjectsInRange()返回并存储在objs变量中。现在,我们可以遍历objs中包含的所有对象,并逐个删除它们。幸运的是,Greenfoot 提供了一个可以在一次调用中删除一组对象的函数。以下是它在 Greenfoot 文档中的定义:

public void removeObjects(java.util.Collection objects)
Remove a list of objects from the world.

Parameters:
objects - A list of Actors to remove.

是时候测试一下了

模拟完成。编译并运行它,确保一切按预期工作。记住,你可以点击任何地方来炸毁建筑、墙壁和僵尸。重置场景并移动事物。添加墙壁和僵尸,看看会发生什么。做得不错!

基于边界的碰撞检测方法

基于边界的碰撞检测涉及从Actor开始向外逐步搜索,直到检测到碰撞,或者确定没有障碍物为止。该方法找到与之碰撞的项目的边缘(或边界)。这种方法在物体需要相互弹跳,或者一个物体落在另一个物体上并需要在该物体上停留一段时间时特别有用,例如,当用户控制的Actor跳到平台上时。我们将在本章介绍这种碰撞检测方法,并在接下来的章节中使用它。

检测偏移量下的单物体碰撞

Greenfoot 的碰撞检测方法的偏移量版本非常适合基于边界的碰撞检测。它们允许我们在调用Actor的中心的某个距离或偏移量处检查碰撞。为了演示这个方法的使用,我们将修改Zombie类中canMarch()方法的实现。以下是我们的修改版本:

private boolean canMarch() {
  int i=0;
  while(i<=step) {
    int front = getImage().getWidth()/2;
    Actor a = getOneObjectAtOffset(i+front, 0, Actor.class);
    if( a != null ) {
      return false;
    }
    i++;
  }
  return true;
}

通常,当一个演员移动时,它将通过一定数量的像素改变其位置。在Zombie类中,如果僵尸可以移动,它们将移动多远被存储在step变量中。我们需要通过在Zombie类的顶部插入以下代码行来声明和初始化这个实例变量,如下所示:

private int step = 4;

使用step变量来存储演员的移动长度是一种常见的做法。在上面的canMarch()实现中,我们检查僵尸前方直到包括完整一步的每个像素。这由while循环处理。我们将变量i0增加到step,每次在位置i + front处检查碰撞。由于一个物体的原始位置是其中心,我们将front设置为表示该演员的图像宽度的一半。图 5说明了这个搜索过程。

检测偏移量下的单物体碰撞

图 5:使用基于边界的检测,一个对象逐像素搜索碰撞。它从其前端开始,然后从前端+0 开始搜索对象,一直到前端+步长。

如果在我们的while循环中的任何时间检测到碰撞,我们返回false,表示演员不能向前移动;否则,我们返回true。测试这个新的canMarch()版本。

在偏移量处检测多对象碰撞

碰撞检测方法getObjectsAtOffset()getOneObjectAtOffset()非常相似。正如其名所示,它只是返回给定偏移量处所有碰撞的演员。为了演示其用法,我们将像对getOneObjectAtOffset()所做的那样重新实现canMarch()。为了利用获取碰撞演员列表的优势,我们将在canMarch()中添加一些额外的功能。对于每个阻挡僵尸前进运动的演员,我们将稍微推挤他们。

这是canMarch()的实现:

private boolean canMarch() {
  int front = getImage().getWidth()/2;
  int i = 1;
  while(i<=step) {
    List<Actor> a = getObjectsAtOffset(front+i,0,Actor.class);
    if( a.size() > 0 ) {
      for(int j=0;j<a.size()&&a.get(j) instanceof Zombie;j++){
        int toss = Greenfoot.getRandomNumber(100)<50 ? 1 : -1;
        Zombie z = (Zombie) a.get(j);
        z.setLocation(z.getX(),z.getY()+toss);
      }
      return false;
    }
    i++;
  }
  return true;
}

在这个版本中,我们使用while循环和step变量,与之前canMarch()getOneObjectAtOffset()版本所做的方式几乎相同。在while循环内部,我们添加了新的“推挤”功能。当我们检测到列表中至少有一个Actor时,我们使用for循环遍历列表,轻微地推动我们与之碰撞的每个演员。在for循环中,我们首先使用instanceof运算符检查Actor类是否是Zombie类。如果不是,我们跳过它。我们不希望有推挤WallHouse的能力。对于每个我们与之碰撞的僵尸,我们以相等的概率将toss变量设置为1-1。然后我们使用setLocation()移动那个僵尸。这种效果很有趣,给人一种僵尸试图推挤和冲到前面的错觉。编译并运行带有canMarch()更改的场景,看看结果如何。图 6展示了僵尸如何在前面的更改下聚集在一起。

注意

instanceof运算符

Java 的instanceof运算符检查左侧参数是否是从右侧指定的类(或其任何子类)创建的对象。如果是,它将返回true;否则返回false。如果左侧对象实现了右侧指定的接口,它也将返回true

在偏移量处检测多对象碰撞

图 6:这是僵尸推挤和冲向房屋中的人类的一个视图

隐藏精灵碰撞检测方法

getOneObjectAtOffets()getObjectsAtOffset() 方法的缺点之一是它们只检查单个像素的粒度。如果一个感兴趣的对象位于提供给这些方法的偏移量上方或下方一个像素,那么将不会检测到碰撞。实际上,在这个实现中,如果你允许模拟运行直到僵尸到达房屋,你会注意到一些僵尸可以穿过房屋。这是因为像素检查在房屋之间失败。处理这种不足的一种方法是用隐藏精灵碰撞检测。图 7展示了这种方法。

隐藏精灵碰撞检测方法

图 7:这显示了使用隐藏精灵来检查碰撞。

在隐藏精灵方法中,你使用另一个Actor类来测试碰撞。图 7显示了一个Zombie对象使用一个较小的、辅助的Actor类来确定是否与花朵发生了碰撞。虽然隐藏精灵显示为半透明的红色矩形,但在实际应用中,我们会设置透明度(使用setTransparency())为0,使其不可见。隐藏精灵方法非常灵活,因为你可以为你的隐藏精灵创建任何形状或大小,并且它没有像前两种碰撞检测方法那样只关注单个像素的问题。接下来,我们再次修改Zombie类中的canMarch()方法,这次使用隐藏精灵碰撞检测。

我们需要做的第一件事是创建一个新的Actor,它将作为隐藏精灵使用。因为我们打算为僵尸使用这个隐藏精灵,所以让我们称它为ZombieHitBox。现在创建这个Actor的子类,并且不要将它与任何图像关联。我们将在构造函数中绘制图像。以下是ZombieHitBox的实现:

import greenfoot.*;
import java.awt.Color;
import java.util.*;

public class ZombieHitBox extends Actor {
  GreenfootImage body;
  int offsetX;
  int offsetY;
  Actor host;

  public ZombieHitBox(Actor a, int w, int h, int dx, int dy, boolean visible) {
    host = a;
    offsetX = dx;
    offsetY = dy;
    body = new GreenfootImage(w, h);
    if( visible ) {
      body.setColor(Color.red);
      // Transparency values range from 0 (invisible)
      // to 255 (opaque)
      body.setTransparency(100);
      body.fill();
    }
    setImage(body);
  }

  public void act() {
    if( host.getWorld() != null ) {
      setLocation(host.getX()+offsetX, host.getY()+offsetY);
    } else {
      getWorld().removeObject(this);
    }
  }

  public List getHitBoxIntersections() {
    return getIntersectingObjects(Actor.class);
  }
}

ZombieHitBox的构造函数接受六个参数。它之所以需要这么多参数,是因为我们需要提供它附加到的Actor类(a参数),定义要绘制的矩形的尺寸(wh参数),提供矩形相对于提供的Actor的偏移量(dxdy参数),并检查隐藏精灵是否可见(visible参数)。在构造函数中,我们使用GreenfootImage()setColor()setTransparency()fill()setImage()来绘制隐藏精灵。我们之前在第二章 动画中讨论了这些方法。

我们使用act()方法来确保这个隐藏精灵与它附加的Actor类(我们将称之为host精灵)一起移动。为此,我们只需调用setLocation(),提供host精灵当前的xy位置,并根据构造函数中提供的偏移值进行微调。然而,在这样做之前,我们检查host是否已被删除。如果已被删除,我们就删除碰撞框,因为它只与host有关。这处理了爆炸摧毁host但并未完全达到碰撞框的情况。

最后,我们提供一个公共方法,host精灵将使用它来获取所有与隐藏精灵发生碰撞的精灵。我们把这个方法命名为getHitBoxIntersections()

接下来,我们需要增强Zombie类以使用这个新的隐藏精灵。我们需要对这个隐藏精灵有一个引用,因此我们需要在Zombie类的声明下添加一个新的属性。在step变量的声明下插入此行代码:

private ZombieHitBox zbh;

接下来,我们需要增强addedToWorld()方法来创建并将ZombieHitBox连接到Zombie。以下是该方法的实现:

protected void addedToWorld(World w) {
  stationaryX = getX();
  amplitude = Greenfoot.getRandomNumber(6) + 2;
  zbh = new ZombieHitBox(this, 10, 25, 10, 5, true);
  getWorld().addObject(zbh, getX(), getY());
}

我们为我们的隐藏精灵创建一个 10 x 25 的矩形,并最初使其可见,这样我们就可以在我们的场景中测试它。一旦你对隐藏精灵的位置和大小满意,你应该将ZombieHitBoxvisible参数从true更改为false

现在我们已经创建、初始化并放置了ZombieHitBox,我们可以对canMarch()进行修改,以展示隐藏精灵方法的使用:

private boolean canMarch() {
  if( zbh.getWorld() != null ) {
    List<Actor> things = zbh.getHitBoxIntersections();
    if( things.size() > 1 ) {
      int infront = 0;
      for(int i=0; i < things.size(); i++ ) {
        Actor a = things.get(i);
        if( a == this || a instanceof ZombieHitBox)
        continue;
        if( a instanceof Zombie) {
          int toss =
          Greenfoot.getRandomNumber(100)<50 ? 1:-1;
          infront += (a.getX() > getX()) ? 1 : 0;
          if( a.getX() >= getX() )
          a.setLocation(a.getX(),a.getY()+toss);
        } else {
          return false;
        }
      }
      if( infront > 0 ) {
        return false;
      } else {
        return true;
      }
    }
    return true;
  } else {
    getWorld().removeObject(this);
  }
  return false;
}

与之前的canMarch()实现不同,我们首先需要询问隐藏精灵获取与这个僵尸碰撞的演员列表。一旦我们得到这个列表,我们检查它的大小是否大于一个。它需要大于一个的原因是ZombieHitBox将包括它所附着的僵尸。如果我们没有与其他僵尸或演员发生碰撞,我们返回true。如果我们与多个演员发生碰撞,那么我们将遍历它们所有,并根据Actor的类型做出一些决定。如果Actor是这个僵尸或ZombieHitBox的实例,我们跳过它并且不采取任何行动。下一个检查是Actor是否是Zombie类的实例。如果不是,那么它是一些其他对象,比如HouseWall,我们返回false,这样我们就不会向前移动。如果是Zombie类的实例,我们检查它是否在这个僵尸的前面。如果是,我们稍微推它一下(就像我们在之前的canMarch()实现中做的那样)并增加infront变量。遍历演员列表结束后,我们检查infront变量。如果有僵尸在这个僵尸的前面,我们返回false以防止它向前移动。否则,我们返回true。最外层的if语句简单地检查与这个对象关联的击中框(zbh)是否已经被Boom对象之前销毁。如果是,那么我们需要移除这个对象。

编译并运行这个场景版本。你应该观察到僵尸们很好地聚集在一起,互相推搡,但它们无法越过房屋。使用隐藏精灵的碰撞检测方法比其他方法复杂一些,但提供了很好的精度。

挑战

好的,我们在僵尸模拟中实现了多种形式的碰撞检测。你更喜欢哪种碰撞检测方法用于这个模拟?

作为挑战,创建一个Actor球,它偶尔从左侧滚动过来,并将僵尸推开。如果球击中Wall,让它对它造成 1,000 点伤害。你将使用哪种形式的碰撞检测来检测球与僵尸以及球与墙壁之间的碰撞?

概述

碰撞检测是任何游戏、模拟或交互式应用的关键组成部分。Greenfoot 提供了检测碰撞的内置方法。在本章中,我们详细解释了这些方法,并展示了如何使用它们进行更高级的碰撞检测。具体来说,我们讨论了基于边界的和隐藏精灵技术。向前推进,我们将经常使用碰撞检测,并选择适合我们示例的方法。在下一章中,我们将探讨投射物,并将有充足的机会将本章学到的知识付诸实践。

第四章。投射物

*"飞行就是学会将自己扔向地面并错过。"
--道格拉斯·亚当斯

在创意 Greenfoot 应用中,如游戏和动画,演员通常具有最佳描述为发射的运动。例如,足球、子弹、激光、光束、棒球和烟花都是这类物体的例子。实现这种运动的一种常见方法是为一组模拟现实世界物理属性(质量、速度、加速度、摩擦等)的类创建一个集合,并让游戏或模拟演员从这些类中继承。有些人把这称为为你的游戏或模拟创建一个物理引擎。然而,这种方法复杂且往往过度。正如你在第二章中学习的,动画,我们通常可以使用一些简单的启发式方法来近似现实运动。这就是我们将采取的方法。

在本章中,你将了解投射物的基础知识,如何使物体弹跳,以及一些关于粒子效果的知识。我们将把所学知识应用到我们将在本章中构建的小型平台游戏中。在本章中,我们将涵盖以下主题:

  • 重力和跳跃

  • 弹跳

  • 粒子效果

  • 子弹和炮塔

创建逼真的飞行物体并不简单,但我们将以系统、分步骤的方法来介绍这个主题,当我们完成时,你将能够用各种飞行、跳跃和发射物体来丰富你的创意场景。这并不像道格拉斯·亚当斯在他的引语中所说的那么简单,但任何值得学习的东西都不简单。

蛋糕计数器

在完整场景的背景下讨论主题对学习过程有益。这样做迫使我们处理在较小、一次性示例中可能被省略的问题。在本章中,我们将构建一个简单的平台游戏,称为蛋糕计数器(如图 1 所示)。我们将首先查看游戏中世界演员类的大多数代码,而不展示实现本章主题的代码,即基于投射物的不同形式。然后,在随后的章节中,我们将展示并解释缺失的代码。这与前一章中我们采取的方法相同,以便研究碰撞检测。

蛋糕计数器

图 1:这是蛋糕计数器的截图

如何玩

Cupcake Counter 的目标是收集尽可能多的纸杯蛋糕,在被球或喷泉击中之前。左右箭头键控制角色左右移动,上箭头键使角色跳跃。您还可以使用空格键跳跃。触摸纸杯蛋糕后,它将消失并随机出现在另一个平台上。球将从屏幕顶部的炮塔发射出来,喷泉会定期出现。随着纸杯蛋糕数量的增加,游戏难度将增加。游戏需要良好的跳跃和躲避技巧。

实现纸杯蛋糕计数器

创建一个名为 Cupcake Counter 的场景,并将每个类按讨论的顺序添加到其中。如果您愿意,您可以从以下链接下载纸杯蛋糕计数器的初始版本:www.packtpub.com/support

CupcakeWorld 类

这个 World 子类设置了与场景相关的所有演员,包括得分。它还负责生成周期性敌人,生成奖励,并随着时间的推移增加游戏的难度。以下是这个类的代码:

import greenfoot.*;
import java.util.List;

public class CupcakeWorld extends World {
  private Counter score;
  private Turret turret;
  public int BCOUNT = 200;
  private int ballCounter = BCOUNT;
  public int FCOUNT = 400;
  private int fountainCounter = FCOUNT;
  private int level = 0;

  public CupcakeWorld() {
    super(600, 400, 1, false);
    setPaintOrder(Counter.class, Turret.class, Fountain.class,
    Jumper.class, Enemy.class, Reward.class, Platform.class);
    prepare();
  }

  public void act() {
    checkLevel();
  }

  private void checkLevel() {
    if( level > 1 ) generateBalls();
    if( level > 4 ) generateFountains();
    if( level % 3 == 0 ) {
      FCOUNT--;
      BCOUNT--;
      level++;
    }
  }

  private void generateFountains() {
    fountainCounter--;
    if( fountainCounter < 0 ) {
      List<Brick> bricks = getObjects(Brick.class);
      int idx = Greenfoot.getRandomNumber(bricks.size());
      Fountain f = new Fountain();
      int top = f.getImage().getHeight()/2 + bricks.get(idx).getImage().getHeight()/2;
      addObject(f, bricks.get(idx).getX(),
      bricks.get(idx).getY()-top);
      fountainCounter = FCOUNT;
    }
  }

  private void generateBalls() {
    ballCounter--;
    if( ballCounter < 0 ) {
      Ball b = new Ball();
      turret.setRotation(15 * -b.getXVelocity());
      addObject(b, getWidth()/2, 0);
      ballCounter = BCOUNT;
    }
  }

  public void addCupcakeCount(int num) {
    score.setValue(score.getValue() + num);
    generateNewCupcake();
  }

  private void generateNewCupcake() {
    List<Brick> bricks = getObjects(Brick.class);
    int idx = Greenfoot.getRandomNumber(bricks.size());
    Cupcake cake = new Cupcake();
    int top = cake.getImage().getHeight()/2 +
    bricks.get(idx).getImage().getHeight()/2;
    addObject(cake, bricks.get(idx).getX(),
    bricks.get(idx).getY()-top);
  }

  public void addObjectNudge(Actor a, int x, int y) {
    int nudge = Greenfoot.getRandomNumber(8) - 4;
    super.addObject(a, x + nudge, y + nudge);
  }

  private void prepare(){
    // Add Bob
    Bob bob = new Bob();
    addObject(bob, 43, 340);
    // Add floor
    BrickWall brickwall = new BrickWall();
    addObject(brickwall, 184, 400);
    BrickWall brickwall2 = new BrickWall();
    addObject(brickwall2, 567, 400);
    // Add Score
    score = new Counter();
    addObject(score, 62, 27);
    // Add turret
    turret = new Turret();
    addObject(turret, getWidth()/2, 0);
    // Add cupcake
    Cupcake cupcake = new Cupcake();
    addObject(cupcake, 450, 30);
    // Add platforms
    for(int i=0; i<5; i++) {
      for(int j=0; j<6; j++) {
        int stagger = (i % 2 == 0 ) ? 24 : -24;
        Brick brick = new Brick();
        addObjectNudge(brick, stagger + (j+1)*85, (i+1)*62);
      }
    }
  }
}

让我们按顺序讨论这个类中的方法。首先,我们有类构造函数 CupcakeWorld()。在调用超类构造函数之后,它调用 setPaintOrder() 来设置在屏幕上显示时将出现在其他演员前面的演员。您在 第二章 动画 中介绍了 setPaintOrder()。我们在这里使用它的主要原因是为了确保没有任何演员会覆盖用于显示得分的 Counter 类。接下来,构造函数方法调用 prepare() 来添加并将初始演员放置到场景中。我们将在本节后面讨论 prepare() 方法。

act() 方法内部,我们只会调用 checkLevel() 函数。随着玩家在游戏中获得分数,游戏的 level 变量也会增加。checkLevel() 函数将根据其 level 变量稍微改变游戏。当我们的游戏第一次开始时,不会生成敌人,玩家可以轻松地得到纸杯蛋糕(奖励)。这给了玩家一个熟悉在平台上跳跃的机会。随着纸杯蛋糕数量的增加,球和喷泉将被添加。随着级别的持续上升,checkLevel() 减少了创建球 (BCOUNT) 和喷泉 (FCOUNT) 之间的延迟。游戏 level 变量在 addCupcakeCount() 方法中增加,我们将在下面详细讨论。

generateFountains()方法向场景中添加一个Fountain演员。我们创建喷泉的速度由延迟变量(参考,第二章,“动画”)fountainContainer控制。延迟后,我们在随机选择的Brick(我们游戏中的平台)上创建一个喷泉。getObjects()方法返回场景中给定类的所有演员。然后我们使用getRandomNumber()随机选择一个介于 1 和Brick演员数量之间的数字。接下来,我们使用addObject()将新的Fountain对象放置在随机选择的Brick对象上。

使用generateBalls()方法生成球体比生成喷泉要简单一些。所有球体都是在屏幕顶部的turret所在位置创建的,并从那里以随机选择的轨迹发送出去。我们生成新的Ball演员的速度由延迟变量ballCounter定义。一旦我们创建了一个Ball演员,我们就根据其x速度旋转turret。通过这样做,我们创造了一种错觉,即turret正在瞄准然后发射Ball Actor。最后,我们使用addObject()方法将新创建的Ball演员放入场景中。

当玩家(鲍勃)每次与Cupcake碰撞时,都会调用代表玩家的演员的addCupcakeCount()方法。在这个方法中,我们增加scores然后调用generateNewCupcake()来向场景中添加一个新的Cupcake演员。generateNewCupcake()方法与generateFountains()方法非常相似,只是没有延迟变量,并且它随机将Cupcake放置在一块砖上而不是Fountain演员上。在第一章,“让我们直接进入…”,我们展示了如何使用Counter类创建游戏分数,这是一个你可以导入到你的场景中的类。请参阅该章节以获取更多详细信息。

在我们所有的先前场景中,我们使用prepare()方法向场景中添加演员。这个prepare()方法和之前的方法的主要区别在于,我们使用addObjectNudge()方法而不是addObject()来放置我们的平台。addObjectNudge()方法只是给平台的放置增加了一点随机性,使得每次新游戏都略有不同。平台中的随机变化会导致Ball演员具有不同的弹跳模式,并要求玩家更加小心地跳跃和移动。在调用addObjectNudge()时,你会注意到我们使用了数字8562。这些数字只是适当分散平台的数字,它们是通过试错发现的。

我为CupcakeWorld的图片创建了一个蓝色渐变背景,你可以随意使用这个背景,从你可以下载的示例代码中,创建你自己的背景图片,或者使用 Greenfoot 附带提供的背景图片之一。

敌人

在 Cupcake Counter 中,所有可以与游戏结束相关的演员都是Enemy类的子类。使用继承是共享代码并减少一组类似演员冗余的好方法。然而,我们通常会在 Greenfoot 中仅为了多态性创建类层次结构。多态性指的是面向对象语言中一个类能够采取多种形式的能力。我们将使用它,这样我们的玩家演员只需要检查与Enemy类的碰撞,而不需要检查每个具体的Enemy类型,例如BallRedBall。此外,通过这种方式编码,我们使添加额外敌人的代码变得非常容易,如果我们发现我们的敌人有冗余代码,我们可以轻松地将该代码移动到我们的Enemy类中。换句话说,我们正在使我们的代码可扩展和可维护。

这里是Enemy类的代码:

import greenfoot.*;

public abstract class Enemy extends Actor {
}

Ball类扩展了Enemy类。由于Enemy仅用于多态性,Ball类包含实现弹跳和初始轨迹所需的所有代码。以下是这个类的代码:

import greenfoot.*;

public class Ball extends Enemy {
  protected int actorHeight;
  private int speedX = 0;

  public Ball() {
    actorHeight = getImage().getHeight();
    speedX = Greenfoot.getRandomNumber(8) - 4;
    if( speedX == 0 ) {
      speedX = Greenfoot.getRandomNumber(100) < 50 ? -1 : 1;
    }
  }

  public void act() {
    checkOffScreen();
  }

  public int getXVelocity() {
    return speedX;
  }

  private void checkOffScreen() {
    if( getX() < -20 || getX() > getWorld().getWidth() + 20 ) {
      getWorld().removeObject(this);
    } else if( getY() > getWorld().getHeight() + 20 ) {
      getWorld().removeObject(this);
    }
  }
}

Ball类的实现缺少处理移动和弹跳的代码。正如我们之前所述,在提供作为本游戏起点使用的代码之后,我们将回顾所有基于投射物的代码。在Ball构造函数中,我们在x方向随机选择一个速度并将其保存在speedX实例变量中。我们包含了一个辅助方法来返回speedX的值(getXVelocity())。最后,我们包括checkOffScreen()来移除屏幕外的Ball。如果我们不这样做,我们的应用程序将会有一种内存泄漏的形式,因为 Greenfoot 将继续分配资源并管理任何演员,直到它们从场景中移除。对于Ball类,我选择使用随 Greenfoot 标准安装提供的ball.png图像。

在本章中,我们将学习如何创建一个简单的粒子效果。创建效果更多的是关于粒子使用而非其实施。在下面的代码中,我们创建了一个通用的粒子类,Particles,我们将扩展它来创建一个RedBall粒子。我们以这种方式组织代码,以便于将来轻松添加粒子。以下是代码:

import greenfoot.*;

public class Particles extends Enemy {
  private int turnRate = 2;
  private int speed = 5;
  private int lifeSpan = 50;

  public Particles(int tr, int s, int l) {
    turnRate = tr;
    speed = s;
    lifeSpan = l;
    setRotation(-90);
  }

  public void act() {
    move();
    remove();
  }

  private void move() {
    move(speed);
    turn(turnRate);
  }

  private void remove() {
    lifeSpan--;
    if( lifeSpan < 0 ) {
      getWorld().removeObject(this);
    }
  }
}

我们的粒子被实现为向上移动并在每次调用act()方法时稍微转向。一个粒子将移动lifeSpan次然后移除自己。正如你可能猜到的,lifeSpan是延迟变量的另一种使用。turnRate属性可以是正的(稍微向右转)或负的(稍微向左转)。

我们只有一个Particles的子类,RedBall。这个类提供了RedBall的正确图像,提供了Particles构造函数所需的要求,并根据scaleXscaleY参数缩放图像。以下是其实施:

import greenfoot.*;

public class RedBall extends Particles {
  public RedBall(int tr, int s, int l, int scaleX, int scaleY) {
    super(tr, s, l);
    getImage().scale(scaleX, scaleY);
  }
}

对于RedBall,我使用了 Greenfoot 提供的图像red-draught.png

喷泉

在这个游戏中,喷泉增加了独特的挑战。在达到五级(见WorldCupcakeWorld)后,Fountain对象将被生成并在游戏中随机放置。图 2显示了喷泉在动作中的样子。一个Fountain对象会不断地将RedBall对象喷向空中,就像喷泉喷水一样。

喷泉

图 2:这是游戏 Cupcake Counter 中喷泉对象的特写

让我们看看实现Fountain类的代码:

import greenfoot.*;
import java.awt.Color;

public class Fountain extends Actor {
  private int lifespan = 75;
  private int startDelay = 100;
  private GreenfootImage img;

  public Fountain() {
    img = new GreenfootImage(20,20);
    img.setColor(Color.blue);
    img.setTransparency(100);
    img.fill();
    setImage(img);
  }

  public void act() {
    if( --startDelay == 0 ) wipeView();
    if( startDelay < 0 ) createRedBallShower();
  }

  private void wipeView() {
    img.clear();
  }

  private void createRedBallShower() {
  }
}

Fountain的构造函数创建一个新的蓝色半透明正方形,并将其设置为它的图像。我们从一个蓝色正方形开始,给游戏玩家一个警告,喷泉即将爆发。由于喷泉被随机放置在任何位置,如果直接在我们的玩家身上放下一个喷泉并立即结束游戏,那就太不公平了。这也是为什么RedBallEnemy的子类,而Fountain不是。玩家触摸蓝色正方形是安全的。startDelay延迟变量用于暂停一段时间,然后使用wipeView()函数移除蓝色正方形,然后开始RedBall淋浴(使用createRedBallShower()函数)。我们可以在act()方法中看到这一点。createRedBallShower()的实现和解释将在本章后面的粒子效果部分给出。

炮塔

在游戏中,屏幕顶部中间有一个炮塔,它会向玩家发射紫色弹跳球。它在图 1中显示。我们为什么使用弹跳球发射炮塔?因为这是我们自己的游戏,我们可以! Turret类的实现非常简单。炮塔旋转和创建要发射的Ball的大部分功能由前面讨论过的CupcakeWorld中的generateBalls()方法处理。这个类的主要目的是只绘制炮塔的初始图像,它由炮塔底座的黑色圆圈和一个作为炮管的黑色矩形组成。以下是代码:

import greenfoot.*;
import java.awt.Color;

public class Turret extends Actor {
  private GreenfootImage turret;
  private GreenfootImage gun;
  private GreenfootImage img;

  public Turret() {
    turret = new GreenfootImage(30,30);
    turret.setColor(Color.black);
    turret.fillOval(0,0,30,30);

    gun = new GreenfootImage(40,40);
    gun.setColor(Color.black);
    gun.fillRect(0,0,10,35);

    img = new GreenfootImage(60,60);
    img.drawImage(turret, 15, 15);
    img.drawImage(gun, 25, 30);
    img.rotate(0);

    setImage(img);
  }
}

我们之前讨论了GreenfootImage类以及如何使用其一些方法进行自定义绘图。我们介绍的一个新功能是drawImage()。此方法允许你在另一个GreenfootImage中绘制一个GreenfootImage。这就是你组合图像的方式,我们用它从矩形图像和圆形图像创建我们的炮塔。

奖励

我们创建一个Reward类,原因和创建Enemy类一样。我们是为了方便将来轻松添加新的奖励而做的准备。(在章节的后面,我们将将其作为练习)。以下是代码:

import greenfoot.*; 

public abstract class Reward extends Actor {
}

Cupcake类是Reward类的子类,代表玩家不断试图收集的屏幕上的对象。然而,纸杯蛋糕没有要执行的动作或需要跟踪的状态;因此,其实现很简单:

import greenfoot.*;

public class Cupcake extends Reward {
}

在创建这个类时,我将它的图像设置为muffin.png。这是一张随 Greenfoot 一起提供的图片。尽管图片的名称是松饼,但它对我来说看起来更像纸杯蛋糕。

跳跃者

Jumper类是一个允许其所有子类在按下上箭头键或空格键时跳跃的类。这个类的大部分内容将在本章后面的重力和跳跃部分实现。在此阶段,我们只提供一个占位符实现:

import greenfoot.*;

public abstract class Jumper extends Actor
{
  protected int actorHeight;

  public Jumper() {
    actorHeight = getImage().getHeight();
  }

  public void act() {
    handleKeyPresses();
  }

  protected void handleKeyPresses() {
  }
}

我们接下来要介绍的是Bob类。Bob类扩展了Jumper类,并添加了让玩家左右移动的功能。它还使用了在第二章动画中讨论的动画技术,使其看起来像是在实际行走。以下是代码:

import greenfoot.*;

public class Bob extends Jumper {
  private int speed = 3;
  private int animationDelay = 0;
  private int frame = 0;
  private GreenfootImage[] leftImages;
  private GreenfootImage[] rightImages;
  private int actorWidth;

  private static final int DELAY = 3;

  public Bob() {
    super();

    rightImages = new GreenfootImage[5];
    leftImages = new GreenfootImage[5];

    for( int i=0; i<5; i++ ) {
      rightImages[i] = new GreenfootImage("images/Dawson_Sprite_Sheet_0" + Integer.toString(3+i) + ".png");
      leftImages[i] = new GreenfootImage(rightImages[i]);
      leftImages[i].mirrorHorizontally();
    }

    actorWidth = getImage().getWidth();
  }

  public void act() {
    super.act();
    checkDead();
    eatReward();
  }

  private void checkDead() {
    Actor enemy = getOneIntersectingObject(Enemy.class);
    if( enemy != null ) {
      endGame();
    }
  }

  private void endGame() {
    Greenfoot.stop();
  }

  private void eatReward() {
    Cupcake c = (Cupcake) getOneIntersectingObject(Cupcake.class);
    if( c != null ) {
      CupcakeWorld rw = (CupcakeWorld) getWorld();
      rw.removeObject(c);
      rw.addCupcakeCount(1);
    }
  }

  // Called by superclass
  protected void handleKeyPresses() {
    super.handleKeyPresses();

    if( Greenfoot.isKeyDown("left") ) {
      if( canMoveLeft() ) {moveLeft();}
    }
    if( Greenfoot.isKeyDown("right") ) {
      if( canMoveRight() ) {moveRight();}
    }
  }

  private boolean canMoveLeft() {
    if( getX() < 5 ) return false;
    return true;
  }

  private void moveLeft() {
    setLocation(getX() - speed, getY());
    if( animationDelay % DELAY == 0 ) {
      animateLeft();
      animationDelay = 0;
    }
    animationDelay++;
  }

  private void animateLeft() {
    setImage( leftImages[frame++]);
    frame = frame % 5;
    actorWidth = getImage().getWidth();
  }

  private boolean canMoveRight() {
    if( getX() > getWorld().getWidth() - 5) return false;
    return true;
  }

  private void moveRight() {
    setLocation(getX() + speed, getY());
    if( animationDelay % DELAY == 0 ) {
      animateRight();
      animationDelay = 0;
    }
    animationDelay++;
  }

  private void animateRight() {
    setImage( rightImages[frame++]);
    frame = frame % 5;
    actorWidth = getImage().getWidth();
  }
}

CupcakeWorld类一样,这个类相当复杂。我们将按顺序讨论它包含的每个方法。首先,构造函数的主要任务是设置行走动画的图像。这种类型的动画在第二章动画伤害角色部分和第三章碰撞检测检测与多个对象的碰撞部分中都有讨论。这些图像来自www.wikia.com,由用户 Mecha Mario 以精灵图集的形式提供。精灵图集的直接链接是smbz.wikia.com/wiki/File:Dawson_Sprite_Sheet.PNG。请注意,我手动使用我最喜欢的图像编辑器从这张精灵图集中复制并粘贴了我使用的图像。

注意

免费互联网资源

除非你除了是程序员之外还是一名艺术家或音乐家,否则你将很难为你的 Greenfoot 场景创建所有需要的资源。如果你查看 AAA 级视频游戏的致谢部分,你会发现艺术家和音乐家的数量实际上等于甚至超过了程序员。

幸运的是,互联网伸出援手。有许多网站提供合法的免费资源,你可以使用。例如,我用来获取Bob类图片的网站在 Creative Commons Attribution-Share Alike License 3.0 (Unported) (CC-BY-SA)许可下提供免费内容。检查你从互联网下载的任何资源的许可使用情况并仔细遵守那些用户协议非常重要。此外,确保你完全注明了资源的来源。对于游戏,你应该包含一个致谢屏幕,列出你使用的所有资源的来源。

以下是一些提供免费在线资源的优秀网站:

接下来,我们有act()方法。它首先调用其超类的act()方法。它需要这样做,以便我们获得由Jumper类提供的跳跃功能。然后,我们调用checkDead()eatReward()checkDead()方法如果Bob类的实例接触到敌人,则结束游戏,而eatReward()方法通过调用CupcakeWorld方法的addCupcakeCount(),每次接触到Cupcake类的实例时,将我们的分数加一。

类的其余部分实现了左右移动。主要方法是handleKeyPresses()。像在act()中一样,我们首先做的事情是调用Jumper超类中的handleKeyPresses()。这运行了Jumper中的代码,处理空格键和上箭头键的按下。处理按键的关键是 Greenfoot 方法isKeyDown()(见以下信息框)。我们使用这个方法来检查是否按下了左箭头键或右箭头键。如果是这样,我们分别使用canMoveLeft()canMoveRight()方法检查演员是否可以向左或向右移动。如果演员可以移动,我们就调用moveLeft()moveRight()

注意

Greenfoot 中的按键处理

在本书的序言中,我们解释说,我们假设您对 Greenfoot 有一些经验,并且至少完成了位于页面上的教程:www.greenfoot.org/doc

第二个教程解释了如何使用键盘控制演员。为了刷新您的记忆,我们将在下面提供一些关于键盘控制的信息。

我们在实现键盘控制时主要使用的方法是isKeyDown()。这个方法提供了一种简单的方式来检查是否按下了某个键。以下是 Greenfoot 文档中的一段摘录:

public static boolean isKeyDown(java.lang.String keyName)
Check whether a given key is currently pressed down.

Parameters:
keyName:This is the name of the key to check.

This returns : true if the key is down.

Using isKeyDown() is easy. The ease of capturing and using input is one of the major strengths of Greenfoot. Here is example code that will pause the execution of the game if the "p" key is pressed:

if( Greenfoot.isKeyDown("p") {
  Greenfoot.stop();
}

接下来,我们将讨论canMoveLeft()moveLeft()animateLeft()canMoveRight()moveRight()animateRight()方法的功能与之类似,将不会进行讨论。canMoveLeft()的唯一目的是防止演员走出屏幕的左侧。moveLeft()方法使用setLocation()移动演员,然后使演员看起来像是在向左侧移动。它使用一个延迟变量来使行走速度看起来更自然(不要太快)。animateLeft()方法依次显示行走左侧的图像。这与我们在第二章中看到的动画策略相同,动画

平台

游戏包含几个玩家可以跳跃或站立的平台。平台不执行任何操作,仅作为图像的占位符。我们使用继承来简化碰撞检测。以下是Platform的实现:

import greenfoot.*; 

public class Platform extends Actor {
}

这里是BrickWall的实现:

import greenfoot.*; 

public class BrickWall extends Platform {
}

这里是Brick的实现:

import greenfoot.*; 

public class Brick extends Platform {
}

测试一下

你现在应该能够编译和测试 Cupcake Counter。确保你处理了在输入代码时引入的任何错误,如拼写错误或其他错误。目前,你只能左右移动。看看 Bob 正在行走。非常酷! 其他一切取决于我们之前实现中省略的一些代码。我们将在下一部分补全这些缺失的代码。让我们启动一些演员。

你的作业

考虑我们之前代码中省略的一个位置。尝试自己提供代码。你将如何开始?我的建议是先从铅笔和纸开始。画一些图形,想象你需要执行哪些步骤来实现功能。将这些步骤转换为 Java 代码并尝试运行。这样做将帮助你更好地理解和处理即将到来的解决方案,即使你的解决方案是错误的。

启动演员

我们将把之前的不完整实现转变为一个游戏,通过添加跳跃、弹跳、粒子效果和从炮塔发射的子弹。

重力和跳跃

目前,我们的玩家角色被卡在屏幕底部。我们将补全 Jumper 类和 Bob 类中的缺失代码,使我们的角色能够跳跃,并最终有办法到达屏幕顶部的蛋糕奖励。跳跃是施加向上的力以移动一个物体。我们还需要一个作用于物体的向下力,以便它能够落回地面。就像现实生活中一样,我们将这个力称为 重力Jumper 类的更改非常广泛,因此我们将首先查看完整的实现,然后进行讨论。以下是代码:

import greenfoot.*;

public abstract class Jumper extends Actor
{
  protected int actorHeight;
  private int fallSpeed = 0;
  private boolean jumping = false;

  // Class Constants
  protected static final int GRAVITY = 1;
  protected static final int JUMPSTRENGTH = 12;

  public Jumper() {
    actorHeight = getImage().getHeight();
  }

  public void act() {
    handleKeyPresses();
    standOrFall();
  }

  protected void handleKeyPresses() {
    if( (Greenfoot.isKeyDown("space") ||
    Greenfoot.isKeyDown("up")) && !jumping) {
      jump();
    }
  }

  private void jump() {
    fallSpeed = -JUMPSTRENGTH;
    jumping = true;
    fall();
  }

  private void standOrFall() {
    if( inAir() ) {
      checkHead();
      fall();
      checkLanding();
    } else {
      fallSpeed = 0;
      jumping = false;
    }
  }

  private void checkHead() {
    int actorHead = -actorHeight/2;
    int step = 0;
    while( fallSpeed < 0 && step > fallSpeed
    && getOneObjectAtOffset(0, actorHead + step,
    Platform.class) == null ) {
      step--;
    }
    if( fallSpeed < 0 ) {
      fallSpeed = step;
    }
  }

  private void checkLanding() {
    int actorFeet = actorHeight/2;
    int step = 0;
    while( fallSpeed > 0 && step < fallSpeed
    && getOneObjectAtOffset(0, actorFeet + step,
    Platform.class) == null ) {
      step++;
    }
    if( fallSpeed > 0 ) {
      fallSpeed = step;
    }
  }

  private boolean inAir() {
    Actor platform = getOneObjectAtOffset(0,
    getImage().getHeight()/2, Platform.class);
    return platform == null;
  }

  private void fall() {
    setLocation(getX(), getY() + fallSpeed);
    fallSpeed = fallSpeed + GRAVITY;
  }
}

请注意,我们添加了两个新的实例变量(fallSpeedjumping)和两个静态常量(GRAVITYJUMPSTRENGTH)。这些新变量将贯穿我们的代码。在我们的 act() 方法中,我们添加了 standOrFall() 方法。这个方法负责应用重力和检测碰撞(对于演员的头和脚)。在进一步查看该方法之前,让我们看看 handleKeyPresses() 方法的完整实现。在这个方法中,我们检测是否按下了空格键或上箭头键,如果是,则调用 jump()。你会注意到 if 语句还包含一个检查 Boolean 变量 jumping 是否为 false 的条件。我们需要这个检查来防止双重跳跃(在跳跃过程中再次跳跃)。jump() 方法将 fallSpeed 改为负值。这会在演员上施加向上的力。我们将 jumping 设置为 true(因为我们现在处于跳跃状态),然后调用 fall()fall() 方法将重力应用于演员。在这个方法中,我们可以看到负值的 fallSpeed 将推动演员向上移动。

fallSpeed 的值会持续加上 GRAVITY,直到它变为正值。这将产生类似抛物线的运动,如图 图 3 所示。

重力和跳跃

图 3:这是坠落实现的示例

让我们来看看standOrFall()函数的实现。首先我们需要检查我们是否目前正站在一个Platform对象上。我们使用inAir()方法来进行这个检查。这个方法使用getOneObjectAtOffset()(见第三章, 碰撞检测)来检查角色的底部是否接触到了Platform对象,如果接触到了则返回false。在standOrFall()中,如果我们确定自己在空中,我们会做三件事情。我们会检查角色的顶部或底部是否与Platform发生碰撞,如果发生碰撞则调用fall()方法。checkHead()checkLanding()方法类似。它们都用于基于边界的碰撞检测,如第三章, 碰撞检测中所述,以检测碰撞发生的确切像素位置。然后它们会改变fallSpeed的值,使角色在碰撞点停止。如果我们检测到在standOrFall()中我们不在空中,那么我们就站在平台上,可以将fallSpeed设置为0(不坠落)并将jumping设置为false(不跳跃)。

弹跳

弹跳角色看起来很棒,并且真的为任何游戏增添了很好的维度。在玩家的心中,它们将你的游戏从像素的平面排列推进到一个丰富的世界,在这个世界中,物体遵循物理的自然法则。在 Cupcake Counter 中,从炮塔射出的球会弹跳。弹跳在Ball类中实现。首先,将以下实例变量添加到现有的Ball类中:

private int fallSpeed = 0;
protected static final int GRAVITY = 1; 

接下来,我们需要向act()方法中添加代码,使类的实例在撞击到对象时能够坠落或弹跳。将你的act()方法更改为以下内容:

public void act() {
  fallOrBounce();
  checkOffScreen();
}

fallOrBounce()方法将会很复杂,但我们将使用功能分解(将其分解成更小的方法)来管理复杂性,并使我们的代码更易于阅读。以下是它的实现:

private void fallOrBounce() {
  if( fallSpeed <= 0) {
    checkHead();
  } else {
    checkLanding();
  }
}

我们已经将fallOrBounce()的实现简化为检查我们是否即将撞头或即将落在平台上。我们根据fallSpeed的值在这两个检查之间进行选择。如果fallSpeed是负数,那么我们正在向上移动,此时不需要检查是否即将着陆。以下是checkHead()的实现:

private void fallOrBounce() {
  if( fallSpeed <= 0) {
    checkHead();
  } else {
    checkLanding();
  }
}
private void checkHead() {
  int actorHead = -actorHeight/2;
  int step = 0;
  int oldFallSpeed;
  while( fallSpeed < 0 && step > fallSpeed &&
  getOneObjectAtOffset( 0, actorHead + step,
  Platform.class) == null ) {
    step--;
  }
  if( step > fallSpeed ) {
    if( fallSpeed < 0 ) {
      handleBounce(step);
    }
  } else {
    fall(speedX);
  }
}

checkHead() 方法使用基于边界的碰撞检测(在第三章碰撞检测中讨论),来检测物体的顶部何时接触到平台。如果 step 最终大于 fallSpeed,则没有发生碰撞,我们可以继续通过调用 fall() 让重力影响我们的轨迹。如果 step 小于 fallSpeed,则我们的头部撞到了平台,我们需要通过调用 handleBounce() 来处理从这个平台上弹跳的情况。以下是 handleBounce() 的实现。

private void handleBounce(int step) {
  int oldFallSpeed = fallSpeed;
  fallSpeed = step; 
  fall(0);
  oldFallSpeed = (int)(oldFallSpeed * 0.7);
  fallSpeed = step - oldFallSpeed;
  fall(0);
  fallSpeed = -oldFallSpeed;
}

此方法通过将其分为两个主要阶段来处理弹跳。第一阶段处理角色与平台之间的运动。第二阶段处理从平台到最终位置的运动。阶段在 图 4 中显示。

弹跳

图 4:这显示了处理弹跳的两个主要阶段。阶段 1 是碰撞前的运动,阶段 2 是碰撞后的运动

在第一阶段,我们将球移动到碰撞点,通过将 fallSpeed 设置为 step 并调用 fall(0) 来实现。我们很快就会看到 fall() 的实现。现在,只需知道 fall(0) 调用 setLocation() 来移动球,并通过应用重力的效果来更新 fallSpeed 就足够了。在 handleBounce() 的第二阶段,我们乘以 0.7 以模拟碰撞中发生的能量损失。0.7 没有什么神奇或科学的地方。它只是在测试时看起来合适。然后我们通过再次调用 fall(0) 来移动剩余的惯性距离(stepoldFallSpeed)。弹跳改变了我们的下落方向,所以我们最后要做的就是更新 fallSpeed 以反映这种变化。

由于我们刚刚使用了 fall() 方法,让我们来看看它:

private void fall(int dx) {
  setLocation(getX() + dx, getY() + fallSpeed);
  fallSpeed = fallSpeed + GRAVITY;
}

如前所述,fall() 方法使用 setLocation() 根据其在 x 方向上的速度和下落速度来移动角色。实例变量 fallSpeed 被更新以考虑重力(减速或加速)的影响。

剩下要完成 Ball 类实现的唯一方法是 checkLanding()。下面是它的实现:

private void checkLanding() {
  int actorFeet = actorHeight/2;
  int step = 0;
  int oldFallSpeed;
  while( fallSpeed > 0 && step < fallSpeed &&
  getOneObjectAtOffset(0, actorFeet + step,
  Platform.class) == null ) {
    step++;
  }
  if( step < fallSpeed ) {
    if( fallSpeed > 0 ) {
      handleBounce(step);
    }
  } else {
    fall(speedX);
  }
}

checkLanding() 函数的实现与 checkHead() 函数的实现完全相同,只是它处理的是向下移动而不是向上移动。

弹跳是一个很好的效果,可以应用于各种角色。你可以将弹跳的实现与我们在上一节中讨论的跳跃实现结合起来,为你的游戏制作一个会弹跳、会跳跃的英雄。

粒子效果

粒子效果是通过创建许多小演员来制作动画的。之前,您学习了主要通过快速图像交换来制作动画。您可以想象通过创建 4-6 个向上喷射的喷泉图像并在这之间切换来创建一个喷泉。而不用这样做,我们将使用粒子效果来创建喷泉。方便的是,您已经拥有了创建粒子效果所需的所有信息。粒子只是您分配了运动模式的小演员。然后,您创建很多它们来提供所需的效果。我们将这样做来完成Fountain类的实现。我们唯一没有实现的部分是createRedBallShower()方法的代码。以下是缺失的代码:

private void createRedBallShower() {
  lifespan--;
  if( lifespan < 0) {
    getWorld().removeObject(this);
  } else {
    int tr = Greenfoot.getRandomNumber(30) - 15;
    int s = Greenfoot.getRandomNumber(4) + 6;
    int l = Greenfoot.getRandomNumber(15) + 5;
    getWorld().addObject(new RedBall(tr, s, l, 10, 10), getX(), getY());
  }
}

实例变量lifespan是一个延迟变量,我们用它来确定喷泉存在的时间。一旦lifespan小于零,我们就从场景中移除这个喷泉。否则,我们用随机的生命周期和转向速度重新创建RedBall。这些参数在敌人部分讨论过。

在每次调用act()方法时重新创建RedBall,并赋予其略微不同的属性,可以创建一个非常有趣的喷泉效果,如图 2所示。

子弹和炮塔

我们已经完全实现了子弹和炮塔。Turret类已经完成,我们在弹跳部分完成了Ball类(我们的子弹)。在这里,我们将讨论创建炮塔和子弹的基本步骤,并解释我们之前所做的工作如何为您提供创建机枪、大炮、坦克或其他类型炮塔所需的信息。

首先,您需要一个带有图像的炮塔。您可以像我们在Turret类中做的那样动态创建图像,或者使用setImage()来设置它。然后,炮塔只需要旋转到它们射击的方向。这就是我们在CupcakeWorld中的generateBalls()方法中所做的。子弹只是旋转到某个方向并不断调用move()以在该方向移动的演员。如果您将炮塔和子弹旋转相同的角,将子弹放置在炮塔相同的起始位置,并让子弹向前移动,那么它就会看起来像炮塔发射了子弹。这说得通吗?图 5总结了这一策略。

子弹和炮塔

图 5:创建发射子弹的炮塔所需的步骤

您的作业

现在,编译我们刚刚给您的所有代码,并玩一会儿蛋糕计数器。您可能会开始注意到为什么我们一开始让平台的位置具有一些随机性。如果我们没有这样做,玩家会很快适应球体的下落模式。

本节的任务是为游戏编写另一个随机变体。你可以进一步随机化平台,玩弄球的速度或大小,或者改变玩家跳跃的力量。

挑战

我们已经创建了一个相当功能齐全的游戏。我们的游戏中有一个得分、酷炫的动画、碰撞检测和关卡。在玩过它之后,你会首先想改进什么?让你的朋友也来玩。他/她觉得怎么样?尝试根据你玩游戏的经验提出一些改进游戏的改变。

此外,我们设计游戏使其易于添加新的奖励、敌人和平台。为游戏添加每种各一个,并给它们添加你自己的特色。例如,你可以创建一个价值五分的超级纸杯蛋糕,但它只持续很短的时间。这将要求玩家在游戏中做出一些快速而有意义的决策。

摘要

虽然我们没有创建一个完整的物理引擎,但我们确实介绍了一些简单的技术来为演员提供有趣的动作。我们的讨论集中在基于抛射物的动作上,包括弹跳、跳跃、射击和粒子效果。到目前为止,我们已经掌握了一系列创造性的编程技术,使我们能够创建各种动画、模拟和游戏。然而,创建一个有趣的交互式体验并非易事。在下一章中,我们将学习游戏设计和游戏开发的过程,这将帮助我们创建令人惊叹的交互式体验。

第五章。交互式应用程序设计与理论

*"如果你从未做过,你应该。这些事情很有趣,有趣是好的。"
--苏斯博士

在 Greenfoot 中创建引人入胜和沉浸式的体验,远比将编程效果集合到一个应用程序中更具吸引力。在本章中,你将学习如何通过理解用户选择与结果之间的关系、对用户进行条件化以及将适当复杂度融入你的工作中来吸引用户。你将看到一个经过验证的迭代开发过程,这将帮助你将理论付诸实践。本章将涵盖以下主题:

  • 有意义的游戏

    • 选择、行动和结果

    • 复杂度

    • 目标

  • 用户条件化

  • 讲故事

    • 虚拟世界

    • 叙事描述

  • 交互娱乐迭代开发过程

当我们讨论本章的主题时,我们将参考我们在第一章,“让我们直接跳进去…”,以及第二章,“动画”中创建的 Avoider 游戏。我们将讨论游戏中已经实现的项目,以说明交互设计概念,并通过添加新功能来展示其他概念。在本章中,我们正在讨论创造乐趣的方法。这听起来可能有些奇怪,但创造乐趣是游戏和其他形式交互娱乐的设计师的主要目标。而且,正如苏斯博士如此优雅地所说,“这些事情很有趣,有趣是好的。”

有意义的游戏

学习创造对用户有意义的体验是交互应用程序开发者最需要的技能。正是这种交互的意义驱使玩家投入时间和精力来玩你的应用程序。我们希望唤起用户快乐、愤怒、自豪、放松、关心、惊讶、惊喜、兴奋或满足的感觉。为了做到这一点,我们需要为用户做出的困难和行动提供即时和长期反馈

让我们看看一些可能发生在角色扮演游戏(RPG)中的澄清示例。想象一下,你是一位在森林覆盖的山脉中漫步的巫师,当你来到一个洞穴前。向洞穴里窥视,你在昏暗的光线中看到一条被宝藏包围的沉睡的龙。以下是一些可能发生的互动。我们将讨论每一个,并确定它是否创造了有意义的游戏。

场景 1:

  • 用户选择:你翻阅你的咒语书,决定施展火球咒语。

  • 用户行动:你对龙施展了咒语。

  • 系统反馈/结果:火球击中了龙。之后没有发生任何事情。

啊?!?你的拼写失败了吗?游戏出故障了吗?龙有反魔法光环吗?你真的错过了吗?我们从这次交互中得不到任何意义。玩家感到困惑和脱节。让我们看看另一个场景。

场景 2:

  • 用户选择:你翻阅你的咒语书,决定施放火球魔法。

  • 用户行为:你对龙施放了魔法。

  • 系统反馈/结果:火球击中了龙。龙痛苦地尖叫着,然后分解。

这里的意义非常明确。你对一个像龙这样强大的生物几乎无能为力,也没有机会。你可以选择和龙交谈,但他已经对你的存在表示极度的不满。你是要冒险还是逃跑?也许有一天,你会变得足够强大,回来从这条龙那里夺走宝藏。现在,你可能觉得自己很幸运,受到鼓舞想要变得更好,或者因为缺乏进展而感到害怕或沮丧。然而,你确实有所感受,这种互动确实是有意义的。

情景 3

  • 用户选择:你翻阅你的咒语书,决定施放火球魔法。

  • 用户行为:你对龙施放了魔法。

  • 系统反馈/结果:火球击中了龙。龙在痛苦中尖叫,然后分解。

啊,是时候收集你的战利品了。你对自己的至高无上力量感到惊叹。也许你应该给龙一次逃跑的机会?不,它可能不会为你这么做。这次互动再次证实了你的伟大。

创建引人入胜的应用程序的关键是时刻与用户产生有意义的互动。如果选择和行动没有有意义的后果,那么为什么还要让用户烦恼呢?您交互式应用程序中的每一次互动都需要有意义。这正是 Salen 和 Zimmerman 在他们所著的《游戏规则》一书中提到的描述性有意义的游戏

您的应用程序中另一个非常重要的方面是用户行为的长期结果。例如,在情景 3 中,玩家可能会被称为游戏中的龙战士,之后在游戏中。用战利品购买的精美物品应该对游戏后期有用。行为的结果需要在游戏中持续存在。Salen 和 Zimmerman 将此称为评估性有意义的游戏

有意义的游戏不仅适用于游戏。在设计任何应用程序时,都应该使用相同的思考过程。例如,如果用户点击了一个他们感兴趣想要购买的电子商务网站上的商品,应该立即有视觉反馈,比如购物车图标显示 1 而不是 0,或者更进一步,播放几秒钟欢呼的人群的声音。如果用户在文字处理文档中输入文字,文字处理应用程序应该突出显示保存图标,或者在文件名旁边放置星号,以表明有未保存的更改。如果一个人正在填写调查问卷,应该有一个进度条让他们知道他们还有多少问题需要回答。也许你可以在用户完成五个调查问题后提供一些鼓励的话语。

复杂性

有一个非常有趣的演示游戏,展示了当玩家的选择过于简单时,游戏会是什么样子。它被称为超级 PSTW 动作 RPG。在这个 RPG 中,你只有一个控制键——空格键。对于任何给定的情况,你只需简单地按空格键。如果你还没有猜到,标题中的 PSTW 代表按空格键获胜

显然,这款游戏只是一个玩笑,但它也是游戏设计中的一个有趣实验。游戏中没有有意义的玩法,因为玩家必须做出的选择和行动都是微不足道的。动作和结果设计得很好,但这还不够。没有选择复杂性的话,就没有有意义的玩法。我们不必用这样一个极端的例子来证明这一点。你玩过“战争”牌戏吗?如果没有,你可以快速在这里回顾规则:en.wikipedia.org/wiki/War_(card_game)。这款游戏也没有有意义的玩法。在整个游戏中,玩家要么翻一张牌,要么将三张牌面朝下放置,然后翻一张牌。当前的游戏状态完全告知玩家他们应该采取什么行动。没有需要考虑的权衡和风险分析。这完全是机械式的玩法。对于大多数 10 岁以上的玩家来说,井字棋也遭受了同样缺乏有意义的玩法的困扰。

为了存在有意义的玩法,玩家所做的决定应该需要足够的脑力劳动。玩家必须有几个选项可供选择,并且每个选项都应该涉及不同的权衡、风险和回报。例如,在我们的 RPG 场景中,玩家可能有以下选择:施放火球、施放闪电打击、施放魅惑怪物、交谈或逃跑。也许玩家在游戏中之前已经了解到,魅惑法术很少对龙有效,而且某些龙对火或闪电具有免疫力。玩家可以选择逃跑。这是一个低回报、低风险的选项。

当玩家做出非平凡的决定、采取行动并获得清晰的反馈时,游戏就变得有意义。在交互式应用程序或游戏中做出决策时,玩家需要知道互动的目标是什么。是要创建定制的音乐作品、成为最强大的巫师,还是在电子商务网站上获得最佳交易?设置用户目标是创建引人入胜的应用程序下一个最重要的方面。

目标

目标为玩家提供了评估他们在与你的应用程序的即时和长期互动中做出的决策的手段。每次互动后,用户可以问自己,“我的最后一个选择和行动是否让我更接近完成我的目标?”通过这种持续的评估,用户可以增强和优化他们的决策过程,以便更快地实现他们的目标。本质上,玩家使用目标作为指导,学习与你的应用程序互动的最佳方式。

在编写一个交互式应用程序时,你必须为你的用户设定清晰的目标和子目标。这就是为什么高分榜在许多游戏中如此受欢迎。仅仅拥有一个高分榜就为游戏提供了目标——尽可能多地得分。当人们玩你的游戏时,他们会不断判断他们上一次的行动或长期行动是否导致了最大数量的得分。这增强了游戏的趣味性。

在工业界,公司通常会尝试将他们的应用程序或服务游戏化。例如,航空业设立了赚取免费飞行里程的程序。因此,客户现在参与做出决策,以优化他们获得免费飞行里程的能力,以换取免费航班。在旅途中建立子目标以保持客户的投入往往是有意义的。如果需要一两年才能积累足够的免费飞行里程,那么消费者可能会感到沮丧。航空公司可能会决定提供中间目标,例如在达到一定数量的里程后,可以获得一个免费的旅行杯。子目标对于驱动短期行为非常重要,正如奖励一样。我们将在下一节进一步讨论用户条件化的方法。

用户条件化

在创建一个交互式应用程序或游戏时,我们希望用户体验尽可能好。在创造有意义的游戏时,我们为用户提供了一个丰富的选项集供他们选择,他们的游戏路径有许多可能的状态和结果。随着游戏状态的可能状态和转换增加,作为游戏设计师,确保每个游戏状态路径都导致积极的交互变得更加困难。我们需要使用用户条件化来帮助引导用户的行为,以可预测的方式与我们的应用程序互动。

条件化的效果在伊凡·巴甫洛夫的一个涉及狗和食物的实验中得到了清晰的展示。在这个实验中,巴甫洛夫每次喂狗时都会响铃。最终,他只需响铃就能让狗流口水。狗学会了将一个中性刺激,如铃声,与食物联系起来。虽然这样说我们想要像这样操纵我们的用户似乎很奇怪,但这将帮助我们引导用户进入与我们的应用程序最有利的交互。

我们将使用三种方法来条件化我们的用户:

  • 正强化:当用户做出我们希望的行为时,给予用户奖励。

  • 负强化:当用户做出期望的行为时,移除一些负面因素。

  • 惩罚:当用户表现出错误行为时,移除一些正面因素或添加一些负面因素。

我们希望尊重我们的用户,并希望他们玩得开心;因此,积极的强化应该是我们最常用来训练用户的方式。我们希望奖励他们正确的表现。在游戏中,我们可能会给他们加分、额外生命、额外能力,或者进入游戏新区域的权限。无论是创建游戏还是交互式应用程序,你都应该为你的用户提供一套奖励计划。一些奖励可能会频繁发放,例如,通过消灭敌人获得积分,而其他奖励可能会更罕见,例如,给予角色飞行的能力。

给予奖励允许我们告诉玩家哪些行为在游戏中是被偏好的。如果我们希望用户尽可能快地通过一个区域,我们可以在 30 秒内完成时给予奖励。如果我们希望用户探索控制并找到战斗动作组合,我们可以在连续完成三次战斗组合时给予额外积分。如果我们希望用户采摘花朵,我们可以在每次采摘花朵时提供 0.001%的机会获得游戏中最强大的物品。如果我们希望顾客在商店更频繁地购买咖啡,我们可以每第十二杯饮料免费。奖励是一种强大的用户训练机制。

负面强化虽然使用较少,但仍然是一种强大的用户训练工具。在上一个段落中,我提到你可以通过给予玩家在 30 秒内通过某个区域的奖励来鼓励他们快速通过该区域。使用负面强化,我们可以驱动相同的行为。想象一下,你在一个非常大的房间里,天花板开始慢慢下降。你意识到如果你不快速离开,你将被压扁。通过快速移动到房间的另一边并离开,你就不再有这种快速移动的压力。这就是负面强化。负面强化的其他例子包括在一个区域中使灯光变暗并播放令人毛骨悚然的音乐,以鼓励玩家快速离开该区域(游戏《生化奇兵》在这方面做得很好),当玩家在不应出现的地方时发出警报,或者闪烁屏幕直到你的健康值回到可接受的水平。

最终,惩罚是游戏玩法的一个必要组成部分,尽管通常不包括在非游戏应用的游戏化中。最终必须对未能达到游戏目标的行为有硬性后果。这可能包括扣分、扣钱、失去生命,最终输掉游戏。惩罚是必要的,因为必须有一些风险与玩家所做的选择相关联,以便实现有意义的游戏体验。只是尽量不要对你的用户太苛刻。制作游戏的全部目的就是提供一个引人入胜、休闲的活动。

讲故事

从最早的时代起,讲述和欣赏故事就已经深深植根于我们的文化中。我们以许多不同的格式和媒介呈现故事。它们出现在口头传统、书面文字、戏剧、电影和游戏中。游戏是讲故事的新形式之一,但也许是因为一个简单的原因——你才是故事的主角。在传统的讲故事方法中,作者必须花费足够的时间构建一个受众关心的、可联系的角色。在游戏中,你无需付出任何代价就能得到这个角色。

虚构世界

在游戏中,故事除了纯粹的娱乐作用外,还有其他作用。故事为游戏玩家创造了一个虚构世界,有助于引导他们的体验。它为行动和实现目标提供背景和动机。为什么我们在游戏中要杀死这些外星人?好吧,鉴于他们刚刚摧毁了整个南美洲,正朝北方进发,我们知道如果世界要在这场入侵中生存下来,我们必须尽快阻止他们的前进。如果一个游戏发生在外太空,你期望看到宇宙飞船、激光和外星人。只要告诉玩家故事发生在老西部、水下或足球场上,就能向玩家提供大量信息。

对于任何游戏,你应该有一个丰富而完整的故事,涵盖游戏在玩家开始游戏之前、游戏过程中以及游戏之后发生的事情——即使用户永远不会体验到游戏之前和之后的事情。你的虚构世界和故事不仅为玩家提供背景和动机,还作为游戏设计师的指南。当你从游戏中添加或删除功能时,当你通过互动娱乐开发过程(在本节中描述)时,你需要确保你忠于这两者。

叙述描述符

在你的游戏中,一切元素都为故事和你的虚构世界做出贡献——无论是游戏包装盒上的图形、说明书,还是游戏中的声音和图像。你希望通过提供适当且一致的叙述描述,让你的玩家想象出一个丰富而充满活力的世界。幸运的是,由于最小偏离原则,不需要太多提示就能让你的用户想象出一个复杂的世界。这个原则指出,人们会利用他们对世界的知识来填补他们在一个不完整的图像中看到的任何缺失。花几分钟时间看看图 1。你想象那些山里住着什么?这个世界有重力吗?你能对这个图中描绘的世界说些什么?无论你想到什么,你都是利用最小偏离原则做到的。

叙述描述符

图 1:这是最小偏离原则的一个例子。

现在,请看看图 2。图片中添加了什么?这如何改变你对世界的看法?如果我在图片中添加一个穴居人而不是机器人会怎样?

叙事描述符

图 2:这展示了单个叙事描述符如何完全改变虚构世界

就像在电影中一样,你游戏中或互动应用程序中出现的每一件事都应该延续故事。例如,如果你创建了一个中世纪时期的互动历史模拟,那么你应该使用符合该时期的字体,而不是使用 Courier New。如果你有一个发生在第一次世界大战的游戏,那么也许你可以在玩家受到更多伤害时,展示一个看起来更受伤的士兵图片,而不是展示一个普通的健康条。

注意

迪士尼

迪士尼提供了精彩的故事,并且是运用叙事描述符的大师。你有没有去过迪士尼乐园?我曾经有幸被迪士尼的想象工程师和艺术家带领参观迪士尼乐园。给我留下深刻印象的第一件事是,迪士尼的每位员工都背诵着同样的咒语,“故事,故事,故事”。我惊讶地发现,在参观过程中,我的导游不断地指出他们使用叙事描述符的方式。在不同的区域,他们会向我展示如何通过混凝土、植物、垃圾桶和照明等元素共同构建该区域的叙事。他们知道,任何一个位置不恰当的物品都可能会使顾客所想象的虚构世界变得混乱或被破坏。

互动娱乐迭代开发过程

本章迄今为止所讨论的所有设计原则都将帮助你创建有意义的、引人入胜的互动应用程序。然而,这些还不够。随着你继续设计和构建你的游戏,你会对游戏有更深入的了解,并失去作为无偏见评判者的能力。此外,你认为有趣和有意义的东西,可能对其他人来说会感到困惑。你必须意识到,如果你创建了一个具有足够复杂性的游戏,你将无法预测游戏玩法。

给你的应用程序提供最佳成功机会的唯一方法,就是使用图 3中所示的互动娱乐迭代开发方法进行开发。

互动娱乐迭代开发过程

图 3:这是互动娱乐迭代开发过程

接下来,我们将讨论为游戏构思一个初步想法以及你需要做的前期工作,以便制作一个有效的游戏提案。在游戏产业中,游戏开发者必须提出他们的游戏,并说服同行和管理层,这个游戏值得投资。如果你在一个小团队中工作,你仍然需要向希望招募加入你项目的开发者和艺术家提出游戏提案。在讨论前期工作之后,我们将依次讨论迭代设计过程的每个阶段。

游戏提案和初步设计

因此,你有一个想法,并且有建造游戏的愿望。接下来你应该做什么?你需要创建一个清晰简洁的方式来向他人描述你的游戏。你需要能够描述你正在创造的世界,这个世界的背景故事,游戏的主要目标,以及游戏规则的草稿。以下是创建这些信息的详细过程:

  1. 用一段话描述你的游戏。尽量保持五句话或更少。要清晰、具体、简洁。随着你的游戏随着时间的推移而发展,确保更新这一段落。

  2. 编写你游戏的剧情,包括游戏开始之前发生了什么,游戏过程中发生了什么,以及游戏结束后发生了什么。这将最终成为所有开发者、设计师和艺术家在考虑为游戏添加新功能和资产时参考的指南。同样,确保这份文档保持更新。

  3. 用一句话说明游戏的目标。玩家试图实现什么?

  4. 用一句话说明玩家如何知道他/她比另一个玩家更好。最好的玩家是得分最高的人吗?进度最远的人?用时最短的人?

  5. 撰写游戏规则的草案。尽量提出至少五条主要规则。规则为你的游戏提供正式描述。每个游戏都有简洁的规则集。

  6. 创建你游戏的分镜脚本。分镜脚本读起来像漫画书,描述了游戏的主要故事线和概念艺术。分镜是主要的设计工具,在游戏和电影行业中都得到广泛应用。

在创建这些信息后,确保将其保存在所有团队成员共享的中心位置,并在游戏发展过程中进行更新。当你不确定应该添加什么功能到游戏中,或者是否应该给故事增加一个转折时,查阅这些文档并确保一切保持一致。

原型

在这一步,你将实施在上一个迭代细化步骤中已经决定的一些游戏功能。如果你是第一次迭代,选择一些简单的事情来实现,比如主要角色、移动控制,也许还有一个敌人。这一步只包含编码,没有设计工作。那是在上一个迭代中完成的。

游戏测试

这种迭代开发过程鼓励基于游戏的设计。在基于游戏的设计中,你让志愿者测试你的游戏。他们评估它,不是基于你给出的描述,而是基于游戏的实际玩法。早期阶段将很简单,评估者可能只能评论控制是否自然或导航是否顺畅。随着游戏的发展,他们提供反馈的能力也会提高。

你的游戏测试会话应该定义明确且可重复。你想要确保每个测试者都有相同体验,以便你可以可靠地收集和比较他们的反馈。进行游戏测试时,你应该:

  1. 准备一台可以玩到你游戏原型的电脑。

  2. 向你的测试者简要解释游戏测试程序,然后简要描述你的游戏以及可供测试的功能。

  3. 根据你想要在这一迭代中测试多少功能,让你的玩家玩 5-20 分钟。尽量不要打断你的测试者(即使你注意到他们做得非常不对),除非他们问你问题。提供简短的答案,但不要详细阐述。

  4. 观察测试者的身体语言。他们看起来是否无聊?沮丧?投入?当他们告诉你他们的 10 分钟游戏时间结束时,他们是否在看着手表或感到惊讶?

  5. 当你的测试者完成游戏后,给他们一份简短的调查问卷。询问他们关于游戏控制、规则、目标和故事的问题。询问他们是否觉得游戏的挑战性平衡。他们认为他们在每一刻都有足够的选项去考虑吗?游戏的外观和感觉是否一致?你选择的叙事描述符是否有效?他们是否曾经对下一步该做什么感到困惑?收集一些关于测试者的统计数据。他们多大年纪?他们是休闲玩家还是核心玩家?

  6. 设定一个开放式的问答环节。他们是否有关于如何改进游戏的建议?有没有他们不喜欢的地方?

确保感谢你的测试者,并回顾你收集到的信息。这是你从测试者那里获取澄清的最后机会。完成所有这些后,你就可以进入下一阶段。

评估

评估阶段非常机械。在这个阶段,你只整理从所有测试者那里收到的结果。整理所有调查问卷的结果。是否对某些答案有共识?例如,80%的测试者觉得控制不灵活,或者 100%的玩家在杀死第一个敌人后不知道该做什么。在开放式的问答环节中,是否有多个测试者提出的建议?从身体语言观察中,是否大多数玩家在时间结束前就询问他们是否完成了测试?

精炼

真正的设计工作发生在这一阶段。挑选出在游戏测试过程中识别出的前两个或四个问题,并将它们记录下来。现在,头脑风暴一下你可以对游戏做出的改变、添加或删除,以解决这些问题。你的想法可能包括测试者给出的建议,也可能不包括。你并不一定要直接使用测试者给出的建议;然而,你应该给予它们特别的考虑。在头脑风暴的过程中,不要过滤你或你的队友的想法。在一张纸上,至少记录下你可以对游戏做出的二十种改变。

在头脑风暴了二十个想法之后,根据它们如何有效地解决你的测试玩家提出的前几个问题以及实现该想法所需的工作范围来优先排序。选择你前两个到五个想法,并在即将到来的原型阶段实现它们。这个过程再次开始。

优点

交互式娱乐迭代开发过程允许你以用户满意的方式逐步扩展你的游戏。通过快速迭代,你很快就能找到应该放弃的开发路径和应该开始的其他路径。虽然一开始可能看起来很耗时,但这个过程实际上会在长期内为你节省大量的开发时间,并大大增加你最终得到一个真正有趣的游戏的机会。

避免者游戏

在本书的前几章中,我们研究了避免者游戏,然后转向僵尸入侵模拟,接着是平台游戏。我们将回到避免者游戏,并使用它来展示本章前面讨论的概念。你可以从你自己的避免者游戏版本开始,或者下载这个版本:www.packtpub.com/support

避免者游戏回顾

我们在第一章、“让我们直接进入…”和第二章、“动画”中创建的避免者游戏版本相当功能齐全。它有一个介绍屏幕、一个游戏结束屏幕、一个移动的星系背景、有趣的演员动画、得分、升级和降级。图 4显示了游戏的一个截图。

避免者游戏回顾

图 4:这是我们版本避免者游戏的一个快照

在接下来的几个部分中,我们将增强这个版本的避免者游戏。我们的改变将基于我们刚刚研究过的游戏设计原则。

高分排行榜

我们将要做的第一个改变是添加一个简单的机制来记录最高分。然后我们将把这个分数显示在游戏结束屏幕上,这样玩家就可以看到他们与其他玩家的相对位置。通过添加高分,我们清楚地确定了游戏的主要目标——得分最高。要添加高分,需要对AvoiderGameOverWorld类进行以下更改:

import greenfoot.*;
import java.nio.*;
import java.nio.file.*;
import java.io.IOException;
import java.util.List;

public class AvoiderGameOverWorld extends World {
  public AvoiderGameOverWorld() {
    super(600, 400, 1);
  }

  public void act() {
    if( Greenfoot.mouseClicked(this) ) {
      AvoiderWorld world = new AvoiderWorld();
      Greenfoot.setWorld(world);
    }
  }

  public void setPlayerHighScore(String s) {
    Label scoreBoardMsg = new Label("Your Score: " + s, 35);
    Label highScoreMsg = new Label("Your Best: " + recordAndReturnHighScore(s), 35);
    addObject(scoreBoardMsg, getWidth()/2, getHeight()*2/3);
    addObject(highScoreMsg, getWidth()/2, (getHeight()*2/3)+45);
  }

  private String recordAndReturnHighScore(String s) {
    String hs = null;
    try {
      Path scoreFile = Paths.get("./scoreFile.txt");

      if( Files.exists(scoreFile) ) {
        byte[] bytes = Files.readAllBytes(scoreFile);
        hs = new String(bytes);

        if( Integer.parseInt(s) > Integer.parseInt(hs) ) {
          Files.write(scoreFile, s.getBytes());
          hs = s;
        }
      } else {
        Files.write(scoreFile, s.getBytes());
        hs = s;
      }

    } catch( IOException e ) {
      System.out.println("IOException");
    }

    return hs;
  }
}

构造函数和 act() 方法没有改变。我们添加了两个新的方法:setPlayerHighScore()recordAndReturnHighScore()setPlayerHighScore() 方法是公开的,将由 AvoiderWorld 调用,以 String 的形式传递当前玩家的分数到游戏结束屏幕。由于功能分解,这个方法相当简单。它创建了两个 Label 对象来显示玩家的分数和最高分,然后将这些对象添加到当前世界,即 AvoiderGameOverWorldLabel 类是新的,提供了一种轻松创建基于文本的图像的方法。我们很快就会查看它的代码。首先,我们将更仔细地查看 recordAndReturnHighScore() 方法,它包含检索和设置最高分的功能。

提示

java.nio.file 包要求您安装 Java 1.7 或更高版本。

recordAndReturnHighScore() 方法引入了文件 I/O。为了使最高分在您打开或关闭 Avoider Game 时都能持久保存,我们需要将最高分存储在文件中。文件提供持久存储。由于我们只存储或检索单个 String,我们可以使用一些非常简单的文件 I/O 操作。首先,我们调用 Paths.get() 静态函数。这提供了文件的位置。接下来,我们使用 Files.exists() 静态函数检查文件是否已存在。如果文件不存在,我们使用 Files.write() 创建并写入当前玩家的分数。

这个函数将创建文件并写入它,然后在返回之前关闭文件。如果文件确实存在,我们将使用 Files.readAllBytes() 读取其内容,这将打开文件,读取文件内容,关闭文件,然后返回它读取的数据。在这个方法中,我们需要做的最后一件事是查看当前玩家的分数是否高于当前最高分。如果是,我们将更新文件。recordAndReturnHighScore() 然后返回最高分,这将是读取自文件或当前玩家的分数。请注意我们添加的额外 import 语句,以便访问这些新的文件 I/O 类。

提示

Greenfoot 提供了另一种机制来存储和维护仅在您在 Greenfoot 网站上共享了您的游戏/应用程序后才能工作的最高分列表。要了解更多信息,请阅读 Greenfoot 在线文档中提供的 UserInfo 类。您可以在 www.greenfoot.org/files/javadoc/ 访问该文档。要了解更多关于 Java 文件 I/O 的信息,请查看 docs.oracle.com/javase/tutorial/essential/io/index.html 上的教程。

现在,我们可以看看Label类的代码。Label类是我们将要创建的一个新类,用来帮助我们向游戏中添加文本。创建一个新的Actor子类,命名为Label。不要将图像与这个类关联。我们也会在本章的后面使用这个类。以下是代码:

import greenfoot.*;
import java.awt.Color;

public class Label extends Actor
{
  GreenfootImage msg;

  public Label(String s, int size) {
    this(s, size, Color.white);
  }

  public Label(String s, int size, Color c) {
    msg = new GreenfootImage(s, size, c, null);
    setImage(msg);
  }
}

这个类很简单,只包含两个构造函数;第一个构造函数使用默认颜色白色调用第二个构造函数。在第二个构造函数中,提供的StringGreenfootImage转换成图像,然后这个图像被设置为Label实例的默认图像。

为了让所有这些功能正常工作,我们需要做的最后一个更改是在AvoiderWorld中添加代码,将玩家的当前分数传递给AvoiderGameOverWorld。我们只需要在AvoiderWorld中的endGame()方法中添加一行代码。以下是endGame()的完整实现:

public void endGame() {
  bkgMusic.stop();
  AvoiderGameOverWorld go = new AvoiderGameOverWorld();
  go.setPlayerHighScore(Integer.toString(scoreBoard.getScore()));
  Greenfoot.setWorld(go);
}

在进行这些更改后,编译游戏以查找和修复在过程中发生的任何错误。玩玩看。你的最高分是多少?图 5展示了你的游戏结束屏幕现在应该看起来是什么样子。

高分列表

图 5:添加了当前和最高分的游戏结束屏幕。

成就徽章

许多游戏和游戏化策略都使用了成就徽章的概念。这是你完成困难或非同寻常的事情后获得的徽章。在游戏和交互式应用程序中,它们是给玩家设定子目标以完成、调节他们的行为和增加选择复杂性的便捷且受欢迎的技术。我们将在我们的 Avoider 游戏版本中添加成就徽章来实现所有这些功能。

首先,我们需要想出一串成就列表。在实践中,想出正确的成就组合需要一些仔细的思考、时间和测试。以下是我想出的成就列表:

  • 神奇美味:玩家必须击中 20 片三叶草。

    • 这个成就增加了玩家在每一刻可用的选择复杂性。他们敢冒险收集另一片三叶草并承受减速惩罚吗?
  • 火鸡:玩家必须连续收集三个岩石。在这段时间内,用户不能触碰任何其他对象。

    • 这个成就条件使玩家即使在满血状态下也会去追击岩石。
  • 不可破坏:玩家必须触摸敌人 10 次。

    • 这个成就增加了用户可用的选择复杂性,并在游戏的某些缓慢部分作为增加挑战的手段。在游戏中,你会遇到一些小段时间,你处于满血状态,没有立即被敌人击中的危险。在这些时期,玩家现在可以选择承受几次打击,以增加他们获得的不可破坏徽章数量。
  • 大师躲避者:玩家在总共击中三个纸杯蛋糕之前完成游戏。

    • 这个成就强化了我们希望的行为。我们希望玩家不惜一切代价避免纸杯蛋糕。

这些成就也作为用户的潜在子目标。我们将在游戏结束屏幕上显示玩家获得的徽章,紧挨着主要目标——他们的分数。

在实现成就徽章时需要考虑的第一个问题是,确定是否获得徽章所需的数据。在我们的案例中,这些数据分布在PowerItems子类和Avatar之间。此外,AvoiderGameOverWorld类将需要知道哪些徽章被获得,以便它们可以在游戏结束屏幕上显示。我们希望有一个中心位置来收集这些信息。因为我们需要分布式访问单个类,我们将使用单例设计模式。

注意

设计模式

随着你对编程的熟练程度提高,你将在工作中认识到常见的编码模式。也许在你创建的下一个游戏中,你也会想要成就,并想,“我可以直接重用我在 Avoider 游戏版本中的成就设计。”本质上,你有一个非常小、个人的设计模式,你可以用它来创建可以随着时间的推移不断改进的成就。

自编程诞生以来,开发者们就创造了许多非常有用的设计模式,这些模式适用于许多不同类型的应用程序。设计模式提供了一种经过验证、测试的方法来编码某些功能,你可以轻松地将其适应自己的用途。

设计模式也非常有用去研究。通过研究它们,你可以看到一些最好的程序员如何使用抽象来解决复杂、重复的问题。它们还作为开发者之间有效沟通的简洁语言。说 Avoider 游戏中的BadgeCenter类实现了单例设计模式,比从头描述它要方便得多。

关于设计模式的书籍出版了很多,但最著名和最受欢迎的是由Gamma 等人撰写的《设计模式:可复用面向对象软件元素》。在你的开发生涯的某个阶段,你应该阅读这本书。

为了跟踪玩家的成就,我们将创建一个名为BadgeCenter的类,该类遵循单例设计模式。这个新类将不是WorldActor的子类。要创建它,请点击菜单栏中的编辑,然后选择新建类。你应该会看到图 6中显示的弹出窗口。输入BadgeCenter并按Enter键。我们现在准备好添加代码。

成就徽章

图 6:这是用于创建不是 Actor 或 World 子类的类的弹出窗口

这里是这个类的代码:

import java.util.ArrayList;
import java.util.List;

public class BadgeCenter //Implemented as a Singleton
{
  private int clovers, rocks, enemies, cupcakes;
  int rockBadges;
  private String previous;
  private ArrayList<Badge> badges = new ArrayList<Badge>();
  private static final BadgeCenter INSTANCE = new BadgeCenter();

  private BadgeCenter() {
    clovers = rocks = enemies = cupcakes = 0;
    rockBadges = 0;
  }

  public static BadgeCenter getInstance() {
    return INSTANCE;
  }

  public void hitClover() {
    ++clovers;
    previous = "clover";
    if( clovers % 20 == 0 ) {
      if( clovers > 80 ) {
        awardBadge("Magically Delicious ");
      } else {
        awardBadge(clovers + " Clovers ");
      }
    }
  }

  public void hitRock() {
    if( previous != "rock" ) {
      rocks = 0;
    }
    ++rocks;
    previous = "rock";
    if( rocks > 2 ) {
      rockBadges++;
      rocks = 0;
    }
  }

  public void hitEnemy() {
    ++enemies;
    previous = "enemy";
    if( enemies % 10 == 0 ) {
      if( enemies > 60 ) {
        awardBadge( "Unbreakable " );
      } else {
        awardBadge("Hit " + enemies + " times ");
      }
    }
  }

  public void hitCupcake() {
    ++cupcakes; // Check if under 3 when return badges
    previous = "cupcake";
  }

  public List<Badge> getBadges() {
    if( cupcakes < 3 ) {
      awardBadge("Master Avoider ");
    }
    if( rockBadges > 0 ) {
      awardBadge("Turkey x " + rockBadges + " ");
    }
    cupcakes = 0;
    return badges;
  }

  private void awardBadge(String title) {
    badges.add(new Badge(title));
  }

}

按照单例设计模式,我们创建了一个private的构造函数,并提供了一个名为getInstance()static方法来管理对这个类单例实例的访问。由于getInstance()static的,所以我们的 Avoider Game 版本中的所有类都可以访问它。

小贴士

在实践中,您应该尽量减少使用关键字static。虽然它在许多情况下非常有用,但过度使用它可能导致设计不佳和难以维护的代码。

为了收集我们所需的所有数据,我们有四个方法:hitClover()hitRock()hitEnemy()hitCupcake()。这些方法将由CloverRockAvatarCupcake类分别调用,以报告碰撞。每个方法跟踪击中次数,设置previous变量,然后确定是否应该颁发徽章。例如,hitClover()首先增加变量clovers,然后将previous设置为Clover。该方法然后检查我们是否刚刚击中了另一个 20 个三叶草。如果是这样,我们使用awardBadge()方法颁发徽章。如果我们击中了 80 多个三叶草,我们将颁发大奖——一个神奇美味徽章。

awardBadge()方法用于记录徽章。它接受一个String,该字符串将用作成就的标题,创建一个新的Badge,然后将该徽章存储在数组中。稍后,AvoiderGameOverWorld将通过getBadges()方法访问这个数组。getBadges()方法除了返回到目前为止获得的徽章数组外,还有其他一些职责。它查看hitCupcakes()hitRock()维护的值,并确定是否应该颁发额外的徽章。如果您不知道,术语turkey来自保龄球,意味着您连续击中了三次。

以下是我们需要添加到AvoiderGameOverWorld构造函数中的代码,以便在游戏结束屏幕上显示徽章:

public AvoiderGameOverWorld() {
  super(600, 400, 1);

  List<Badge> badgeList = BadgeCenter.getInstance().getBadges();
  int yPos = 130;
  while(!badgeList.isEmpty()) {
    Badge nextBadge = badgeList.remove(0);
    addObject(nextBadge, 60, yPos);
    yPos += 70;
  }

}

在构造函数中,我们使用BadgeCenter.getInstance()来获取对BadgeCenter单例实例的访问,然后立即调用getBadges()。然后我们遍历badgeList(我们在第三章中讨论了List接口,碰撞检测)并将每个Badge添加到世界中。我们使用变量yPos来适当地分隔徽章。

由于我们使用了List接口,我们需要将以下import语句添加到AvoiderGameOverWorld中:

import java.util.List;

最后,我们需要定义Badge类。这是一个简单的类,它将文本字符串添加到徽章的图像上。图 7显示了我为徽章创建的图像。我尽量让它看起来像一块墓碑。

成就徽章

图 7:这是与Badge类相关的图像。

创建一个新的Actor子类,命名为Badge。分配您为徽章创建的新图像或使用我的。将以下代码添加到其中:

import greenfoot.*;
import java.awt.Color;

public class Badge extends Actor {
  GreenfootImage bkg;
  GreenfootImage msg;

  public Badge(String s) {
    bkg = getImage();
    msg = new GreenfootImage(s, 14, Color.white, null);
    bkg.drawImage(msg, 10, 20);
    setImage(bkg);
  }

}

我们已经在前面章节中讨论了ColorGreenfootImage类。构造函数使用GreenfootImagedrawImage()方法在另一个图像上绘制一个图像。通过这样做,我们有效地添加了文本。

编译代码,调试任何错误,并尝试运行。你获得了哪些徽章?

玩家条件

我想给你提供一个例子,其中我添加到游戏中的某些用户条件在游戏测试后必须被移除,因为我确定它导致了错误的行为。最初,我想将游戏改为每次击中三叶草得 10 分。这将有助于增加游戏的复杂性,因为玩家必须平衡获得更多分数与三叶草的减速惩罚。

然而,这个改变确实对玩家产生了影响。因为三叶草非常多,价值 10 分,所以收集三叶草作为一种获得高分的方式变得非常有价值。这改变了游戏的整体感觉,从鼓励避免转变为鼓励收集。因此,这个改变被移除了。

此外,给三叶草加分破坏了游戏的一些故事元素。在游戏中,看起来好的东西是坏的。但是,通过让一个好的物品为玩家提供直接的好处,我们破坏了游戏的主题。接下来,我们将讨论避免者游戏的主题和讲故事。

讲故事

为像我们这样的游戏构建一个深刻、有意义的故事是困难的。然而,玩家仍然会试图理解我们扔给他们的世界,我们需要尽我们所能帮助玩家构建一个有意义的世界。我们的故事应该激励我们为什么逃离笑脸和纸杯蛋糕,而欢迎岩石。

添加故事屏幕

为了帮助讲述我们游戏的故事,我们将创建一个玩家可以选择在玩游戏之前查看的故事屏幕。这个屏幕将提供我们游戏的背景和上下文,以及设定其主题。在此期间,我们还将添加一个信用屏幕。在代码中添加注释以感谢艺术家和开发者是一个好的开始,但最终您需要正式认可这些人。此外,您可以给在这款游戏上工作最努力的人以认可——那就是你!

首先,我们将在介绍屏幕上添加一些按钮,点击这些按钮将带您进入故事屏幕、信用屏幕或开始游戏。以下是我们需要对AvoiderGameIntroScreen进行的更改:

import greenfoot.*; 

public class AvoiderGameIntroScreen extends World {
  Actor startButton, creditButton, storyButton;

  public AvoiderGameIntroScreen() {  
    super(600, 400, 1); 
    startButton = addButton("Start Game", getWidth()/2, getHeight()*2/3);
    creditButton = addButton("Credits Screen", getWidth()/2, (getHeight()*2/3)+40);
    storyButton = addButton("Story Screen", getWidth()/2, (getHeight()*2/3)+80);
  }

  public void act() {
    if( Greenfoot.mouseClicked(startButton) ) {
      AvoiderWorld world = new AvoiderWorld();
      Greenfoot.setWorld(world);
    } else if( Greenfoot.mouseClicked(creditButton) ) {
      AvoiderGameCreditScreen world = new AvoiderGameCreditScreen();
      Greenfoot.setWorld(world);
    } else if( Greenfoot.mouseClicked(storyButton) ) {
      AvoiderGameStoryScreen world = new AvoiderGameStoryScreen();
      Greenfoot.setWorld(world);
    }
  }

  private Actor addButton(String s, int x, int y) {
    Actor button = new Label(s, 24);
    addObject(button, x, y);
    return button;
  }
}

构造函数使用addButton()方法创建三个按钮。在act()方法中,我们只是监听鼠标点击这些按钮,并在收到点击时相应地切换世界。我们已经在第一章中涵盖了所有这些内容,让我们直接进入…,除了addButton()的实现。

addButton()方法创建一个新的Label并将其添加到屏幕上。我们在本章前面讨论了Label类。

运行此代码后,你的介绍屏幕将看起来像图 8中所示的那样。

添加故事屏幕

图 8:这是我们版本 Avoider 游戏的修订版介绍屏幕

现在,我们只需要制作故事屏幕(AvoiderGameStoryScreen)和信用屏幕(AvoiderGameCreditScreen)。你已经学到了如何做这件事(参考第一章,让我们直接进入…),我只会向你展示我的屏幕在图 9图 10中的样子。你可以自由使用我的或者自己制作。

添加故事屏幕

图 9:这是我们故事屏幕的显示效果

这是信用屏幕的显示效果。

添加故事屏幕

图 10:这是信用屏幕的显示效果

改变分数

在游戏中获得分数是好事;因此,我们的分数指示器应该看起来像坏事。目前,它是中性的,并不有助于游戏的故事或主题。让我们改变这个叙述描述符。

目前,我们正在使用导入的Counter类。为了使我们的分数看起来更定制化,我们需要创建自己的类,而不是依赖于Counter类。创建一个新的Actor子类,命名为Score。因为分数和徽章都是奖励的类型,我觉得将它们都做成墓碑是有意义的。图 11显示了用于新Score演员的图像。它是用于成就徽章的墓碑的小版本,将成就徽章文本替换为R.I.P

改变分数

图 11:这是我们新Score类的图像。

这是Score类的代码:

import greenfoot.*;
import java.awt.Color;

public class Score extends Actor{
  Label msg;
  int counter = 0;

  public Score() {
    msg = new Label("0", 24, Color.black);
  }

  protected void addedToWorld(World w) {
    w.addObject(msg, getX(), getY() + 5);
  }

  public void addScore(int i) {
    counter = counter + i;
    updateImage();
  }

  public int getScore() {
    return counter;
  }

  private void updateImage() {
    getWorld().removeObject(msg);
    msg = new Label(Integer.toString(counter), 24, Color.black);
    getWorld().addObject(msg, getX(), getY() + 5);
  }

}

这个类将当前分数存储在counter整型变量中。你可以通过调用addScore()来增加分数,通过调用getScore()来获取当前分数。Score类通过在默认图像(墓碑)上添加包含当前分数的图像来工作。每当分数改变时,counter变量会增加,然后调用updateImage()updateImage()方法会移除包含分数图像的旧对象,然后根据counter的当前值使用Label类(如前所述)创建一个新的图像。addedToWorld()方法用于显示初始分数 0。

我们已经做了几项更改。请确保编译并运行你的游戏,以确保一切正常工作。

添加音效

音效可以为玩家提供重要且有价值的反馈。它们还可以作为重要的叙述描述符。在本节中,我们将添加一些音效,以增强有意义的游戏玩法和游戏故事。

由于纸杯蛋糕、三叶草和石头具有随机和有限的寿命,知道你是否击中了一个或它就在你接触之前过期可能会很困惑。如果你已经处于满血状态,你根本不知道你是否击中了石头。如果你不动,你无法判断你是否真的击中了纸杯蛋糕或三叶草。我们将通过在每次你与任何PowerItems相撞时播放声音来解决这种歧义。这也有助于那些积极尝试获得成就徽章的玩家。

我们将选择适合我们游戏故事情节的声音。如果玩家与纸杯蛋糕或三叶草相撞,我们将播放你说“Woot!”的声音。如果你,玩家,与石头相撞,我们将播放你说“Ahhh!”的声音。这与游戏的主题相符。

你可以播放你在互联网上找到的任何声音效果(假设它适用于此类用途是免费的)或使用各种音频编辑程序创建自己的声音。幸运的是,Greenfoot 自带内置的音频录制和编辑工具。要访问它,请点击主菜单中的控制,然后选择显示声音录制器。你应该会看到图 12中显示的窗口。

添加音效

图 12:这是 Greenfoot 中的声音录制器工具

使用 Greenfoot 的声音录制器,录下你说“ahhh”并保存为ahhh.wav。然后,录下你说“woot”并保存为woot.wav。为了使你的录音尽可能简洁,你可以使用裁剪到选择按钮来消除任何开始或结束的沉默或不必要的噪音。

我们将向Avatar类添加两种方法来播放这些声音。这是第一个方法:

public void sayAhhh() {
  ahhh.play();
}

这是第二个方法:

public void sayWoot() {
  woot.play();
}

我们在Avatar类的顶部创建了两个实例变量,wootahhh

private GreenfootSound woot;
private GreenfootSound ahhh;

AddedToWorld()方法中初始化它们。以下是初始化代码:

woot = new GreenfootSound("sounds/woot.wav");
ahhh = new GreenfootSound("sounds/ahhh.wav");

我们现在已经设置了Avatar来说出“ahhh”或“woot!”。我们只需要将PowerItems更改为调用我们刚刚添加到Avatar类中的两种方法之一。在Cupcake类中,将checkHitAvatar()更改为以下内容:

protected void checkHitAvatar() {
  Avatar a = (Avatar) getOneIntersectingObject(Avatar.class);
  if( a != null ) {
    bc.hitCupcake();
    a.sayWoot();
    a.stun();
    getWorld().removeObject(this);
  }
}

Clover类中,我们需要将checkHitAvatar()更改为以下内容:

protected void checkHitAvatar() {
  Avatar a = (Avatar) getOneIntersectingObject(Avatar.class);
  if( a != null ) {
    bc.hitClover();
    a.sayWoot();
    a.lagControls();
    getWorld().removeObject(this);
  }
}

Health类中,我们需要将checkHitAvatar()更改为以下内容:

protected void checkHitAvatar() {
  Avatar a = (Avatar) getOneIntersectingObject(Avatar.class);
  if( a != null ) {
    bc.hitRock();
    a.sayAhhh();
    a.addHealth();
    getWorld().removeObject(this);
  }
}

编译你的游戏并播放。享受所有的wootingahhhing

游戏测试

测试你的游戏,看看你是否认为游戏需要任何更改。玩了一段时间后,我决定将AvoiderWorld中的increaseLevel()方法改为以下内容:

private void increaseLevel() {
  int score = scoreBoard.getScore();

  if( score > nextLevel ) {
    enemySpawnRate += 4;
    enemySpeed++;
    cupcakeFrequency += 3;
    cloverFrequency += 3;
    healthFrequency += 1;
    nextLevel += 50;
  }
}

我觉得屏幕上的敌人不够多,而且玩家提升了几级之后,Health增益物品出现的频率太高。为了解决这个问题,我将enemySpawnRate变量的变化率提高到4,并将healthFrequency变量的增加率降低到1。这些变化使得游戏体验感觉更好。你同意我的这些改动吗?你觉得哪些改动能改善游戏?当然,确定这些变量适当值的最有效方法是招募更多的测试玩家。

小贴士

查阅en.wikipedia.org/wiki/Balance_(game_design)获取有关游戏平衡的更多信息。

挑战

我们在我们的 Avoider Game 版本中添加了一些不错的功能。当然,似乎有无数酷炫的事情我们可以尝试来实现以改善我们的游戏。作为一个编程挑战,让我们再尝试一个。改变游戏,使得玩家每获得一个成就徽章就能获得额外的分数。这个改动将进一步鼓励用户尝试成就。这也可能增强了长期的有意义的游戏体验。你打算为每个徽章分配多少分数?每个徽章的分数是否相同?这对游戏体验有何影响?你会保留这个改动吗?

让我们美化我们的介绍屏幕。添加背景音乐,当玩家点击菜单选项时播放一个点击声音。为玩家动作提供听觉反馈可以增强这些交互。

额外阅读

我的游戏设计哲学的大部分内容来自多年的游戏开发教学和两本关键教科书。第一本教科书是 Eric Zimmerman 和 Katie Salen 合著的《游戏规则》(Rules of Play),于 2003 年出版。这本书深入全面地涵盖了游戏、游戏历史和游戏设计。另一本对我影响很大的书是 Jesper Juul 的《半真实》(Half-real),于 2005 年出版。这本书对游戏设计进行了更简洁的研究。如果你对游戏设计有热情,我强烈推荐你阅读这两本书。

摘要

为游戏设计师来说,创造有意义的游戏体验是主要目标。在本章中,我们定义了有意义的游戏体验,并学习了多种增强有意义的游戏体验的游戏设计技巧。我们还学习了一个创建交互式应用程序的过程,这将指导你创建引人入胜的应用程序。随着你阅读本书的其余部分,并最终创建自己的应用程序,你应该回顾本章并应用所提供的技术。

在下一章中,你将学习为我们的游戏创建世界,这些世界将远远超出屏幕的边缘。

第六章。滚动和映射世界

"你已经知道你需要什么。"
--尤达大师

在 Greenfoot 中,我们可以构建比单个屏幕范围更大的世界。通过滚动技术和动态演员生成,我们可以构建侧滚动游戏和看似无限的世界地图。当你将这些大型世界与上一章中学到的技术结合起来时,你将为你的观众创造一个真正吸引人和沉浸式的体验。

本章中你将学习的技术与你在第二章“动画”中学到的技术相似。我们将使用错觉。通过非常简单的技术,我们可以给人一种用户正在穿越广阔世界的错觉。在本章中,你将学习创建以下类型的世界:

  • 动态生成

  • 映射

  • 基于瓦片

随着你对 Greenfoot 和 Java 编程了解的深入,你会注意到重复的模式和方法来完成特定的任务。虽然这里展示的内容值得拥有自己的章节,但它实际上是你已经学到的内容的精心混合。

章节场景示例

在本章中,我们将探讨四种不同的方法来创建用于模拟、游戏或动画的大型世界。我们只将展示完成这一目标所需的代码,而不会进一步展开。你应该将在这里学到的方法与上一章中介绍的主题结合起来,以开发完整的应用程序。此外,在我们的场景中,我们将使用非常简单的图形,并假设你会在艺术和故事(如第五章交互式应用程序设计和理论中所述)上花费更多时间。

动态生成世界

看起来,创建动态生成且可能无限的世界应该是本章的结论性话题,而不是入门话题。相反,动态创建一个世界是很容易的,我们已经看到了完成这一目标所需的所有代码。在第一章,“让我们直接进入…”,我们动态创建了从顶部流下来的敌人,后来,在第二章动画中,我们添加了一个动态生成的星系。我们将使用相同的技巧来创建一个看似无限的世界。想象一下,Avoider Game 中的敌人生成频率较低,看起来像行星。这会让人感觉我们正在穿越太空。想象一下,我们有一个绿色的背景,使用树木的图片作为敌人。这会让人感觉我们正在穿越森林。接下来,我们将创建一个 Greenfoot 场景,显示一个用户控制的火箭在多云的天空中飞行。

侧滚动

我们将创建图 1中描述的飞行游戏。在这个游戏中,用户控制火箭并试图避开墙壁。为什么是墙壁?好吧,它们很容易绘制并且足以说明概念。在你的场景中,你可以花些时间绘制外星飞船、鸟、气球或其他对你有意义的任何东西。

侧滚动

图 1:这是 Clouds Greenfoot 场景的截图。

首先创建一个新的 Greenfoot 场景,命名为Clouds,并将其保存到磁盘上。我们将接下来展示世界和角色的代码。

Rocket 类

在这个示例场景中,用户控制一个火箭。你可以通过按箭头键来移动火箭。火箭的移动被限制在屏幕区域内。如果火箭撞到墙壁(我们很快会添加),则场景将停止。Rocket类中没有特定于生成动态世界的代码,所有代码都是我们在前几章中看到的。创建一个新的Actor子类,命名为Rocket,将其与 Greenfoot 提供的火箭图像关联起来,并在其类文件中输入以下代码:

import greenfoot.*;

public class Rocket extends Actor {
  private int speedX = 1;
  private int speedY = 0;
  private static final int SPEED = 2;
  private static final int BOUNDARY = 20;

  public void act() {
    handleKeyPresses();
    boundedMove();
    checkForCrash();
  }

  private void handleKeyPresses() {
    handleArrowKey("down", 0, SPEED);
    handleArrowKey("up", 0, -SPEED);
    handleArrowKey("left", -SPEED, 0);
    handleArrowKey("right", SPEED, 0);
  }

  private void handleArrowKey(String k, int sX, int sY) {
    if( Greenfoot.isKeyDown(k) ) {
      speedX = sX;
      speedY = sY;
    }
  }

  private void boundedMove() {
    int newX = Math.max(BOUNDARY, speedX+getX());
    newX = Math.min(getWorld().getWidth()-BOUNDARY, newX);
    int newY = Math.max(BOUNDARY, speedY+getY());
    newY = Math.min(getWorld().getHeight()-BOUNDARY, newY);
    setLocation(newX,newY);
  }

  private void checkForCrash() {
    Actor w = getOneIntersectingObject(Obstacle.class);
    if( w != null ) {
      Greenfoot.stop();
    }
  }
}

你应该非常熟悉处理按键和移动角色的代码。我在这里增加的一个额外概念是功能分解,用于去除代码冗余。注意handleArrowKey()方法可以处理所有箭头键的移动。checkForCrash()方法的代码只是简单地实现了我们的标准模板来检测碰撞。我们很快将添加Obstacle角色。

boundedMove()中,我们有一些代码,用于确保用户不会离开屏幕。如果没有这段代码,用户可以朝任何方向离开屏幕并消失在视野中。使用 Java 的max()min()数学库函数,boundedMove()确保火箭的新xy位置保持在屏幕范围内。BOUNDARY变量定义了火箭可以靠近边缘的距离。我们添加这个缓冲区以防止火箭的大部分图像隐藏在边缘之外。

CloudsWorld 类

我们的世界类的主要责任是在屏幕上最初放置火箭,并随机生成云和墙壁。创建一个新的World子类,命名为CloudsWorld,并为其分配一个纯蓝色图像作为背景。你可以使用我们在第四章“Projectiles”中使用的蓝色渐变背景,或者使用你喜欢的绘图程序创建一个新的。与Rocket类一样,CloudsWorld的大多数代码应该是之前提供的代码的复习。以下是CloudsWorld的代码:

import greenfoot.*;

public class CloudsWorld extends World {

  public CloudsWorld() {
    super(600, 400, 1, false);
    prepare();
  }

  public void act() {
    generateBackgroundClouds();
    generateWalls();
  }

  private void generateBackgroundClouds() {
    generateActor(5, new Cloud1());
    generateActor(4, new Cloud2());
    generateActor(3, new Cloud3());
  }

  private void generateWalls() {
    generateActor(5, new Wall());
  }

  private void generateActor(int chance, Actor a) {
    if( Greenfoot.getRandomNumber(1000) < chance) {
      int randY = Greenfoot.getRandomNumber(300) + 50;
      addObject(a, getWidth()+20, randY);
    }
  }

  private void prepare(){
    Rocket rocket = new Rocket();
    addObject(rocket, 90, 200);
  }
}

你还记得我们最新版本的 Avoider 游戏中的act()方法的样子吗?下面是这个样子:

// NOTE: DO NOT PUT THIS CODE IN YOUR CLOUDSWORLD CLASS
public void act() {
  generateEnemies();
  generateStars(-1);
  generatePowerItems();
  increaseLevel();
}

它看起来不像是CloudsWorldact()方法吗?我们将使用在 Avoider 游戏中生成敌人的相同技术来在 Clouds 应用程序中生成云。

让我们先看看generateActor()方法。该方法接受一个角色(类型Actor)和一个整数(类型int)作为参数。整数代表我们将提供的角色添加到世界中的概率。数字越高,角色出现在屏幕上的可能性就越大。使用此方法,我们可以轻松实现generateBackgroundClouds()方法和generateWalls()方法。在这些方法中,我们简单地调用generateActor(),提供角色出现在屏幕上的机会以及所需角色的一个新实例。

侧滚动角色

我们场景中的所有其他角色都将成为SideScrollingActor类的子类。通过从Actor类派生来创建它,但不要与它关联任何图像。使用以下代码,我们通过继承为一系列角色提供侧滚动行为:

import greenfoot.*;

public abstract class SideScrollingActor extends Actor
{
  public int speed = -1; // Moves right to left
  private static final int BOUNDARY = 100;

  public void act()
  {
    move(speed);
    checkOffScreen();
  }

  private void checkOffScreen() {
    if( getX() < -BOUNDARY || getX() > getWorld().getWidth() + BOUNDARY) {
      getWorld().removeObject(this);
    }
  }
}

为了产生我们的火箭从左向右移动的错觉,我们让所有滚动角色从右向左移动。这就是为什么速度变量是负数的原因。在act()方法中,我们移动角色,然后调用checkOffScreen()来在角色移出屏幕后将其移除。因为我们从不打算直接使用SideScrollingActor类来实例化对象,所以我们将其设置为abstract。接下来,我们将讨论将要成为SideScrollingActor子类的角色。

云朵

我们在我们的应用程序中使用三种不同的云朵图像,并将使它们以不同的随机速度移动。这将提供足够的多样性,使我们的飞行火箭看起来更真实。我使用的三个图像显示在图 2中。你可以绘制自己的或提供的那些,在www.packtpub.com/support

云朵

图 2:这些是云朵的图像

通过从SideScrollingActor派生,命名为Cloud1,并将你的云朵图像之一分配给它来创建一个云朵角色。在Cloud1的类文件中,放置以下代码:

import greenfoot.*; 

public class Cloud1 extends SideScrollingActor {
  private static final int SPEEDRANGE = 3;
  public Cloud1() {
    speed = -(Greenfoot.getRandomNumber(SPEEDRANGE) + 1);
  }
}

Cloud1中,我们给speed变量分配一个介于13之间的随机值。我们继承了speed变量来自SideScrollingActor父类。

要创建另外两个云朵角色,重复前面的步骤一次,将Cloud1替换为Cloud2,然后再将Cloud1替换为Cloud3。为了增加更多变化,你可以更改每个角色中的SPEEDRANGE常量。我建议将Cloud1SPEEDRANGE设置为3(如图 2 所示),Cloud22Cloud35

墙壁

我们需要添加的最后一件事情是墙壁障碍物。虽然在这个例子中我们只有一个障碍物,但我们将要编写的代码将使我们能够轻松地在未来添加更多的障碍物。我们将使用继承,但这次,我们使用它来分组相关类型,而不是共享代码。在Rocket Actor代码中,我们检查与Obstacle类的碰撞。现在我们将通过从SideScrollingActor派生来创建这个类,将新的子类命名为Obstacle,并且不关联任何图像。以下是Obstacle角色的代码:

import greenfoot.*;

public class Obstacle extends SideScrollingActor{
}

再次强调,我们不是使用继承来重用代码,因此需要添加的代码非常少。

现在,为了创建Wall角色,我们创建了一个Obstacle的子类。我简单地为我墙壁创建了一个深灰色矩形图像。我相信你能想出更好的东西。以下是Wall类的代码:

import greenfoot.*; 
public class Wall extends Obstacle {
}

由于ObstacleSideScrollingActor继承,Wall角色将与Cloud角色具有相同的移动方式。然而,Rocket类现在可以检测与Obstacle类的碰撞。如果我们使用SideScrollingActor类进行碰撞检测,那么我们也会与云发生碰撞。

试一试

我们已经完成了为我们的 Greenfoot 场景创建世界和角色类。图 3展示了完成的 Greenfoot 场景。确保你的类层次结构完全相同。

试一试

图 3:这显示了完成的云场景

编译它,并注意你创建过程中的任何错误。花些时间运行场景,观察移动的云如何产生穿越广阔天空的错觉。即使知道它是如何实现的,也很难想象你的火箭实际上是屏幕上移动最少的演员。

映射世界

肯定有你想为你的游戏或模拟添加特定背景的时候。在这些情况下,仅仅随机生成演员来模拟移动背景是不够的。我们接下来要探索的方法包括创建一个比屏幕尺寸大得多的背景图像,并适当地移动它来模拟运动。此外,我们还将学习如何在这个更大的世界中放置演员。

侧滚动

我们的侧滚动示例是一个允许用户穿越山林,最终找到湖的场景。用户只能左右移动,不能上下移动。图 4展示了完成的应用。

侧滚动

图 4:这是 HikingWorld 的截图

要创建这个滚动世界,我们需要一张大图作为背景。在这个例子中,我创建了一张 2400 x 400 像素的图像,如图 5 所示。由于我们的场景的可视屏幕大小为 600 x 400,所以这张图像比我们的屏幕长六倍。您可以自由地创建自己的 2400 x 400 像素图像,或者使用提供的www.packtpub.com/support上的图像。

侧滚动

图 5:这是 HikingWorld 的背景图像,长 2400 像素,高 400 像素。注意,这个图中的图像是原始大小的四分之一,以便适应页面

接下来,我们将描述世界和演员类的代码。

HikingWorld 类

我们的世界类HikingWorld的主要责任是将世界中的所有内容相对于用户控制的Hiker类进行移动。我们将允许用户在屏幕的范围内正常移动,但当用户尝试移动到屏幕的左侧边界或右侧边界之外时,我们将分别将一切移动到右侧或左侧。图 6 演示了如果用户在屏幕的右侧边缘并尝试向右移动时,我们会做什么。

HikingWorld 类

图 6:如果玩家在屏幕的右侧边缘向右移动,我们将一切向左移动

现在我们已经了解了HikingWorld类必须做什么,让我们看看代码。首先,从World类派生出一个子类,并将其命名为HikingWorld。不要将图像与这个新类关联;我们将在构造函数中做这件事。以下是HikingWorld类必须执行的代码:

import greenfoot.*;
import java.util.List;

public class HikingWorld extends World {
  private int xOffset = 0;
  private final static int SWIDTH = 600;
  private final static int SHEIGHT = 400;
  private final static int WWIDTH = 2400;
  private GreenfootImage bimg;

  public HikingWorld() {
    super(SWIDTH, SHEIGHT, 1, false);
    bimg = new GreenfootImage("HikingWorldBackground.png");
    shiftWorld(0);
    prepare();
  }

  public void shiftWorld(int dx) {
    if( (xOffset + dx) <= 0 && (xOffset + dx) >= SWIDTH - WWIDTH) {
      xOffset = xOffset + dx;
      shiftWorldBackground(dx);
      shiftWorldActors(dx);
    }
  }

  private void shiftWorldBackground(int dx) {
    GreenfootImage bkgd = new GreenfootImage(SWIDTH, SHEIGHT);
    bkgd.drawImage(bimg, xOffset, 0);
    setBackground(bkgd);
  }

  private void shiftWorldActors(int dx) {
    List<ScrollingActor> saList =
    getObjects(ScrollingActor.class);
    for( ScrollingActor a : saList ) {
      a.setAbsoluteLocation(dx);
    }
  }

  private void prepare() {
    HedgeHog hh1 = new HedgeHog();
    addObject(hh1, 900, 250);
    Lemur l = new Lemur();
    addObject(l, 1200, 300);
    HedgeHog hh2 = new HedgeHog();
    addObject(hh2, 1500, 250);
    Lake lake = new Lake();
    addObject(lake, 2100, 300);
    Hiker hiker = new Hiker();
    addObject(hiker, 90, 275);
  }
}

在类开始时,我们创建了三个常量来存储屏幕的尺寸(SWIDTHSHEIGHT)和背景图像的尺寸(WWIDTH)。由于图像高度和屏幕高度相同,我们不需要WHEIGHT常量。我们还声明了xOffset实例变量。我们使用这个变量来跟踪背景图像和演员当前移动的距离。最后,我们创建了一个实例变量bimg,指向背景图像。

在构造函数中,我们加载我们的背景图像,并使用偏移量0调用shiftWorld(),将一切放置在其起始位置。我们以标准方式使用prepare()方法——放置我们的初始演员。需要注意的一点是,我们使用大于屏幕大小的y位置。因此,一些我们的演员将被创建,但放置在屏幕之外。最终,它们将被移动到屏幕上,用户可以看到。使这个世界变大的真正工作是在shiftWorld()中完成的。

注意shiftWorld()方法中的第一个if语句。这个if语句防止我们将背景图像移动到我们看到它后面的空白白色空间的位置。

如果我们不在背景图像的边缘,那么我们将通过将当前的偏移量(dx)加到当前的偏移量(xOffset)上来记录新的偏移量。然后,我们使用shiftWorldBackground()方法移动背景图像,并使用shiftWorldActors()方法移动世界中的所有角色。shiftWorldBackground()方法相当简单。我们首先创建一个与屏幕大小相同的新图像。然后我们将背景图像绘制到其中,偏移量为xOffset(刚刚增加了dx),然后将这个新图像设置为背景图像。

shiftWorldActors()方法可能只有几行,但它做了很多工作。有一个名为getObjects()World方法提供给我们,它将返回所提供类中的所有角色。对我们来说,我们调用getObjects(ScrollingActor.class)来获取所有应该移动的对象。用户控制的类Hiker不是ScrollingActor的子类;因此,在这个方法中它不会被移动。然后我们遍历返回的 Java List,并对每个ScrollingActor实例调用setAbsoluteLocation()。我们很快将查看ScrollingActor类和setAbsoluteLocation()方法的实现。

创建侧滚动世界的绝大部分工作都在HikingWorld中完成。在继续前进之前,请确保你理解这段代码。这个场景中剩余角色的代码相当直接。

Hiker

我们将有一个Hiker类的实例,用户将控制它。在这个场景中,我们只允许用户左右移动。这种程度的控制足以演示侧滚动场景。这个类的代码几乎与我们在本章开头创建的 Clouds 场景中的Rocket角色的代码相同。首先,看看代码,然后我们将讨论差异:

import greenfoot.*;

public class Hiker extends Actor
{
  private int speedX = 1;
  private static final int SPEED = 2;
  private static final int BOUNDARY = 40;

  public void act() {
    handleKeyPresses();
    boundedMove();
    checkAtLake();
  }

  private void handleKeyPresses() {
    handleArrowKey("left", -SPEED);
    handleArrowKey("right", SPEED);
  }

  private void handleArrowKey(String k, int sX) {
    if( Greenfoot.isKeyDown(k) ) {
      speedX = sX;
    }
  }

  private void boundedMove() {
    if( speedX+getX() <= BOUNDARY ) {
      setLocation(BOUNDARY, getY());
      ((HikingWorld)getWorld()).shiftWorld(-speedX);
    } else if( speedX+getX() >= getWorld().getWidth()-BOUNDARY ) {
      setLocation(getWorld().getWidth()-BOUNDARY, getY());
      ((HikingWorld)getWorld()).shiftWorld(-speedX);
    } else {
      setLocation(getX()+speedX, getY());
    }
    speedX = 0;
  }

  private void checkAtLake() {
    // Do something cool if make it to the lake...
  }
}

主要区别发生在boundedMove()方法中。在前面提到的Rocket类中,我们有一个类似的方法,它将用户移动限制在比屏幕略小的矩形区域内。我们在这里对水平移动做同样的事情,但增加了一个功能。当我们检测到用户位于屏幕边缘(无论是左侧还是右侧)时,我们将调用shiftWorld()来使其看起来像角色正在继续移动。

我们还有一个没有实现的checkAtLake()方法。这是你可以在世界的尽头设置一个目标,用户必须到达的例子。在我们的案例中,我们将在徒步旅行的尽头放置一个湖。如果我们想在用户到达湖边后做些什么,我们会使用这个方法。

ScrollingActor

我们想要移动的所有角色都将继承自ScrollingActor类。它提供了一个方便的方式来分组这些角色,并允许我们在一个地方定义setAbsoluteLocation()方法。下面是如何实现的:

import greenfoot.*;

public class ScrollingActor extends Actor {
  public void setAbsoluteLocation(int dx) {
    setLocation(getX()+dx, getY());
  }
}

HikingWorld类中,我们对所有需要移动的演员调用了setAbsoluteLocation()方法。这个方法只是setLocation()的一个包装,通过dx值移动演员。

HedgeHogLemurLake演员的代码相同且非常简洁。这些类主要存在是为了允许将不同的图片与它们关联。刺猬和狐猴的图片包含在 Greenfoot 的默认安装中。我的湖是一个简单的蓝色椭圆形,在绘图程序中创建。这些演员在HikingWorldprepare()方法中被放置到世界中。现在创建每个这些演员,并将以下代码添加到每个演员中(确保用适当的类名替换):

import greenfoot.*;

public class HedgeHog extends ScrollingActor {
}

尝试运行

恭喜!您已经创建了一个横向卷轴、已映射的世界。编译并尝试运行它。为了增加挑战,实现Hiker类中的checkAtLake()方法,以奖励徒步旅行者到达目的地。您还可以在www.packtpub.com/support下载该场景的完整版本。

2D 滚动

创建一个允许用户在x(左右)和y(上下)方向上探索大地图的应用程序,是我们在刚刚创建的横向卷轴世界的一个简单扩展。代码将完全相同,除了我们还将处理上下移动的情况。我们还需要一个比我们场景的屏幕尺寸更长更宽的图片。我创建的图片是 1200 x 1200 像素,并在图 7中展示。您可以创建自己的图片或从www.packtpub.com/support下载图 7中的图片。这张图片旨在表示一个带有树木的地形的俯视图。

2D 滚动

图 7:HikingWorld2D 的背景图片,长宽均为 1200 像素。请注意,本图中的图片已被缩小以适应页面。

创建一个新的场景并命名为HikingWorld2D。由于此代码与我们在上一节中实现的HikingWorld场景非常相似,我们只需突出显示处理上下移动所需的代码。

HikingWorld2D

继承World类并命名新类为HikingWorld2D,但不要为其关联图片。我们将在该类的构造函数中添加图 7(或您创建的类似图片)。以下是完成所有这些任务的代码:

import greenfoot.*;
import java.util.List;

public class HikingWorld extends World {
  private int xOffset = 0;
  private int yOffset = 0;
  private final static int SWIDTH = 600;
  private final static int SHEIGHT = 400;
  private final static int WWIDTH = 1200;
  private final static int WHEIGHT = 1200;
  private GreenfootImage bimg;

  public HikingWorld() {  
    super(SWIDTH, SHEIGHT, 1, false); 
    bimg = new GreenfootImage("HikingWorldBackground2D.png");
    shiftWorld(0,0);    
    prepare();
  }

  public void shiftWorld(int dx, int dy) {
    if( (xOffset + dx) <= 0 && (xOffset + dx) >= SWIDTH - WWIDTH) {
      xOffset = xOffset + dx;
      shiftWorldBackground(dx, 0);
      shiftWorldActors(dx, 0);
    }
    if( (yOffset + dy) <= 0 && (yOffset + dy) >= SHEIGHT - WHEIGHT) {
      yOffset = yOffset + dy;
      shiftWorldBackground(0, dy);
      shiftWorldActors(0, dy);
    }
  }

  private void shiftWorldBackground(int dx, int dy) {
      GreenfootImage bkgd = new GreenfootImage(SWIDTH, SHEIGHT);
      bkgd.drawImage(bimg, xOffset, yOffset);
      setBackground(bkgd);
  }

  private void shiftWorldActors(int dx, int dy) {
    List<ScrollingActor> saList = getObjects(ScrollingActor.class);
    for( ScrollingActor a : saList ) {
      a.setAbsoluteLocation(dx, dy);
    }
  }

  private void prepare() {
    HedgeHog hh1 = new HedgeHog();
    addObject(hh1, 600, 600);
    Lemur l = new Lemur();
    addObject(l, 300, 900);
    HedgeHog hh2 = new HedgeHog();
    addObject(hh2, 900, 300);
    Lake lake = new Lake();
    addObject(lake, 900, 1100);
    Hiker hiker = new Hiker();
    addObject(hiker, 90, 275);
  }
}

首先,我们将WWIDTHWHEIGHT设置为背景图像的尺寸。之前,我们不需要WHEIGHT,因为它与SHEIGHT相同。这个类与HikingWorld类在HikingWorld,中的主要区别是,我们在shiftWorld()shiftWorldBackground()shiftWorldActors()中添加了一个额外的参数(dy),它提供了y方向上的变化。新dy参数的使用反映了dx参数的使用。我们最终通过dxdy同时移动背景图像和其他演员。

漫步者类

创建一个新的Actor子类,命名为Hiker,并关联 Greenfoot 提供的默认人物图像之一。以下是这个新类的代码:

import greenfoot.*;

public class Hiker extends Actor {
  private int speedX = 1;
  private int speedY = 1;
  private static final int SPEED = 2;
  private static final int BOUNDARY = 40;

  public void act() {
    handleKeyPresses();
    boundedMove();
    checkAtLake();
  }

  private void handleKeyPresses() {
    handleArrowKey("left", -SPEED, 0);
    handleArrowKey("right", SPEED, 0);
    handleArrowKey("up", 0, -SPEED);
    handleArrowKey("down", 0, SPEED);
  }

  private void handleArrowKey(String k, int sX, int sY) {
    if( Greenfoot.isKeyDown(k) ) {
      speedX = sX;
      speedY = sY;
    }
  }

  private void boundedMove() {

    if( speedX+getX() <= BOUNDARY ) {
      setLocation(BOUNDARY, getY());
      ((HikingWorld)getWorld()).shiftWorld(-speedX, 0);
    } else if( speedX+getX() >= getWorld().getWidth()-BOUNDARY ) {
      setLocation(getWorld().getWidth()-BOUNDARY, getY());
      ((HikingWorld)getWorld()).shiftWorld(-speedX, 0);
    } else {
      setLocation(getX()+speedX, getY());
    }

    if( speedY+getY() <= BOUNDARY ) {
      setLocation(getX(), BOUNDARY);
      ((HikingWorld)getWorld()).shiftWorld(0, -speedY);
    } else if( speedY+getY() >= getWorld().getHeight()-BOUNDARY ) {
      setLocation(getX(), getWorld().getHeight()-BOUNDARY);
      ((HikingWorld)getWorld()).shiftWorld(0, -speedY);
    } else {
      setLocation(getX(), getY()+speedY);
    }
    speedX = 0;
    speedY = 0;
  }

  private void checkAtLake() {
  }
}

这个类也被增强以处理xy方向上的移动。在handleKeyPresses()中,我们添加了两个额外的handleArrowKey()调用,以处理上下箭头键被按下。在boundedMove()中,我们添加了检查以确保演员不会移动到屏幕的顶部或底部,并在适当的时候调用shiftWorld()

滚动演员类

创建一个新的Actor子类,命名为ScrollingActor。你不需要为它关联一个图像。和之前一样,这个类仅仅提供了一个setLocation()方法的包装。现在,它还处理在y方向上的移动。以下是代码:

import greenfoot.*;

public class ScrollingActor extends Actor {
  public void setAbsoluteLocation(int dx, int dy) {
    setLocation(getX()+dx, getY()+dy);
  }
}

HedgeHogLemurLake类与之前在HikingWorld场景中展示的完全相同。将它们添加到HikingWorld2D中。

尝试一下

只需几处改动,我们就创建了一个值得探索的世界,而我们所做的只是对已经完成的HikingWorld场景进行了一些扩展。现在,是时候编译并尝试你的场景了。处理任何拼写错误或错误,然后探索地图。你还可以在www.packtpub.com/support下载场景的完整版本。

基于瓦片的宇宙

基于瓦片的宇宙是全动态创建的宇宙和那些使用大图像作为背景的宇宙的愉快结合。使用大图像,你可以创建一个非常详细且可预测的世界,但改变它非常困难。动态创建的宇宙容易生成,但通常太随机。基于瓦片的宇宙让你能够轻松创建详细、可预测的世界,同时易于更改或修改。

作为瓦片的演员

艺术家可以使用小块的瓦片或玻璃创造出惊人的图像。图 8展示了一个简单的瓦片马赛克。通过策略性地放置小块彩色瓦片,你可以生成许多不同类型的图像。

作为瓦片的演员

图 8:这是一个简单的马赛克,由 pixabay.com 提供,链接为 http://pixabay.com/en/uzbekistan-mosaic-pattern-artfully-196875/

我们将使用类似的技术在 Greenfoot 中创建世界,但我们将使用小演员而不是瓦片。图 9演示了我们将如何做到这一点。我们将创建一组演员,它们将作为我们的瓦片。然后,我们将指定如何使用一个字符串数组来组合这些演员,该数组使用字母来编码要放置的演员类型。例如,字母C对应于显示蓝色背景的云朵的演员,而字母F对应于显示绿色背景的花的演员。图 9显示了一个 4 x 3 的字母矩阵,用于指定创建最终图像的瓦片布局。在矩阵中,左上角的字母是S;因此,图像的左上角是纯蓝色。

演员作为瓦片

图 9:此图显示了将单个演员映射以创建更大世界的过程

希望你现在对基于瓦片的世界创建的工作方式有了感觉。在接下来的代码片段中,我们将再次编写徒步世界场景的代码,但这次修改为使用基于瓦片的世界创建。大部分代码直接借鉴了我们上一节中构建的 2D 滚动徒步世界。

创建一个新的场景并将其命名为HikingWorldTiled。本节将描述此场景的世界和演员类。我们只突出与基于瓦片的世界创建相关的添加。图 10显示了完成场景的截图。我现在提供这个截图,以便您可以快速查看我们将要实现的全部类,并一瞥我们将要创建的图像的一部分。

演员作为瓦片

图 10:这是完成后的 HikingWorldTiled 场景的截图

HikingWorld

通过继承World类创建HikingWorld。我们正在动态创建一个背景图像,因此你不想将图像与这个类关联;以下是实现此功能的代码:

import greenfoot.*;
import java.util.List;

public class HikingWorld extends World {
  private int xOffset = 0;
  private final static int SWIDTH = 600;
  private final static int SHEIGHT = 400;
  private final static int WWIDTH = 1200;
  private final static int TWIDTH = 25;
  private final static int THEIGHT = 25;

  private final static String[] WORLD = {
    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWUUWWUUWWUUWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWUUWWUUWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWUUUUUUWWUUWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWUUWWUUWWUUWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWUUWWUUWWUUWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWB",
    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
  };

  public HikingWorld() {
    super(SWIDTH, SHEIGHT, 1, false);
    createWorldFromTiles();
    shiftWorld(0);
    prepare();
  }

  public void shiftWorld(int dx) {
    if( (xOffset + dx) <= 0 && (xOffset + dx) >= SWIDTH - WWIDTH) {
      xOffset = xOffset+dx;
      shiftWorldActors(dx);
    }
  }

  private void shiftWorldActors(int dx) {
    List<ScrollingActor> saList =
    getObjects(ScrollingActor.class);
    for( ScrollingActor a : saList ) {
      a.setAbsoluteLocation(dx);
    }
  }

  private void createWorldFromTiles() {
    for( int i=0; i < WORLD.length; i++ ) {
      for( int j=0; j < WWIDTH/TWIDTH; j++ ) {
        addActorAtTileLocation(WORLD[i].charAt(j), j, i);
      }
    }
  }

  private void addActorAtTileLocation(char c, int x, int y) {
    Actor tile = null;
    switch(c) {
      case 'W':
      tile = new WhiteBlock();
      break;
      case 'B':
      tile = new BlackBlock();
      break;
      case 'U':
      tile = new BlueBlock();
      break;
    }
    if( tile != null) 	addObject(tile, 12+x*TWIDTH, 12+y*THEIGHT);
  }

  private void prepare() {
    Lake lake = new Lake();
    addObject(lake, WWIDTH-300, 300);
    Hiker hiker = new Hiker();
    addObject(hiker, 90, 275);
  }
}

这个类的新部分是位于类顶部的字符串数组WORLDcreateWorldFromTiles()方法,它使用addActorAtTileLocation()方法来帮助从现有演员构建世界。《WORLD》数组指定了我们将放置构成背景的每个演员的位置。我们将使用三个演员来创建我们的背景图像;它们是BlackBlockWhiteBlockBlueBlock。这些演员使用 25 x 25 像素的图像。这对于瓦片来说是一个合适的大小——任何更小,你的WORLD数组都会太大且难以管理,任何更大,你将失去创建细节的能力。

WORLD 数组中有十六个字符串,每个字符串包含四十八个字符,因此我们创建的图像大小为 1200(48 x 25)x 400(16 x 25)。字母 B 对应于 BlackBlock 演员字母,字母 W 对应于 WhiteBlock 演员字母,字母 U 对应于 BlueBlock 演员字母。这种映射在 addActorAtTileLocation() 方法中的 switch 语句中被捕获。了解映射后,你可以查看 WORLD 数组,并看到图像将有一个黑色边框和白色背景,并用蓝色拼写出单词 Hi

好的,让我们来分析 createWorldFromTilesMethod()。此方法遍历 WORLD 中每个字符串的每个字符。对于每个字符,它调用 addActorAtTileLocation(),提供参数指定表示哪个瓦片应放置的字符以及该瓦片的位置。在 addActorAtTileLocation() 中,我们根据传递给它的字符创建一个新的演员,然后使用提供的 xy 值将新演员放置在世界上。

Hiker 类

这里的代码与我们在动态创建的世界中查看的 Hiker 类的代码相同。我在这里重新呈现它是为了方便,因为它相对较短:

import greenfoot.*;

public class Hiker extends Actor {
  private int speedX = 1;
  private static final int SPEED = 2;
  private static final int BOUNDARY = 40;

  public void act() {
    handleKeyPresses();
    boundedMove();
    checkAtLake();
  }

  private void handleKeyPresses() {
    handleArrowKey("left", -SPEED);
    handleArrowKey("right", SPEED);
  }

  private void handleArrowKey(String k, int sX) {
    if( Greenfoot.isKeyDown(k) ) {
      speedX = sX;
    }
  }

  private void boundedMove() {
    if( speedX+getX() <= BOUNDARY ) {
      setLocation(BOUNDARY, getY());
      ((HikingWorld)getWorld()).shiftWorld(-speedX);
    } else if( speedX+getX() >= getWorld().getWidth()-BOUNDARY ) {
      setLocation(getWorld().getWidth()-BOUNDARY, getY());
      ((HikingWorld)getWorld()).shiftWorld(-speedX);
    } else {
      setLocation(getX()+speedX, getY());
    }
    speedX = 0;
  }

  private void checkAtLake() {
  }
}

ScrollingActor 类

这段代码也与我们在本章中创建的第一个场景提供的代码相同。在此需要注意的是,我们用来创建世界的演员也可能具有额外的功能,而不仅仅是被动背景图像。例如,你可以有一个火焰瓦片,如果徒步旅行者与之碰撞,就会将其烧毁。以下是我们要讨论的代码:

import greenfoot.*;

public class ScrollingActor extends Actor {
  public void setAbsoluteLocation(int dx) {
    setLocation(getX()+dx, getY());
  }
}

瓦片

BlackBlockBlueBlockWhiteBlock 演员的代码几乎相同。唯一的不同之处在于类的名称和相关的图像。看看 BlackBlock 的代码:

import greenfoot.*;

public class BlackBlock extends ScrollingActor {
}

确保这些演员的图像大小都相同,以便它们容易组合成更大的图像。在我们的例子中,图像是 25 像素 x 25 像素的有色方块。

Lake 类

Lake 类与第一个场景中的相同。需要注意的是,并非所有 ScrollingActor 的子类都必须作为背景图像的瓦片。Lake 演员代表我们的最终目的地。这是 Lake 类的使用方法:

import greenfoot.*;

public class Lake extends ScrollingActor {
}

你可以直接复制它及其相关的图像从上一个场景。

尝试一下

编译场景并运行它。它应该与我们在 Clouds 场景之后提供的侧滚动示例感觉相似,但现在很容易更改世界的图像。在背景中拼写 Hi 的地方,拼写你的名字。作为一个挑战,更改 Hiker 类,使其在接触蓝色方块时游戏结束。

其他游戏精灵

在我们的示例中,瓦片非常简单。基于瓦片的世界创建的真正优势在于拥有广泛的选择,可以创建一个有趣的世界。您可以创建自己的世界,从付费网站下载一些,例如cartoonsmartart.com,或者从 100%免费的网站下载,例如opengameart.org图 11展示了来自opengameart.org的免费瓦片集的一个示例。

其他游戏精灵

图 11:这是来自 opengameart.org 并由 Kenny 在 http://opengameart.org/content/rpg-pack-base-set 提供的免费瓦片图集。

摘要

通过结合本章中介绍的大型世界创建技术,以及其他章节中介绍的概念和技术,您已经完全准备好使用 Greenfoot 创建无限形式的信息、娱乐和沉浸式体验。在下一章中,我们将探讨如何使您的应用程序中的演员智能行为,以进一步增强用户体验。

第七章。人工智能

*"智慧始于惊奇。"
--苏格拉底

我们在本章中已经探讨了如何移动、控制、检测 Greenfoot 演员之间的碰撞以及为这些演员添加动画。在本章中,我们将探讨如何让我们的演员展现出类似智能的行为。这样做将使我们能够讲述更好的故事并创造更具吸引力的用户交互。

现在,人工智能(AI)领域非常复杂,为我们的演员创建真正的智能行为超出了本书的范围。然而,我们可以使用一些简单的技术来模拟各种程度的智能行为,这些技术包括概率和启发式方法。然后我们将探讨一个流行的算法(在许多 AAA 游戏中使用),它将允许一个演员穿过一系列障碍物。具体来说,你将学习如何应用以下内容来模拟智能:

  • 随机性

  • 行为启发式

  • A*(发音为 A-star)路径查找

在整本书中,你一直在学习如何为你的应用程序、动画和游戏增添“哇”的元素。将简单的 AI 技术加入你的技能库将提升你创造力和创造力的能力。你对 Java 编程的了解越深,你就能为你的观众提供越多的惊奇。

MazeWorld 场景

在上一章中,我们学习了如何创建基于瓦片的世界。我们将使用基于瓦片的方法增强我们创建的 Hiking World 场景,以创建一个新的场景,命名为MazeWorld。在这个场景中,我们的英雄需要绕过障碍物并避开三个智能演员,才能到达迷宫尽头的金子。图 1展示了完成后的场景截图。

MazeWorld 场景

图 1:这是 MazeWorld 的完成版本

与上一章的HikingWorld场景相比,本章我们将构建的新的MazeWorld场景有一些显著的不同。我们将快速解释冗余区域,然后放慢速度并详细解释创建智能演员所需的更改。如有需要,请查阅第六章,滚动和映射世界,以获取基于瓦片的世界创建的完整描述。

MazeWorld 类

创建一个新的场景,命名为MazeWorld。在新场景中,创建一个名为MazeWorldWorld类的子类。选择无图像作为此场景的图像。以下是MazeWorld类的实现:

import greenfoot.*;
import java.util.List;
import java.util.Stack;

public class MazeWorld extends World {
  private int xOffset = 0;
  private Hiker hiker;
  private final static int SWIDTH = 600;
  private final static int SHEIGHT = 400;
  private final static int WWIDTH = 1200;
  private final static int TWIDTH = 25;
  private final static int THEIGHT = TWIDTH;
  private final static int TILEOFFSET = TWIDTH/2;
  private final static String validSpaces = "WG";

  private final static String[] WORLD = {
    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWUWWWWB",
    "BWWWWWWWWWWWWWUUWWWWWWWWUUUUUUUWWWWWWWWWWWUWWWWB",
    "BWWWWWUUUUUWWWUUUWWWWWWWWWWWWWWWWWWWWWWWWWUWWWWB",
    "BWWWWWUUUUUWWWWWWWWWWWWWWWWWWWWWWWWWUWWWWUUUWWWB",
    "BWWWWWWWWWWWWWWWWWUUUUUWWWWWWWWUUUUUUWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWUUUUWWWWWWWWWUUUUUUUUWWWWWWWWB",
    "BWWWWUUUUUUUWWWUWWWWWWWWWWWWWWWUWWWWWWWWWWWWWWWB",
    "BWWWWWWWUUUWWWWUWWWWWWWWWWUWWWWUWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWUWWWWWWWWWWWWWWWWWUWWB",
    "BWWWWWWWWWWWWWWWWWWWUUUUUUUWWWWWWWWWUUUUWWWWUWWB",
    "BWWWWWWWWWWWWWUUWWWWUWWWWWWWWWWWWWWWUUUUWWWWUWWB",
    "BWWWWWWWUUUUUUUUUWWWWWWWWWWWWWWWWWWWUUUUUUWWUWWB",
    "BWWWWWWWUUUUUUUUUWWWWWWWWWUUWWWWWWWWWWWWWWWWUWWB",
    "BWWWWWWWUWWWWWWWWWWWWWWWWWUUWWWWWWWWWWWWWWWWUWGB",
    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
  };

  public MazeWorld() {  
    super(SWIDTH, SHEIGHT, 1, false); 
    createWorldFromTiles();
    shiftWorld(0);    
    prepare();
  }

  public void shiftWorld(int dx) {
    if( (xOffset + dx) <= 0 && (xOffset + dx) >= SWIDTH - WWIDTH) {
      xOffset = xOffset+dx;
      shiftWorldActors(dx);
    }
  }

private void shiftWorldActors(int dx) {
    List<ScrollingActor> saList = getObjects(ScrollingActor.class);
    for( ScrollingActor a : saList ) {
      a.setAbsoluteLocation(dx);
    }
  }

  private void createWorldFromTiles() {
    for( int i=0; i < WORLD.length; i++ ) {
      for( int j=0; j < WORLD[i].length(); j++ ) {
        addActorAtTileLocation(WORLD[i].charAt(j), j, i);
      }
    }
  }

  private void addActorAtTileLocation(char c, int x, int y) {
    Actor tile = null;
    switch(c) {
      case 'W':
        tile = new WhiteBlock();
        break;
      case 'B':
        tile = new BlackBlock();
        break;
      case 'U':
        tile = new BlueBlock();
        break;
      case 'G':
        tile = new GoldBlock();
        break;
    }
    if( tile != null) addObject(tile, TILEOFFSET+x*TWIDTH, TILEOFFSET+y*THEIGHT);

  }

 public int getTileWidth() {
    return TWIDTH;
  }

  public int getTileHeight() {
    return THEIGHT;
  }

  public int getTileOffset() {
    return TILEOFFSET;
  }

  public String[] getStringWorld() {
    return WORLD;
  }

  public int getXHiker() {
    return hiker.getX()-xOffset;
  }

  public int getYHiker() {
    return hiker.getY();
  }

  public String getValidSpaces() {
    return validSpaces;
  }

  private void prepare()
  {
    hiker = new Hiker();
    addObject(hiker, 80, 200);
    addObject(new Mouse(), 60,40);
    addObject(new Spider(), 1000,40);
    addObject(new Spider(), 120,340);
    addObject(new Spider(), 1050,250);
    addObject(new Snake(), 1050,250);
    addObject(new Mouse(), 1000,200);
    addObject(new Snake(), 400,260);
  }
}

我们首先声明这个类的所有实例变量。我们添加了TILEOFFSET常量(用于记录地砖宽度值的一半)和String validspaces(用于指示我们的英雄可以走上的地砖)。WORLD数组定义了地砖的类型和在我们世界中的位置。我们通过使用字母U创建各种静态障碍物并添加了通过字母G在右下角的目标目的地来增强WORLD数组。W字符表示可走动的背景区域,而B表示不可通过的区域。

shiftWorldshiftWorldActorscreateWorldFromTiles方法和构造函数与HikingWorld中的相同。addActorAtTileLocation方法只是在switch语句中添加了一个处理创建和放置金币地砖的情况。到达金币地砖是本场景的目标。

以下方法被添加以提供一种方便的方式来访问我们世界中包含的信息:getTileWidthgetTileHeightgetTileOffsetgetStringWorldgetXHikergetYHikergetValidSpaces。我们将在本章定义的类中看到它们的使用。MazeWorld实现中提供的最后一个方法是prepare(),默认情况下用于将初始演员放置到我们的世界中。

漫步者类

我们的Hiker类与我们在上一章中在HikingWorld中看到的是一样的,只不过我们扩展了这个类的上下移动能力。二维移动在之前的章节中已经介绍过,我们将对此类进行总结性解释。以下是代码:

import greenfoot.*;

public class Hiker extends Actor
{
  private static final int SPEED = 2;
  private static final int BOUNDARY = 40;
  private int speedX = SPEED;
  private int speedY = SPEED;

  public void act() {
    handleKeyPresses();
    handleCollisions();
    boundedMove();
  }

  private void handleKeyPresses() {
    handleArrowKey("left", -SPEED, 0);
    handleArrowKey("right", SPEED, 0);
    handleArrowKey("up", 0, -SPEED);
    handleArrowKey("down", 0, SPEED);
  }

  private void handleArrowKey(String k, int sX, int sY) {
    if( Greenfoot.isKeyDown(k) ) {
      speedX = sX;
      speedY = sY;
    }
  }

  private void handleCollisions() {
    if( isTouching(ScrollingEnemy.class) ) {
      Greenfoot.stop(); // Game Over
    }
  }

  private void boundedMove() {
    setLocation(getX()+speedX, getY()+speedY);
    if( isTouching(ScrollingObstacle.class) ) {
      setLocation(getX()-speedX, getY()-speedY);
    } else if( isTouching(GoldBlock.class) ) {
      Greenfoot.stop(); // Game over...you Win!!
    }else if( getX() > getWorld().getWidth() - BOUNDARY ) {
      ((MazeWorld)getWorld()).shiftWorld(-speedX);
      setLocation(getX()-speedX, getY()-speedY);
    } else if( getX() < BOUNDARY ) {
      ((MazeWorld)getWorld()).shiftWorld(-speedX);
      setLocation(getX()-speedX, getY()-speedY);
    }
    speedX = 0;
    speedY = 0;
  }
}

Hiker类的代码处理左右上下箭头键的按下,并确保演员不会穿过障碍物,并适当地调用shiftWorld()。它还检查与ScrollingEnemy演员之一的碰撞,并在发生碰撞时停止游戏。

处理上下移动的代码与处理左右移动的代码类似。handleKeyPresses()boundedMove()方法通过简单地添加上下移动的情况进行了扩展。

滚动演员

ScrollingActor类与上一章中的相同,我们在此处重新呈现以保持完整性:

import greenfoot.*;

public class ScrollingActor extends Actor {
  public void setAbsoluteLocation(int dx) {
    setLocation(getX()+dx, getY());
  }
}

有四个类继承自ScrollingActor。前两个是GoldBlockWhiteBlock的实现。这两个演员是可走动的背景世界的一部分,因此不需要任何特殊处理。确保在创建它们时,分别关联金币块和白色块的图像。以下是两者的代码:

import greenfoot.*;

public class GoldBlock extends ScrollingActor {
}
import greenfoot.*;

public class WhiteBlock extends ScrollingActor {
}

ScrollingActor的其他两个子类旨在被继承(注意它们没有与之关联的图像)并帮助我们将演员分为两类之一:障碍物或敌人。我们将在下一节讨论这两个子类。

滚动障碍物类

这个类没有添加任何额外的功能。它仅仅是一个方便的方式来分组Hiker类的实例无法穿过的砖块。这使得在Hiker类中执行碰撞检测变得更容易。以下是代码:

import greenfoot.*;

public class ScrollingObstacle extends ScrollingActor {
}

我们只有两种障碍砖块:BlackBlockBlueBlock。当你创建这些时,确保将适当的图像(就像我们在上一章中做的那样)与它们关联起来。以下是两者的代码:

import greenfoot.*;

public class BlackBlock extends ScrollingObstacle {
}
import greenfoot.*;

public class BlueBlock extends ScrollingObstacle {
}

我们现在可以描述展示智能行为的类的实现。

智能行为演员

现在我们将向我们的MazeWorld场景添加实现不同智能行为模拟方法的敌人。我们将讨论的第一种方法是概率移动,第二种方法是简单启发式方法,最后一种方法使用A*路径查找算法来引导演员移动。在讨论每种方法之前,我们首先展示实现智能行为演员通用结构的ScrollingEnemy类。

ScrollingEnemy 类

ScrollingEnemy类从ScrollingActor继承,因此它将被正确地放置在滚动世界中。然后,它设置了一个有利于智能移动演员的行为模式。模仿实际有感知的动物,ScrollingEnemy在其act()方法中提供了一个三阶段动作处理过程。首先,它调用一个要求演员感知其环境的方法,然后它调用一个基于感知结果选择行动方案的方法,最后它调用一个移动演员的方法。请注意,这个类是abstract的,不能直接实例化。

下面是ScrollingEnemy类的代码:

import greenfoot.*;

abstract public class ScrollingEnemy extends ScrollingActor {
  protected static final int SPEED = 1;
  private static final int BOUNDARY = 40;
  protected int speedX = SPEED;
  protected int speedY = SPEED;

  protected void addedToWorld(World w) {
    MazeWorld mw = (MazeWorld) w;
    GreenfootImage img = getImage();
    img.scale(mw.getTileWidth(),mw.getTileHeight());
    setImage(img);
  }

  public void act() {
    sense();
    reaction();
    boundedMove();
  }

  protected void sense() {
    // No smarts
  }

  protected void reaction() {
    // No reaction
  }

  protected void boundedMove() {
    setLocation(getX()+speedX, getY()+speedY);
    if( isTouching(ScrollingObstacle.class) ) {
      setLocation(getX()-speedX, getY()-speedY);
    }
  }
}

sense()reaction()方法为空,因为它们打算由实现我们智能移动策略之一的子类覆盖。这些方法的结果是它们将speedXspeedY变量的值改变以影响移动。最后一个方法boundedMove()完全实现,一旦speedXspeedY的值被设置,ScrollingEnemy的每个子类的移动都是相同的。

随机性

使用纯概率确定问题解决方案的算法出奇地有效,在计算机科学中并不罕见。虽然它们几乎从未是最好的答案,但它们与为内存管理或调度等事物开发的新算法进行了良好的比较。

对于游戏来说,一个随机移动的演员为玩家提供了独特的挑战,让他们避免或捕捉。我们将在我们的MazeWorld场景中添加一个随机移动的演员。

Spider

让我们通过在ScrollingEnemy上右键单击,选择新建子类…,输入Spider作为新的类名,然后在动物类别中选择图像spider.png来创建一个新的演员。将以下代码添加到这个新类中:

import greenfoot.*;

public class Spider extends ScrollingEnemy {
  private final static int SPEEDVARIATION = 3;
  private final static int SPEEDCHANGECHANCE = 20;

  protected void reaction() {
    speedX = Greenfoot.getRandomNumber(1000) < SPEEDCHANGECHANCE ? Greenfoot.getRandomNumber(SPEEDVARIATION)-1 : speedX;
    speedY = Greenfoot.getRandomNumber(1000) < 	SPEEDCHANGECHANCE ? Greenfoot.getRandomNumber(SPEEDVARIATION)-1 : speedY;
  }
}

首先要注意的是,我们没有为在ScrollingEnemy中定义的空sense()方法提供实现。由于我们是随机移动的,所以我们不需要对环境进行任何感知。reaction()方法随机地将speedXspeedY变量设置为10-1。它只有 2%的时间改变这些变量的值,这样移动就不会太零散。

你现在可以测试这个场景了。首先,在MazeWorldprepare()方法中注释掉MouseSnake对象的添加,然后编译并运行场景。观察蜘蛛对象的移动。你能绕过它们吗?在Spider类中调整值,看看它们如何影响蜘蛛对象的移动。

用一点代码,我们就构建了一个难以避免的敌人。

行为启发式

在这个方法中,我们提供了一些简单的移动规则,这些规则提供了相当不错的智能,而不需要复杂的编码。自然界中遵循简单行为启发式算法的动物的一个好例子是蚂蚁。蚂蚁遵循一些移动规则,这些规则提供了一种在环境中找到食物并返回巢穴的可靠方法。

这些简单启发式算法的例子包括:

  • 如果你撞到障碍物,就向左转

  • 跟随太阳

  • 如果你靠近猎物,就朝它跑去

  • 沿着圆形路径行走

让我们创建一个角色,如果徒步者太靠近,它就会攻击徒步者;否则,它就会来回踱步。

蛇类

创建一个名为Snake的类,就像我们之前创建的Spider类一样。当然,你需要选择蛇的图片snake2.png,而不是蜘蛛的图片。

下面是Snake类的代码:

import greenfoot.*;
import java.util.List;

public class Snake extends ScrollingEnemy {
  private static final int PATHLENGTH = 200;
  private static final int INRANGE = 100;
  private int pathCounter = PATHLENGTH;
  private boolean pathing = false;
  private int rememberSpeedX = 0;
  private List<Hiker> lse;

  public Snake() {
    speedX = rememberSpeedX = SPEED;
    speedY = 0;
  }

  protected void sense() {
    // If near, move towards enemy
    lse = getObjectsInRange(INRANGE,Hiker.class);
    pathing = lse.isEmpty();
  }

  protected void reaction() {
    if( pathing ) {
      speedX = rememberSpeedX;
      speedY = 0;
      if( --pathCounter == 0 ) {
        pathCounter = PATHLENGTH;
        speedX = rememberSpeedX = -speedX;
      }
    } else {
      speedX = lse.get(0).getX() > getX() ? 1 : -1;
      speedY = lse.get(0).getY() > getY() ? 1 : -1;
    }
  }
}

Snake角色的sense()方法很简单。它使用getObjectsInRange()碰撞检测方法查看远处的徒步者是否在范围内。如果徒步者在范围内,那么getObjectsInRange()将返回一个包含对Hiker对象的引用的列表;否则,列表将为空。接下来,我们通过调用isEmpty()方法并保存结果到pathing变量来检查返回的列表是否为空。我们将使用pathing的值来确定蛇应该来回移动还是追逐徒步者。

蛇类

图 2:这显示了蛇角色所做的移动决策。蛇来回移动,如箭头所示,除非徒步者在绿色圆圈内。在这种情况下,蛇将朝向徒步者移动。

reaction()方法中,如果pathing为真,蛇会在两个方向上来回移动;否则,蛇会追逐徒步旅行者。图 2显示了这两种情况。为了来回移动,我们使用一个延迟变量pathCounter来定义蛇在每个方向上移动多长时间。当变量到期(值为0)时,我们让蛇改变方向并重置延迟变量。为了追逐徒步旅行者,我们只需使用简单的计算来设置speedXspeedY变量。如果徒步旅行者在蛇的右边,我们将speedX设置为1;否则,设置为-1。如果徒步旅行者在蛇的下方,那么我们将speedY设置为1;否则,设置为-1

让我们测试这个场景。因为我们还没有实现Mouse类,所以你需要在MazeWorld类中存在的prepare()方法中注释掉添加Mouse对象的部分。编译并运行场景。观察Snake对象的移动。尝试靠近一个。是Spider对象还是Snake对象更难避开?

A*路径查找

A路径查找算法在起始位置和目标位置之间找到一个路径,该路径智能地避开障碍物。这个算法在游戏行业中得到了广泛的应用。你是否曾经好奇过你在游戏中玩过的敌人是如何在避开障碍物的同时追逐你的?他们的移动是通过使用这个算法来编程的。虽然这个算法相当复杂(我们很快就会看到),但理解它是相当直接的。图 3显示了 A算法在确定鼠标演员和徒步旅行者之间的路径时考虑的不同区域。

A*路径查找

图 3:第一轮比较是在包含红色“1”的区域进行的,第二轮是在包含绿色“2”的区域进行的,第三轮是在包含蓝色“3”的区域进行的,第四轮是在包含紫色“4”的区域进行的。竞争路径用右上角的黑方块表示。经过第四轮后,上方的路径继续前进,直到达到目标目的地

概述

在开始算法之前,您需要将世界划分为均匀大小的网格区域。每个直接围绕角色的单个区域定义了角色可能移动到的潜在位置。有了这个基础,我们就可以开始了。A算法通过比较角色可能移动到的区域,使用一个近似剩余距离到目标位置的启发式方法(通常称为H值),并将其与迄今为止的距离(称为G值)相结合来工作。例如,在图 3中,鼠标最初可以移动到任何标记有红色1的方块。如果一个区域包含障碍物,则它不会用于比较。因此,我们计算鼠标上方、下方和左侧的方块的H + G(称为F)。H值通过仅计算我们离目标目的地有多远来近似,忽略任何障碍物。G值是通过计算回到鼠标起始位置的方块数量来确定的。了解这一点后,我们可以计算鼠标周围可通行方块的F值(G+H)。在我们的例子中,每个方块的F值是10H=9,G=1)。然后算法将假装角色已经移动到最有利的位置(具有最低F值的位置),然后重复此过程。如果有最佳的F值相同的情况,算法将随机选择一个。图 3以图示方式展示了这一点以及算法的几个更多迭代。我们的鼠标只能向上、向下、向左和向右移动——不能斜向移动*。然而,该算法对可以斜向移动的角色同样有效。

算法

既然我们现在对算法有了基本了解,我们可以更正式地陈述它。以下是步骤:

  1. 将起始位置添加到open列表中。

  2. 选择open列表中具有最小F值的节点。让我们称它为n

  3. nopen列表中移除并添加到closed列表中。

  4. 对于n的每个不在closed列表中且不包含障碍物的邻居,执行以下步骤:

    1. 计算其F值,将其父节点设置为n

    2. 如果它尚未在该列表中,则将其添加到open列表中。

    3. 如果它在开放列表中,更新其F值及其父节点。

  5. 如果您尚未到达目的地,请返回步骤 2。

  6. 如果您已到达目标节点,则通过回溯父链接来构建从起始位置到结束位置的路径。

在我们的算法中,GHF的定义如下:

  • G:这是从起始位置到达此节点需要穿越的位置数量。

  • H: 这大约是我们与目标节点的距离。这是通过将当前节点和目标节点在 x 位置上的差值的绝对值与当前节点和目标节点在 y 位置上的差值的绝对值相加来计算的。这被称为 曼哈顿距离

  • F: 这是 HG 的和。

现在,让我们看看这个算法在我们 MazeWorld 场景中的实现。

小贴士

要了解更多关于 A* 路径查找的信息,请参考以下资源:

鼠标类

我们将创建一个 Mouse 角色来使用 A* 路径查找追踪徒步者。首先,在 ScrollingEnemy 上右键点击,选择 New subclass…,然后输入 Mouse 作为新的类名,接着在 animals 类别中选择 mouse.png 图片。为这个新类打开 Greenfoot 的编辑器并输入以下代码:

import greenfoot.*;
import java.util.Stack;

public class Mouse extends ScrollingEnemy {
  private TiledWorldPathfinding twp;
  private Stack<Point> apath;
  private int walkDelay = -1;
  private final static int WALKDELAY = 40;
  private int searchDelay = -1;
  private final static int SEARCHDELAY = 130;
  private int prevRow = 0;
  private int prevCol = 0;

  /* initilization */
  protected void addedToWorld(World w) {
    MazeWorld mw = (MazeWorld) w;
    super.addedToWorld(w);
    twp = new TiledWorldPathfinding
    (mw.getStringWorld(),mw.getValidSpaces());
    prevRow = getY()/mw.getTileWidth();
    prevCol = getX()/mw.getTileWidth();
    setLocation(prevCol*mw.getTileWidth()+mw.getTileWidth()/2,
    prevRow*mw.getTileWidth()+mw.getTileWidth()/2);
  }

  protected void sense() {
    // A* pathfinding determines direction
    if( --searchDelay < 0) {
      MazeWorld w = (MazeWorld) getWorld();
      int hikerCol = w.getXHiker()/w.getTileWidth();
      int hikerRow = w.getYHiker()/w.getTileWidth();
      apath = twp.findShortestFeasiblePath(new
      Point(prevRow,prevCol), new Point(hikerRow,hikerCol));
      if( apath != null && !apath.isEmpty() ) apath.pop();
      searchDelay = SEARCHDELAY;
    }
  }

  protected void reaction() {
    // Move in direction chosen by A* pathfinding
    if( --walkDelay < 0 ) {
      walkDelay = WALKDELAY;
      if( apath != null && !apath.isEmpty() ) {
        Point p = apath.pop();
        MazeWorld w = (MazeWorld) getWorld();
        speedX = (p.col-prevCol) * w.getTileWidth();
        speedY = (p.row-prevRow) * w.getTileWidth();
        prevCol = p.col;
        prevRow = p.row;
      }
    } else {
      speedX = 0;
      speedY = 0;
    }
  }

}

Mouse 类的实现中,sense() 方法运行 A* 算法以找到通往徒步者的路径,而 reaction() 方法将 speedXspeedY 设置为沿着找到的路径移动 Mouse 对象。由于徒步者可以移动,Mouse 类需要定期更新其计算出的路径。

Mouse 类需要在 addedToWorld() 方法中一次性初始化 A* 路径查找算法代码。首先,执行对父类 addedToWorld() 方法的调用,以确保在该类中执行任何必要的初始化,例如,缩放角色的图像不会跳过。接下来,我们创建 TiledWorldPathfinding 类的新实例。这是实现 A* 路径查找的类,我们将在稍后详细讨论它。现在,我们只需假设它完美无缺地工作。要创建 TiledWorldPathfinding 的新实例,我们需要提供在 MazeWorld 类中定义的世界的字符串表示以及在此表示中可通行的空间集合,这些也在 MazeWorld 中定义。此方法完成的最后一件事是确保角色在所需的新网格视图中对齐,以便位于网格的中心。

sense()方法运行 A*路径查找算法。它被延迟变量包装,以便降低我们重新运行算法的速率,使其更高效,因为在延迟期间徒步者实际上无法移动很远。当searchDelay小于零时,我们向我们的世界请求Hiker对象的位置,并确定徒步者所在的行和列。我们将我们的位置和徒步者的位置传递给TiledWorldPathfindingfindShortestFeasiblePath()方法。为了方便,我们选择将世界中的位置表示为由Point类定义的点。我们很快就会看到Point类的实现。然后,findShortestFeasiblePath()方法返回从鼠标位置到徒步者位置的最短可行路径。返回的路径包含我们的当前位置,因此我们从路径中移除它,然后重置searchDelay值。

reaction()方法中,我们只是根据sense()方法中确定的路径移动Mouse对象。首先,我们检查walkDelay是否小于零。我们需要这个延迟变量,以便鼠标以合理的速度向徒步者移动。在if语句内部,我们从路径中弹出下一个位置到徒步者那里,然后将speedXspeedY设置为将鼠标正确移动到该位置的值。

Mouse类的实现实际上很简单。真正的重头戏是在TiledWorldPathfinding类中完成的——这个类实现了 A*路径查找。

TiledWorldPathfinding类不会成为Actor的子类。它是一个非图形类,将仅用于封装 A*路径查找的实现。要创建此类,请点击 Greenfoot 主菜单栏中的编辑,然后选择新建类…。在弹出的窗口中,键入TiledWorldPathfinding。您将在 Greenfoot 主场景窗口中所有Actor类下方看到新类。在本章的后面部分,您将以相同的方式创建Point类和Tile类。

下面是代码:

import java.util.PriorityQueue;
import java.util.Queue;
import java.util.Stack;

public class TiledWorldPathfinding {
  private String []world;
  private String validSpaces;
  private int worldColumns;
  private int worldRows;
  private Tile[][] tiledWorld;

  public TiledWorldPathfinding(String []w, String vs) {
    world = w;
    worldColumns = w[0].length(); // number of columns
    worldRows = w.length; // number of rows
    tiledWorld = new Tile[worldRows][worldColumns];
    validSpaces = vs;
    resetWorld();
  }

  public void changeWorld( String []w ) {
    world = w;
    resetWorld();
  }

  public Stack<Point> findShortestFeasiblePath(Point start, Point end) {
    Queue<Tile> openList = new PriorityQueue<Tile>();
    Queue<Tile> closedList = new PriorityQueue<Tile>();
    Stack<Point> answer = new Stack<Point>();

    // Check for trivial case
    if( start.equals(end) ) {
      answer.push(start);
      return answer;
    }

    // Check that both start and end are walkable
    if( !tiledWorld[start.row][start.col].isWalkable() ) {
      return null;
    }
    if( !tiledWorld[end.row][end.col].isWalkable() ) {
      return null;
    }

    // Mark location of end point
    tiledWorld[end.row][end.col].setEndNode();

    // Add starting node to open list
    openList.add(tiledWorld[start.row][start.col]);

    // A* algorithm
    runAStar(openList, closedList, end);

    // derive the answer area from the marked up TileWorld
    if( tiledWorld[end.row][end.col].getParent() == null ) {
      resetWorld();
      return null;
    } else {
      deriveWaypoints(answer, end);
    }

    // Prepare for next time
    resetWorld();

    // return result
    return answer;
  }

  /* private methods */
  private void runAStar(Queue<Tile> openList,
  Queue<Tile> closedList, Point end) {
    boolean done = false;
    Tile t;

    while( !openList.isEmpty() && !done ) {
      t = openList.remove();
      done = done || processNeighbor(t, t.getUp(), openList, end);
      done = done || processNeighbor(t, t.getDown(), openList, end);
      done = done || processNeighbor(t, t.getLeft(), openList, end);
      done = done || processNeighbor(t, t.getRight(), openList, end);
      t.setDone();
      closedList.add(t);
    }
  }

  private boolean processNeighbor( Tile parent, Tile node, Queue<Tile> openList, Point end) {
    boolean retval = false;

    if( node != null && !node.isDone() && node.isWalkable()) {
      if( node.isEndNode() ) { // Are we done?
        node.setParent(parent);
        retval = true; // FOUND THE END NODE
      } else {
        node.setParent(parent);
        node.setG(1 + parent.getG());
        node.setH(calculateManhattenDistance(
        node.getPoint(), end));
        openList.add(node);
      }
    }
    return retval;
  }

  private int calculateManhattenDistance(Point start,Point end)
  {
    return Math.abs(start.row - end.row) + Math.abs(start.col - end.col);
  }

  private void deriveWaypoints(Stack<Point> a, Point end) {
    Tile tp = tiledWorld[end.row][end.col];

    while( tp != null ) {
      a.push(tp.getPoint());
      tp = tp.getParent();
    }
  }

  private void resetWorld() {
    for( int i = 0; i<worldRows; i++ ) {
      for(int j = 0; j<worldColumns; j++) {
        tiledWorld[i][j] = new Tile();
        tiledWorld[i][j].setPoint(i,j);
      }
    }
    for( int i = 0; i<worldRows; i++ ) {
      for(int j = 0; j<worldColumns; j++) {
        Tile t = tiledWorld[i][j];;
        if( validSpaces.indexOf(world[i].charAt(j)) == -1) {
          t.setNotWalkable();
        } else {
          if( i == 0 ) {
            t.setUp(null);
          } else {
            t.setUp(tiledWorld[i-1][j]);
          }
          if( i == worldRows-1 ) {
            t.setDown(null);
          } else {
            t.setDown(tiledWorld[i+1][j]);
          }
          if( j == 0 ) {
            t.setLeft(null);
          } else {
            t.setLeft(tiledWorld[i][j-1]);
          }
          if( j == worldColumns-1 ) {
            t.setRight(null);
          } else {
            t.setRight(tiledWorld[i][j+1]);
          }
        }
      }
    }
  }
}

本类的主要方法是 findShortestFeasiblePath()。类中的其他方法都支持这个方法,所以让我们首先看看它。方法 findShortestFeasiblePath() 接受两个以 Point 形式表示的位置。Point 类非常简单。它只是记录一个位置的行和列值。findShortestFeasiblePath() 方法首先通过使用在 Point 类中定义的 equals() 方法检查起始位置和结束位置是否相同。如果是这样,我们可以返回只包含起始节点的路径,然后我们就完成了。接下来,我们检查起始位置和结束位置是否可通行;如果不是,那么我们实际上无法运行算法,因为它忽略了不可通行的位置,所以我们返回 null。然后我们将结束节点设置为我们的目的地,将起始节点添加到开放列表 (openList) 中,然后运行 A* 算法。我们现在将探讨 runAStar() 的实现。

由于我们使用了良好的功能分解,runAStar() 的实现相当简洁。我们从 openList 中移除一个节点,处理所有有效的邻居,将节点设置为 done,并将其添加到 closedList 中。当我们处理邻居时,我们将新的节点添加到 openList 中。如果我们遇到结束节点,我们将 done 设置为 true 并跳出循环。这是我们之前讨论的 A* 寻路算法的直接实现。为了完成我们的讨论,我们需要看看 processNeighbor() 的实现。

processNeighbor() 中,我们检查两件事。如果节点无效(我们已处理它或它不可通行),我们跳过它。然后我们检查节点是否是我们的目标目的地。如果是这样,我们将我们刚刚到达的节点设置为父节点并返回 true。如果不是,我们计算 GHF,设置父节点,然后将此节点添加到 openList 中。

runAStar() 完成后,我们返回到 findShortestFeasiblePath() 方法。我们现在要么找到了通往目标位置的路,要么确定没有可行的路径。如果我们找到了有效的路径,我们使用 deriveWaypoints() 构建一个存储在 Stack 中的点的列表(见以下两段之后的信箱信息),重置这个类的状态以便我们可以再次调用它,并将答案返回给调用者。

deriveWaypoints() 方法很小。它通过从目的地沿着父指针回溯到起始点,从 tiledWorld 矩阵中推导出路径。在这个过程中,它将每个节点推入一个栈中。这就是为什么我们在 processNeighbor() 中设置父引用的原因。

我们在本类中讨论的最后一个方法是 resetWorld()。它负责初始化 tiledWorld 矩阵并确保它准确地表示游戏的当前状态(障碍物在哪里,目的地在哪里)。我们在 tiledWorld 上运行 A* 寻路算法,而不是游戏的实际屏幕。

注意

栈和优先队列

在编程中,你会使用许多不同类型的数据结构来存储你的数据。我们已经使用了数组列表(列表类首次在第三章中使用,碰撞检测)。有时,我们希望在存储数据时按某种特定方式排序,因为列表和数组是无序的。在 A*路径查找的实现中,我们使用了两种新的数据结构:栈和优先队列。栈按后进先出LIFO)的顺序存储数据,而优先队列按排序顺序存储数据。要了解更多关于这两种数据结构的信息,请参阅以下链接:

我们有两个类,我们使用它们来支持 A*路径查找算法的运行:TilePoint。让我们首先讨论Tile类。这个类用于表示屏幕上的一个区域,并存储在tiledWorld矩阵中。随着我们通过路径查找算法的进展,我们需要跟踪每个区域的信息。例如,我们需要存储该区域的GHF值;注意它是否是目标节点,以及它是否可通行并记录父信息。该类被设置为存储这些信息并允许轻松访问它们。代码如下:

import java.util.Comparator;

public class Tile implements Comparable<Tile> {
  private int g = 0, h = 0;
  private Tile up, down, left, right, parent;
  private Point location;
  private boolean walkable = true;
  private boolean done = false;
  private boolean isEndNode = false;

  public Tile() {
    parent = up = down = left = right = null;
    location = new Point(0,0);
  }

  public Tile(Tile u, Tile d, Tile l, Tile r) {
    up = u;
    down = d;
    left = l;
    right = r;
    parent = null;
    location = new Point(0,0);
  }

  /* state methods */
  public boolean isWalkable() {
    return walkable;
  }

  public void setNotWalkable() {
    walkable = false;
  }

  public boolean isDone() {
    return done;
  }

  public void setDone() {
    done = true;
  }

  public boolean isEndNode() {
    return isEndNode;
  }

  public void setEndNode() {
    isEndNode = true;
  }

  /* neighbors */
  public void setParent(Tile t) {
    parent = t;
  }

  public Tile getParent() {
    return parent;
  }

  public void setUp(Tile t) {
    up = t;
  }

  public Tile getUp() {
    return up;
  }

  public void setDown(Tile t) {
    down = t;
  }

  public Tile getDown() {
    return down;
  }

  public void setRight(Tile t) {
    right = t;
  }

  public Tile getRight() {
    return right;
  }

  public void setLeft(Tile t) {
    left = t;
  }

  public Tile getLeft() {
    return left;
  }

  /* accessor methods */
  public void setPoint(int _row, int _col) {
    location.row = _row;
    location.col = _col;
  }

  public Point getPoint() {
    return location;
  }

  public void setG(int n) {
    g = n;
  }

  public int getG() {
    return g;
  }

  public void setH( int n) {
    h = n;
  }

  public int getH() {
    return h;
  }

  public int getF() {
    return g+h;
  }

  // needed for Comparable interface
  public int compareTo(Tile t) {
    return getF()-t.getF();
  }

}

注意

Comparable 接口

在第三章中,我们已经讨论了 Java 接口的一般情况。Comparable接口是一个要求实现类提供compareTo()方法的接口。然后,该方法将在PriorityQueue等类中使用,以帮助确定队列中的排序。

如前所述,Point类为我们提供了一个方便的方式来引用tiledWorld中的位置。它简洁地跟踪行和列的位置,并提供了一种比较点的简单方法(查看它们是否相等)。以下是完成此任务的代码:

public class Point {
  public int row;
  public int col;

  public Point() {
    row = col = 0;
  }

  public Point( int _row, int _col) {
    row = _row;
    col = _col;
  } 

  public boolean equals(Point p) {
    return (p.row == row) && (p.col == col);
  }
}

我们现在已经完全实现了Mouse类。这需要相当多的编码工作!但现在,我们有一个能够有效追逐我们的徒步者的演员。编译场景并修复你沿途犯的任何错误。我们现在有一个非常有趣的场景。

进行测试

我们在这个场景上花费了很长时间。是时候玩耍了!

prepare()方法中取消注释所有演员,编译场景,然后尝试运行。你能到达金色的方块吗?哪个敌人最难躲避?

摘要

我们在本章中确实覆盖了大量的内容。正如我们所看到的,为演员添加智能行为可以从非常简单到非常复杂。很多时候,使用随机性或启发式方法,或者两者的结合,可以创造出一些极具挑战性的敌人,这对于你创建的许多游戏/模拟来说已经足够了。然而,没有一个替代品能比得上一个知道如何通过 A*路径查找算法追踪你的敌人。我希望你能找到新的和创造性的方法,为你的演员的行为带来挑战、神秘和惊喜。

在本书的这个阶段,我们已经涵盖了大量的主题,帮助你创建一个有趣且引人入胜的交互式应用程序。接下来,我们将探讨如何创建用户界面,以便从我们的用户那里获取更多信息,并向他们提供更多的反馈。

第八章。用户界面

*"如果你能梦想它,你就能做到。"
--沃尔特·迪士尼

除了游戏和模拟的用户控制之外,有时你可能希望用户点击按钮、查看文本和从菜单中选择项目。想象一下,你正在创建一个物理模拟,并希望用户设置某些模拟参数,或者你在游戏中有一个商店,玩家可以在那里购买升级。或者,也许你想要在你的场景中创建两个演员之间的对话。在本章中,我们将探讨提供各种类型 用户界面UIs)的技术。具体来说,我们将探讨以下主题:

  • 按钮和标签

  • 菜单

  • 头戴式显示器 (HUD)

Greenfoot 在创建用户界面方面提供的直接支持很少。只有几个类,如 LabelCounter,被打包在 Greenfoot 中以帮助这方面。因此,我们将不得不自己构建支持。我们将使用 Greenfoot 的 ActorsGreenfootImage 类来创建用户界面和将支持创建用户界面的类。幸运的是,Greenfoot 允许我们构建我们所能梦想的几乎所有东西,包括用户界面。

UIWorld

在本节中,我们将解释如何编写以下用户界面元素:按钮、文本框、菜单和 头戴式显示器HUDs)。我们将通过一个 Greenfoot 场景(如图 1 所示)来操作,该场景只包含用户界面元素,这样我们就可以独立讨论每个元素。

我们编写的某些代码将是通用的,可以应用于许多不同的场景。在其他情况下,我们将编写需要稍作修改才能跨场景使用的用户界面代码。在下一节中,我们将把这些元素添加到我们在上一章中编写的 MazeWorld 场景中,使其成为一个更加精致和可玩的游戏。

UIWorld

图 1:这显示了 UI MainWorld

要处理这个场景,从一个名为 UIMainWorld 的新 Greenfoot 场景开始,创建一个名为 UIMainWorldWorld 子类,并将其与一个纯背景关联。我选择的背景是 bluerock.jpg。以下是 UIMainWorld 的代码:

import greenfoot.*;
import java.awt.Color;

public class UIMainWorld extends World {

  public UIMainWorld() {
    super(600, 400, 1);
    testActors();
  }

  private void testActors() {
    /*   Begin comment
    TextBox t1 = new TextBox(
    " This is a question?\n Yes, it is! ",
    24, true, Color.BLUE, Color.YELLOW);
    addObject(t1, 150, 50);
    TextBox t2 = new TextBox("This is one line",
    18, false, Color.BLACK, Color.WHITE);
    addObject(t2, 150, 120);
    Button b1 = new Button("button-blue.png",
    "button-green.png");
    addObject(b1, 450, 50);
    Menu m1 = new Menu(" Destroy Everything? ",
    "Are you sure?", 18,
    Color.BLUE, Color.WHITE
    Color.BLACK, Color.WHITE,
    new DestroyCommands());
    addObject(m1, 450, 120);
    Menu m2 = new Menu(" File ",
    "New\nOpen\nSave\nClose\nExit", 18,
    Color.BLACK, Color.lightGray,
    Color.WHITE, Color.BLUE,
    new FileCommands());
    addObject(m2, 450, 180);
    HUD h = new HUD();
    addObject(h, 300, 310);
    Label l = new Label("This is a label", 18);
    addObject(l, 150, 180);
    End Comment  */
  }
}

目前,testActors() 方法中的代码已被注释。在我们实现相关演员时取消注释它们,这样你就可以逐一测试和玩每个演员。如果你愿意,你可以从 www.packtpub.com/support 下载完整的 UI 场景。

按钮类

有没有比谦逊的按钮更丰富的 UI 元素?很难想象任何界面不包含几个这样的按钮。幸运的是,在 Greenfoot 中实现它们非常简单。在你的 UI 场景中,从 Actor 类派生出一个新子类,并将其命名为 Button。为 Button 的图像选择 无图像。我们将动态添加此演员所需的图像。以下是 Actor 类的代码:

import greenfoot.*;

public class Button extends Actor {
  protected String first;
  protected String second;

  public Button(String f, String s) {
    first = f;
    second = s;
    setImage(f);
  }

  public void act() {
    handleMouseClicks();
  }

  private void handleMouseClicks() {
    if( Greenfoot.mousePressed(this) ) {
      setImage(second);
    } else if( Greenfoot.mouseClicked(this) ) {
      setImage(first);
      clickedAction();
    }
  }

  protected void clickedAction() {
    // Can either fill this in or have subclasses override.
  }
}

对于按钮,您需要一个用于正常状态的图像和一个用于按下状态的图像。firstsecond实例变量存储这些图像的名称。它们的值作为输入参数提供给类的构造函数。构造函数将初始图像设置为first图像。

act()方法只包含一个方法调用,用于处理此演员的鼠标事件——handleMouseClicks()。当鼠标按下时,此方法显示second图像,然后在点击完成后返回显示first图像。在 Greenfoot 中,Greenfoot.mousePressed()方法在给定对象上按下左鼠标按钮时返回trueGreenfoot.mouseClicked()方法在给定对象上按下并释放左鼠标按钮时返回true图 2演示了这两个鼠标事件。当我们检测到鼠标按下时,我们只需将图像更改为second图像。当鼠标释放时,发生了一次完整的点击,我们做两件事。首先,我们将图像恢复到正常状态,然后通过调用clickedAction()方法执行一个动作。此方法目前为空,用作可以放置您自己的自定义动作代码的占位符。另一种选择是创建此类的子类,并在您的新子类中重写clickedAction()方法。

按钮类

图 2:在 Greenfoot 中,当左鼠标按钮同时按下和释放时,认为鼠标被点击

按钮是通过在World子类UIMainWorld中以下两行代码添加到屏幕上的:

Button b1 = new Button("button-blue.png", "button-green.png");
addObject(b1, 450, 50);

button-blue.pngbutton-green.png图像是 Greenfoot 默认安装提供的图像(在版本 2.2 中不可用)。您可以通过创建具有这些图像作为默认图像的临时演员或将它们从 Greenfoot 的安装中复制来快速将这些图像添加到您的项目中。取消注释UIMainWorld中的testActors()方法中显示的两行代码,编译您的场景,并测试您的新按钮。

TextBox 类

TextBox的功能在功能上与 Greenfoot 提供的Label类非常相似。请注意,在UIMainWorld中,我们添加了一个Label类的实例到我们的场景中,用于演示和比较目的。要将Label类添加到您的 UI 场景中,请点击 Greenfoot 主菜单中的编辑,然后点击导入类…。在出现的弹出窗口的左侧点击Label,阅读关于Label类的文档(如果您感兴趣),然后点击导入按钮。我们将实现我们自己的Label版本,并将其称为TextBox。我们将编写的Textbox类更加简洁,这为我们提供了一个讨论如何在 Greenfoot 中处理文本的理由。

图 1 中,我们可以看到 TextBox 类的两个示例。此类允许我们在屏幕上使用自定义字体、颜色、背景颜色和可选边框显示文本。以下是 TextBox 的代码:

import greenfoot.*;
import java.awt.Color;

public class TextBox extends Actor {
  private GreenfootImage img;
  private boolean border = false;
  private int fontSize;
  private Color foreground;
  private Color background;

  public TextBox(String s, int fs, boolean b,
  Color fg, java.awt.Color bg) {
    super();
    fontSize = fs;
    foreground = fg;
    background = bg;
    img = new GreenfootImage(s, fontSize,
    foreground, background);
    border = b;
    display();
  }

  public void setText(String s) {
    img = new GreenfootImage(s, fontSize,
    foreground, background);
    display();
  }

  private void display() {
    setImage(img);
    if( border ) {
      img.setColor(Color.BLACK);
      img.drawRect(0, 0, img.getWidth()-1,
      img.getHeight()-1);
      setImage(img);
    }
  }
}

TextBox 中,我们可以配置前景色、背景色、字体大小以及是否在文本框周围绘制边框。除了要显示的实际文本外,构造函数还接受并存储这些值。display() 方法负责实际创建我们的新文本框。首先,它使用 Greenfoot 的 GreenfootImage() 方法根据之前的配置信息创建一个新的图像。

当你将文本作为第一个参数传递给 GreenfootImage() 时,它将创建一个该文本的图像。然后,我们只需使用 setImage() 来显示该文本。display() 方法检查 border 实例变量,并在需要时在新创建的图像上绘制一个边框。我们还提供了一个 setText() 方法,以防我们需要动态更改文本。此方法基于新文本创建一个新的 GreenfootImage,然后使用 display() 方法正确设置文本框的图像为新创建的图像。

要测试我们的新 TextBox 类,请取消注释 UIMainWorldtestActors() 的所有行,这些行涉及添加 TextBox 实例,编译场景,并运行它。

菜单在接收用户命令方面非常出色。我相信你有很多使用它们的经验,并理解它们的实用性。我们实现的菜单涉及使用我们刚刚创建的 TextBox 类和一个名为 MenuCommands 的新 Java 接口,我们很快将实现它。TextBox 实例显示文本,而菜单选择的动作由实现 MenuCommands 接口的类执行。我们很快会详细解释这一点。

图 3 提供了我们的 Menu 类的功能概述。我们的菜单最初看起来像 TextBox,如图 3(a) 所示。当用户点击菜单时,会出现一个弹出菜单,用户可以选择一系列操作。弹出菜单如图 3(b) 所示。菜单标题和命令集都是可配置的。

菜单类

图 3:最初,菜单对象看起来像 TextBox(见图 (a))。当用户点击文本时,会出现一个下拉菜单,用户可以选择多个项目(见图 (b))

这是 Menu 的代码:

import greenfoot.*;
import java.awt.Color;

public class Menu extends Actor
{
  private TextBox titleBar;
  private TextBox menuItems;
  private MenuCommands menuCommands;
  private int fontSize = 24;
  private boolean visible = false;
  private Color mainFG;
  private Color mainBG;
  private Color secondFG;
  private Color secondBG;
  int th, mh;  /* title and menu height */

  public Menu(String tb, String i, int fs,
  Color fg1, Color bg1, Color fg2, Color bg2,
  MenuCommands mc) {
    mainFG = fg1;
    mainBG = bg1;
    secondFG = fg2;
    secondBG = bg2;
    titleBar = new TextBox(tb, fs, true, mainFG, mainBG);
    menuItems = new TextBox(i, fs, true, secondFG, secondBG);
    menuCommands = mc;
    fontSize = fs;
  }

  public Menu() {
    this("not initialized", "none", 24,
    Color.BLACK, Color.lightGray, Color.BLACK,
    Color.WHITE, null);
  }

  protected void addedToWorld(World w) {
    w.addObject(titleBar, getX(), getY());
    th = titleBar.getImage().getHeight();
    mh = menuItems.getImage().getHeight();
  }

  public void act() {
    handleMouse();
  }

  private void handleMouse() {
    if( Greenfoot.mouseClicked(titleBar) ) {
      if( !visible ) {
        getWorld().addObject(menuItems,
        getX(), getY()+(th+mh)/2);
      } else {
        getWorld().removeObject(menuItems);
      }
      visible = !visible;
    }

    if( Greenfoot.mouseClicked(menuItems)) {
      MouseInfo mi = Greenfoot.getMouseInfo();
      int menuIndex =
      ((mi.getY()-menuItems.getY()+mh/2)-1)/fontSize;
      menuCommands.execute(menuIndex, getWorld());
      visible = !visible;
      getWorld().removeObject(menuItems);
    }
  }
}

Menu 实例由两个 TextBox 实例和一个 MenuCommands 接口的实现组成。第一个 TextBox 实例表示菜单标题(如图 3(a) 所示),第二个 TextBox 实例表示命令集合(如图 3(b) 所示)。Menu 构造函数创建了这两个 TextBox 实例,并存储提供的 MenuCommands 对象以供以后使用。

Menu被添加到World中时,我们使用addedToWorld()方法将菜单标题栏放置在场景中,并收集稍后正确放置弹出窗口所需的高度信息。

act()方法调用一个方法handleMouse(),当点击标题文本时放置菜单项弹出。对于菜单项弹出,handleMouse()方法确定是否被点击以及点击的位置,然后调用适当的命令。以下代码确定了点击位置:

((mi.getY()-menuItems.getY()+mh/2)-1)/fontSize

这基于当前的字体大小和TextBox菜单项的高度。图 4以图解的形式展示了计算过程。

菜单类

图 4:为了确定哪个菜单项被点击,使用以下公式:((a)-(b)+(c))/(d)。这个公式确定了图像中心(b)和点击位置(a)之间的距离,通过添加一半的高度(c)来调整值,使其相对于图顶部的位置,然后除以字体大小(d)以获得实际的项目索引

现在我们知道了用户点击的菜单项的索引,我们需要运行与其关联的命令。为此,我们只需在通过构造函数传递给我们的MenuCommands对象上调用execute()方法。MenuCommands是一个 Java 接口,它保证任何实现此接口的 Java 类都将具有execute()方法。

提示

我们在第三章中首次遇到了 Java 接口,碰撞检测。请记住,实现 Java 接口的类承诺提供该接口中定义的每个方法的实现。有关更多信息,请参阅第三章,碰撞检测

以下是MenuCommands类的代码:

import greenfoot.*;

public interface MenuCommands {
  public void execute(int idx, World w);
}

如我们所见,此接口只定义了一个方法,execute(),它必须接受一个整数参数(表示菜单项的索引)和当前World实例的引用。

在我们的 UI 场景中,我们提供了两个使用Menu类的示例。第一个是带有菜单标题栏文本的示例,销毁一切?。弹出的菜单只有一个选项,你确定吗?。以下是DestroyCommands类的代码,它实现了MenuCommands接口:

import greenfoot.*;

public class DestroyCommands implements MenuCommands {
  public void execute(int idx, World w) {
    System.out.println("Boooom!!!!");
  }
}

因为弹出菜单只有一个选项,所以我们不需要使用提供的idx值。我们通过简单地向控制台窗口打印Boooom!!!!来实现execute()方法。

第二个Menu类示例模仿了在处理文件的应用程序中会看到的命令类型。此示例在图 3中展示。以下是实现MenuCommands接口的FileCommands类的代码:

import greenfoot.*;

public class FileCommands implements MenuCommands {
  public void execute(int idx, World w) {
    switch(idx) {
      case 0:
      System.out.println("Running New command");
      break;
      case 1:
      System.out.println("Running Open command");
      break;
      case 2:
      System.out.println("Running Save command");
      break;
      case 3:
      System.out.println("Running Close command");
      break;
      case 4:
      System.out.println("Running Exit command");
      break;
    }
  }
}

此代码使用idx值运行几个可用选项之一。为了简单起见,我们只是向控制台窗口打印消息来演示代码正在正常工作。在你的应用程序中,你会用实际的有关代码替换打印消息。

在第三章中,我们使用接口,因为我们需要遵守 Greenfoot API。在这种情况下,我们选择使用接口,因为它们提供了一种干净简单的方式,可以提供许多不同类型的菜单操作,而无需更改Menu类。我们有效地抽象了了解自定义菜单内容的需求,并使我们的Menu类适用于广泛的用途。

现在,在UIMainWorld中的testActors()方法中取消注释Menu演员,并测试我们之前创建的菜单。

小贴士

Menu类相当复杂,因为它涉及管理两个TextBox类并实现MenuCommands接口。为了更好地理解它,现在尝试创建自己的菜单并将其添加到 UI 场景中。

抬头显示

通常,你想要创建一个完全自定义的 UI,它涉及各种形状和图形。在本节中,我们将学习如何做到这一点。本节的标题是抬头显示(HUD),因为游戏通常有自定义界面(称为 HUD),为玩家提供关键信息和控制。然而,这里讨论的方法适用于任何自定义 UI。在我们的例子中,我们将创建图 5 中显示的自定义用户界面元素。在我们的 HUD 中,用户将能够点击主页、收藏、打印和购物车图标来执行我们选择的操作。

抬头显示

图 5:这显示了自定义用户界面元素

图 5 中显示的图形是在 Adobe Illustrator 中创建的。使用任何图形编辑器创建类似的东西。在 UI 场景中,创建一个新的HUD演员,并将你创建的图像与之关联。一般来说,你可以在任何编辑器中创建任何你想要的图形。我们创建自定义界面的方法涉及我们在自定义图形上叠加不可见的 Greenfoot 演员,并且图形不需要是任何特定的形状或大小。

这里是我们 UI 场景中HUD类的代码:

import greenfoot.*;

public class HUD extends Actor {
  private TransparentRectangle home;
  private TransparentRectangle favorite;
  private TransparentRectangle print;
  private TransparentRectangle cart;
  private static final int W = 70;
  private static final int H = 70;

  protected void addedToWorld(World w) {
    home = new TransparentRectangle(W,H);
    w.addObject(home,
    getX()-getImage().getWidth()/2+W/2,
    getY());
    favorite = new TransparentRectangle(W,H);
    w.addObject(favorite, getX()-W+20, getY());
    print = new TransparentRectangle(W,H);
    w.addObject(print, getX()+W-10, getY());
    cart = new TransparentRectangle(W,H);
    w.addObject(cart,
    getX()+getImage().getWidth()/2-W/2,
    getY());
  }

  private class TransparentRectangle extends Actor {
    public TransparentRectangle(int w, int h) {
      GreenfootImage img = new GreenfootImage(w,h);
      setImage(img);
    }
  }

  public void act() {
    handleMouseClicks();
  }

  private void handleMouseClicks() {
    if( Greenfoot.mouseClicked(home) ) {
      System.out.println("Clicked Home");
    }
    if( Greenfoot.mouseClicked(favorite) ) {
      System.out.println("Clicked Favorite");
    }
    if( Greenfoot.mouseClicked(print) ) {
      System.out.println("Clicked Print");
    }
    if( Greenfoot.mouseClicked(cart) ) {
      System.out.println("Clicked Cart");
    }
  }
}

如前文片段所示,与这个类相关的代码并不多。代码创建了四个新的无形演员,并将它们放置在我们希望用户能够点击的我们的自定义 UI 中的对象上。在 addedToWorld() 方法中,我们创建了家、收藏、打印和购物车演员来覆盖 图 5 中显示的家、收藏、打印和购物车图标。这个方法中特定于 图 5 中图形的部分是放置无形演员的位置。如果你创建了一个与我展示不同的图形,那么你需要自己确定放置新演员的正确位置。

你可能已经注意到我们创建的无形演员是名为 TransparentRectangle 的内部类的实例。这是我们在这本书中第一次使用内部类,它们值得一些讨论。在最简单的层面上,内部类只是定义在另一个类内部的类,因此通常无法被项目中其他类访问。以下信息框包含有关内部类的更多信息。

注意

关于内部类的更多内容

在面向对象的设计中,你通过将问题分解成更小的对象,然后仔细构建这些对象如何通信或协作来解决一个问题。这是一个自上而下的设计示例(在第一章中讨论,Let's Dive Right in…),我们将问题分解成越来越小的子问题。有时,一个类的内部状态可能非常复杂,使用内部类可能有助于管理这种内部复杂性。本质上,这是一种层次化的面向对象设计。

内部类的另一个用途是封装只对项目中一个类有非常特定用途的类。例如,我们的 HUD 类是我们场景中唯一使用 TransparentRectangle 类的类。通过在 HUD 中隐藏 TransparentRectangle,没有其他类会暴露给 TransparentRectangle。你将注意到在 Greenfoot 中,TransparentRectangle 并未出现在主场景窗口的 Actor 类… 部分中。

关于内部类(以及嵌套类)的更多信息,请参阅以下文章:www.javaworld.com/article/2077411/core-java/inner-classes.html

最后两个方法,act()handleMouseClicks(),遵循处理演员上鼠标点击的常见模式,我们在本书中已经多次看到,并在此再次讨论。与我们在本场景中创建的 Menu 演员一样,当用户点击其中一个图标时,我们会向控制台打印一条消息。

让我们现在测试整个场景。记住在 UIMainWorld 中的 testActors() 方法中取消注释创建并添加到场景中的 HUD 演员记得编译并确保当你点击各种图标时,控制台会收到消息。

向 MazeWorld 添加 UI

现在我们已经有一些创建各种 UI 元素的经验,我们将增强上一章的 MazeWorld 场景。这将给我们一个机会在一个更真实的环境中练习我们所学到的知识。

具体来说,我们将添加:

  • 一个带有开始游戏按钮和玩家可以使用以指示游戏难度模式的菜单的起始界面

  • 一个游戏结束界面,玩家可以使用按钮重新开始游戏

  • 一个玩家可以使用它来暂时击昏敌人、减慢他们的速度或让蛇形敌人说,“sssssssss”

从上一章结束的 MazeWorld 代码开始,或者从www.packtpub.com/support下载。

添加菜单和按钮

在本节中,我们将向MazeWorld添加一个介绍界面和游戏结束界面。我们将向介绍界面(如图图 6所示)添加一个按钮、文本框和菜单,而游戏结束界面(如图图 7所示)只添加一个按钮。

添加菜单和按钮

图 6:这是我们添加到 MazeWorld 的新介绍界面

这就是游戏结束界面的样子。

添加菜单和按钮

图 7:这是我们添加到 MazeWorld 的新游戏结束界面

我们在第一章中创建了一个介绍界面和游戏结束界面,让我们直接深入…,并在第五章中增强了游戏结束界面,交互式应用程序设计和理论,以避免游戏,所以这些屏幕添加到 MazeWorld 中只会简要介绍。

首先,我们将创建一个新的类,这两个屏幕都将继承它。创建一个新的World类的子类,命名为MazeWorldScreens;不要将图像与此类关联,并添加以下代码到它中:

import greenfoot.*;

public class MazeWorldScreens extends World
{
  int playMode = 0;

  public MazeWorldScreens() {
    super(600, 400, 1);
  }

  public void startGame() {
    MazeWorld mw = new MazeWorld(playMode);
    Greenfoot.setWorld(mw);
  }

}

介绍界面和游戏结束界面都需要存储用户选择的难度级别(在playMode实例变量中)并实现一个启动游戏的方法,因为它们都有一个Play MazeWorld按钮。这种共性被MazeWorldScreens类捕捉。startGame()方法将游戏模式传递给一个新的 MazeWorld 实例,然后切换场景到那个世界。

MazeWorldIntroMazeWorldGameOver类作为MazeWorldScreens的子类创建。确保创建一个看起来像图 6的介绍界面图像(不包含 UI 元素)和一个看起来像图 7的游戏结束界面图像(不包含 UI 元素),并将它们作为新类的图像选择。我们的图像不需要包含 UI 元素,因为我们将在这些屏幕上动态添加它们。

一旦创建了这些World类,你应该能在你的主要 Greenfoot 场景屏幕的World 类区域看到图 8所示的内容。

添加菜单和按钮

图 8:这显示了 MazeWorld 中 World 类的类层次结构

这是你需要添加到MazeWorldIntro类的代码:

import greenfoot.*;
import java.awt.Color;

public class MazeWorldIntro extends MazeWorldScreens {
  TextBox mode;

  public MazeWorldIntro() {
    super();
    prepare();
  }

  public void setMode(String s, int i) {
    mode.setText(s);
    playMode = i;
  }

  private void prepare() {
    PlayButton pb = new PlayButton(
    "playButton1.png", "playButton2.png");
    addObject(pb, 200, 250);
    Menu m = new Menu(" Choose game difficulty...",
    "Easy\nMedium\nHard ", 18,
    Color.BLUE, Color.WHITE,
    Color.BLACK, Color.WHITE,
    new GameDifficultyCommands());
    addObject(m, 400, 250);
    mode = new TextBox(" Play the game in Easy Mode ",
    28, true, Color.BLUE, Color.WHITE);
    addObject(mode, 300, 300);
  }

}

prepare()方法将 UI 元素添加到介绍屏幕。为了清晰起见,图 9显示了添加的具体元素的特写视图。播放按钮使用我创建的两个图像(一个用于按钮按下状态,另一个用于按钮的正常状态)。你需要创建自己的图像或使用 Greenfoot 提供的两个默认图像之一。Menu类的一个实例放置在按钮旁边。此菜单将允许用户指定他们想要在简单、中等或困难模式中玩游戏(稍后,我们将更改MazeWorld类以尊重这些选择)。为了完成菜单的功能,我们需要提供一个实现MenuCommands接口的类。在这种情况下,我们传递一个GameDifficultyCommands对象。最后,我们添加一个TextBox实例以显示游戏的当前难度级别。如果用户选择不同的难度级别,消息将更改。

添加菜单和按钮

图 9:这是 MazeWorld 介绍屏幕上 UI 元素的特写视图。

与 UI 示例场景一样,你需要在你的场景中添加MenuCommands接口。为了方便,我在这里复制了MenuCommands接口的代码:

import greenfoot.*;

public interface MenuCommands {
  public void execute(int idx, World w);
}

GameDifficultyCommands类实现了MenuCommands接口,并为弹出菜单中提供的菜单选项提供了适当的命令。以下是GameDifficultyCommands的代码:

import greenfoot.*;

public class GameDifficultyCommands implements MenuCommands {
  public void execute(int idx, World w) {
    MazeWorldIntro mwi = (MazeWorldIntro) w;
    switch(idx) {
      case 0:
      mwi.setMode(" Play the game in Easy Mode ", idx);
      break;
      case 1:
      mwi.setMode(" Play the game in Medium Mode ",
      idx);
      break;
      case 2:
      mwi.setMode(" Play the game in Hard Mode ", idx);
      break;
    }
  }
}

对于每个菜单选项,GameDifficultyCommands类中的execute()方法调用我们在MazeWorldIntro类中定义的setMode()方法。此方法更改介绍屏幕上TextBox的消息,并存储用于后续使用的难度模式。

MazeWorldGameOver类更简单,因为它只需要添加一个播放按钮。以下是MazeWorldGameOver类的代码:

import greenfoot.*;
public class MazeWorldGameOver extends MazeWorldScreens {

  public MazeWorldGameOver(int pm) {
    super();
    prepare();
    playMode = pm;
  }

  private void prepare() {
    PlayButton pb = new PlayButton("playButton1.png",
    "playButton2.png");
    addObject(pb, 420, 330);
  }
}

游戏结束屏幕需要通过构造函数中的pm参数变量传递难度级别,以便在玩家点击Play MazeWorld按钮再次游戏时将其传递给MazeWorld

当然,这不会工作,因为我们还没有添加在UIWorldScenario中创建的MenuTextBoxButton类。这些类将与我们在本章前面讨论过的类相同或非常相似。我们现在将查看代码,并只讨论差异。

首先,为了方便地将 UI 类分组在一起,让我们创建一个空类,命名为UI,它们都可以继承。这是 Greenfoot 中的一种有用的组织技术,其中你可能有一个包含数百个演员的项目。随着我们通过本节和下一节,我们将创建图 10中显示的类层次结构。

添加菜单和按钮

图 10:这显示了 MazeWorld 中 UI 元素的类结构

这是UI的代码:

import greenfoot.*; 

public class UI extends Actor {  
}

TextBoxButtonMenu的代码与我们在本章开头的工作场景中的 UI 示例场景中的代码完全相同。现在以完全相同的方式将它们添加到MazeWorld场景中,除了一个小变化。这些类将继承自UI而不是Actor

最后,我们需要创建PlayButton类。这个类扩展了Button类(如图 9 所示)并包含以下代码:

import greenfoot.*;

public class PlayButton extends Button {

  public PlayButton(String f, String s) {
    super(f,s);
  }

  protected void clickedAction() {
    MazeWorldScreens mws = (MazeWorldScreens) getWorld();
    mws.startGame();
  }
}

这个类覆盖了在Button类中找到的空clickedAction()方法。当用户点击PlayButton实例时,会调用startGame()方法。这是我们之前在MazeWorldScreens中实现的方法。

我们刚刚添加了大量代码。我们处理得相当快,因为大部分我们添加的代码都在本章的第一部分和前面的章节中解释过了。我们还需要添加一些内容来完成这个 MazeWorld 的新版本。我们需要添加一个抬头显示,然后增强MazeWorld类,以便游戏可以根据用户选择的难度模式进行游戏。

提示

您应该尽可能频繁地测试您的代码。有时,您可能需要对代码进行一些小的、简单的/临时更改,以便能够测试它。例如,如果我们更改MazeWorld类的构造函数以接受一个整数参数,那么我们就可以在此时编译并运行代码。

添加 HUD

我们将为游戏中的主要角色添加一组简单的动作。图 11显示了我们要添加的三个控制器。如果用户点击第一个图标,敌人将暂时昏迷。如果用户点击第二个图标,敌人将暂时移动得更慢。如果用户点击最后一个图标,蛇形敌人会说,“sssssssss”。让蛇嘶嘶叫并不能真正帮助玩家赢得游戏。我只是觉得我们可以添加一些有趣的东西。

添加 HUD

图 11:这显示了我们要添加到 MazeWorld 的一组控制器

图 12是放置在游戏中的控制器的特写视图;我们将它们添加到屏幕的底部中间。

添加 HUD

图 12:这显示了游戏中的抬头显示

使用您喜欢的图形编辑器,创建与图 12中显示的图片类似的东西。我使我的图形相当小,以便它完全包含在游戏的底部黑色边框中。

一旦您有了合适的图形,创建MazeWorldHUD类作为UI的子类。将您刚刚创建的图形与之关联,并添加以下代码:

import greenfoot.*;

public class MazeWorldHUD extends UI {
  private TransparentRectangle stun;
  private TransparentRectangle slow;
  private TransparentRectangle talk;
  private static final int W = 29;
  private static final int H = 22;

  protected void addedToWorld(World w) {
    stun = new TransparentRectangle(W,H);
    w.addObject(stun, getX()-W, getY());
    slow = new TransparentRectangle(W,H);
    w.addObject(slow, getX(), getY());
    talk = new TransparentRectangle(W,H);
    w.addObject(talk, getX()+W, getY());

  }

  private class TransparentRectangle extends Actor {
    public TransparentRectangle(int w, int h) {
      GreenfootImage img = new GreenfootImage(w,h);
      setImage(img);
    }
  }

  public void act() {
    handleMouseClicks();
  }

  private void handleMouseClicks() {
    MazeWorld mw = (MazeWorld) getWorld();
    if( Greenfoot.mouseClicked(stun) ) {
      mw.stunAllEnemies();
    }
    if( Greenfoot.mouseClicked(slow) ) {
      mw.slowAllEnemies();
    }
    if( Greenfoot.mouseClicked(talk) ) {
      mw.makeSnakesTalk();
    }
  }
}

代码与我们在 UI 示例场景中添加的 HUD 不同,我们现在有三个控制按钮而不是四个,并且handleMouseClicks()方法执行此场景的适当操作。在addedToWorlds()中,我们创建三个TransparentRectangle对象,并将它们放置在我们图像中的三个图标(stun、slow 和 talk)上。在handleMouseClicks()中,我们获取当前World对象的引用,并对其调用以下三个方法之一:stunAllEnemies()slowAllEnemies()makeSnakesTalk()

这就完成了向MazeWorld添加 HUD。接下来,我们需要修改MazeWorld类,根据玩家选择的播放模式更改游戏,并实现stunAllEnemies()slowAllEnemies()makeSnakesTalk()方法。

实现游戏难度设置和 HUD 控制

在我们的新版本MazeWorld准备好之前,我们有一些事情要处理。首先,我们需要将玩家在介绍屏幕上选择的难度级别整合到游戏中,并且我们需要实现我们添加到游戏中的 HUD 的功能。这些更改涉及三个类:MazeWorldScrollingEnemySnake

这里是带有所需更改高亮的MazeWorld代码:

import greenfoot.*;
import java.util.List;
import java.util.ListIterator;
import java.util.Stack;

public class MazeWorld extends World {
  private int xOffset = 0;
  private Hiker hiker;
  private final static int SWIDTH = 600;
  private final static int SHEIGHT = 400;
  private final static int WWIDTH = 1200;
  private final static int TWIDTH = 25;
  private final static int THEIGHT = TWIDTH;
  private final static int TILEOFFSET = TWIDTH/2;
  private final static String validSpaces = "WG";
  private int playMode = 0;

  private final static String[] WORLD = {
    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWUWWWWB",
    "BWWWWWWWWWWWWWUUWWWWWWWWUUUUUUUWWWWWWWWWWWUWWWWB",
    "BWWWWWUUUUUWWWUUUWWWWWWWWWWWWWWWWWWWWWWWWWUWWWWB",
    "BWWWWWUUUUUWWWWWWWWWWWWWWWWWWWWWWWWWUWWWWUUUWWWB",
    "BWWWWWWWWWWWWWWWWWUUUUUWWWWWWWWUUUUUUWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWUUUUWWWWWWWWWUUUUUUUUWWWWWWWWB",
    "BWWWWUUUUUUUWWWUWWWWWWWWWWWWWWWUWWWWWWWWWWWWWWWB",
    "BWWWWWWWUUUWWWWUWWWWWWWWWWUWWWWUWWWWWWWWWWWWWWWB",
    "BWWWWWWWWWWWWWWWWWWWWWWWWWUWWWWWWWWWWWWWWWWWUWWB",
    "BWWWWWWWWWWWWWWWWWWWUUUUUUUWWWWWWWWWUUUUWWWWUWWB",
    "BWWWWWWWWWWWWWUUWWWWUWWWWWWWWWWWWWWWUUUUWWWWUWWB",
    "BWWWWWWWUUUUUUUUUWWWWWWWWWWWWWWWWWWWUUUUUUWWUWWB",
    "BWWWWWWWUUUUUUUUUWWWWWWWWWUUWWWWWWWWWWWWWWWWUWWB",
    "BWWWWWWWUWWWWWWWWWWWWWWWWWUUWWWWWWWWWWWWWWWWUWGB",
    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
  };

  /* constructors */
 public MazeWorld() {
 this(0);
 }

 public MazeWorld(int pm) {
 super(SWIDTH, SHEIGHT, 1, false);
 playMode = pm;
 createWorldFromTiles();
 shiftWorld(0);
 prepare();
 }

  /* ability methods */
  public void shiftWorld(int dx) {
    if( (xOffset + dx) <= 0
    && (xOffset + dx) >= SWIDTH - WWIDTH) {
      xOffset = xOffset+dx;
      shiftWorldActors(dx);
    }
  }

  /* accessor methods */
  public int getTileWidth() {
    return TWIDTH;
  }

  public int getTileHeight() {
    return THEIGHT;
  }

  public int getTileOffset() {
    return TILEOFFSET;
  }

  public String[] getStringWorld() {
    return WORLD;
  }

  public int getXHiker() {
    return hiker.getX()-xOffset;
  }

  public int getYHiker() {
    return hiker.getY();
  }

  public String getValidSpaces() {
    return validSpaces;
  }

  public void stunAllEnemies() {
 List<ScrollingEnemy> le =
 getObjects(ScrollingEnemy.class);
 ListIterator<ScrollingEnemy> listItr = le.listIterator();
 while( listItr.hasNext() ) {
 ScrollingEnemy se = listItr.next();
 se.stun();
 }
 }

 public void slowAllEnemies() {
 List<ScrollingEnemy> le =
 getObjects(ScrollingEnemy.class);
 ListIterator<ScrollingEnemy> listItr = le.listIterator();
 while( listItr.hasNext() ) {
 ScrollingEnemy se = listItr.next();
 se.slow();
 }
 }

 public void makeSnakesTalk() {
 List<Snake> le = getObjects(Snake.class);
 ListIterator<Snake> listItr = le.listIterator();
 while( listItr.hasNext() ) {
 Snake s = listItr.next();
 s.talk();
 }
 }

 public void gameOver() {
 MazeWorldGameOver mwgo = new MazeWorldGameOver(playMode);
 Greenfoot.setWorld(mwgo);
 }

  /* private methods */
  private void shiftWorldActors(int dx) {
    List<ScrollingActor> saList =
    getObjects(ScrollingActor.class);
    for( ScrollingActor a : saList ) {
      a.setAbsoluteLocation(dx);
    }
  }

  private void createWorldFromTiles() {
    for( int i=0; i < WORLD.length; i++ ) {
      for( int j=0; j < WORLD[i].length(); j++ ) {
        addActorAtTileLocation(WORLD[i].charAt(j), j, i);
      }
    }
  }

  private void addActorAtTileLocation(char c, int x, int y) {
    Actor tile = null;
    switch(c) {
      case 'W':
      tile = new WhiteBlock();
      break;
      case 'B':
      tile = new BlackBlock();
      break;
      case 'U':
      tile = new BlueBlock();
      break;
      case 'G':
      tile = new GoldBlock();
      break;
    }
    if( tile != null) addObject(tile, TILEOFFSET+x*TWIDTH,
    TILEOFFSET+y*THEIGHT);

  }

  private void prepare()
  {
    hiker = new Hiker();
    addObject(hiker, 80, 200);
    addObject(new MazeWorldHUD(), 300, 387);
    addObject(new Mouse(), 60,40);
    addObject(new Spider(), 1000,40);
    addObject(new Spider(), 120,340);
    addObject(new Spider(), 1050,250);
    addObject(new Snake(), 1050,250);
    addObject(new Mouse(), 1000,200);
    addObject(new Snake(), 400,260);
    if( playMode >= 1 ) {
 addObject(new Snake(), 80,40);
 if( playMode == 2 ) {
 addObject(new Mouse(), 50,350);
 }
 }
  }
}

我们将通过改变迷宫中需要避免的敌人数量来实现不同的难度级别。首先,我们创建playMode实例变量来存储难度级别。接下来,我们需要添加另一个接受整数参数的构造函数。为此,我们需要将没有参数的老构造函数改为有一个参数,并添加一行代码将playMode实例变量设置为该参数——其余保持不变。然后我们可以添加一个新的构造函数,它没有参数,并简单地调用其他构造函数方法,传入值为0(这对应于简单模式)。最后,在prepare()方法中,我们在方法末尾添加代码来检查是否根据playMode的值添加更多演员到游戏中。如果playMode1,则添加一个额外的蛇。如果是2,则添加一个额外的蛇和老鼠到游戏中。

接下来,我们需要将stunAllEnemies()slowAllEnemies()makeSnakesTalk()方法添加到MazeWorld中。每个方法都使用 Greenfoot 的World方法getObjects()来获取所有指定类型的对象列表。当将ScrollingEnemy.class传递给getObjects()时,我们得到所有当前敌人的列表。当将Snake.class作为参数传递给getObjects()方法时,我们得到当前场景中所有Snake对象的列表。然后我们遍历对象列表,分别对对象调用stun()slow()talk()

由于所有敌人都是从ScrollingEnemy继承的,因此我们可以在该类中实现stun()slow()

这里是带有所需更改高亮的ScrollingEnemy代码:

import greenfoot.*;

abstract public class ScrollingEnemy extends ScrollingActor {
  protected static final int SPEED = 1;
  private static final int BOUNDARY = 40;
  protected int speedX = SPEED;
  protected int speedY = SPEED;
  private int stunTime = 0;
 private int slowTime = 0;
 private boolean stunned = false;
 private boolean slowed = false;

  /* initialization */
  protected void addedToWorld(World w) {
    MazeWorld mw = (MazeWorld) w;
    GreenfootImage img = getImage();
    img.scale(mw.getTileWidth(),mw.getTileHeight());
    setImage(img);
  }

  public void stun() {
 if( stunned == false ) {
 stunned = true;
 stunTime = 100;
 }
 }

 public void slow() {
 if( slowed == false ) {
 slowed = true;
 slowTime = 400;
 }
 }

  /* ability methods */
  public void act() {
    if( !stunned ) {
 if( slowTime > 0 ) {
 slowed = (slowTime-- % 2) == 0;
 }
 if( !slowed ) {
        sense();
        reaction();
        boundedMove();
      }
 } else {
 if( stunTime-- < 0 ) {
 stunTime = 0;
 stunned = false;
 }
 }
  }

  protected void sense() {
    // No smarts
  }

  protected void reaction() {
    // No reaction
  }

  protected void boundedMove() {
    setLocation(getX()+speedX, getY()+speedY);
    if( isTouching(ScrollingObstacle.class) ) {
      setLocation(getX()-speedX, getY()-speedY);
    }
  }
}

Snake类的开始部分,我们添加了四个实例变量。其中两个变量存储有关敌人被击晕(stunTime)和减速(slowTime)的信息,另外两个变量跟踪我们是否处于被击晕(stunned)或减速(slowed)的状态。

当玩家击晕ScrollingEnemy对象时,将对该对象调用stun()方法(正如我们在关于MazeWorld的讨论中所见)。如果对象当前处于击晕状态,则stun()方法将不执行任何操作。如果不是,该方法将stunned设置为true并将stunTime设置为100。这些值在act()方法中用于实现击晕对象。slow()方法与stun()方法几乎相同,只是将slowTime设置为400。这意味着减速对象的持续时间比击晕时间长。

act()中,我们检查stunned布尔变量的值,如果stunnedtrue,则跳过调用sense()reaction()boundedMove()方法。stunTime变量作为延迟变量(在第二章中介绍,动画)。如果我们没有被击晕,则act()方法将继续检查slowed变量。如果没有减速,我们按正常进行。slowTime变量作为延迟变量;然而,由于它正在倒计时,它会切换slowed的值。这种切换将sense()reaction()boundedMove()方法约束为仅在act()方法的每次调用中执行一次。这使得减速时敌人移动速度减半。

由于蛇是唯一需要说话的,我们将talk()方法的实现直接放入Snake类中。

下面是带有所需更改高亮的Snake代码:

import greenfoot.*;
import java.util.List;
import java.awt.Color;

public class Snake extends ScrollingEnemy {
  private static final int PATHLENGTH = 200;
  private static final int INRANGE = 100;
  private int pathCounter = PATHLENGTH;
  private boolean pathing = false;
  private int rememberSpeedX = 0;
  private List<Hiker> lse;
  private boolean talking = false;
 private int talkTime = 0;
 private TextBox sss;

  /* constructors */
  public Snake() {
    speedX = rememberSpeedX = SPEED;
    speedY = 0;
  }

  public void talk() {
 if( talking == false ) {
 talking = true;
 talkTime = 100;
 sss = new TextBox(" sssssss ", 14, true,
 Color.BLACK, Color.WHITE);
 getWorld().addObject(sss, getX()-20, getY()-20);
 }
 }

  /* ability methods */
  protected void sense() {
    // If near, move towards enemy
    lse = getObjectsInRange(INRANGE,Hiker.class);
    pathing = lse.isEmpty();
  }

  protected void reaction() {
    if( pathing ) {
      speedX = rememberSpeedX;
      speedY = 0;
      if( --pathCounter == 0 ) {
        pathCounter = PATHLENGTH;
        speedX = rememberSpeedX = -speedX;
      }
    } else {
      speedX = lse.get(0).getX() > getX() ? 1 : -1;
      speedY = lse.get(0).getY() > getY() ? 1 : -1;
    }

    if( talking ) {
 sss.setLocation(getX()-20, getY()-20);
 if( talkTime-- < 0 ) {
 talking = false;
 talkTime = 0;
 getWorld().removeObject(sss);

 }
    }
  }
}

ScrollingEnemy类中stun()slow()的实现类似,我们需要一个延迟变量(talkTime)和一个布尔值(talking)来实现talk()方法。此外,我们还需要一个变量来存储TextBoxsss),它将包含sssssss文本。talk()方法的结构与stun()slow()相同。然而,talk()方法还必须创建TextBox并将其添加到世界中。

reaction()方法中,我们可以看到如果Snake对象处于说话状态,则sss TextBox将在对象位置偏移一段时间后显示,这段时间由talkTime实例变量指定。一旦talkTime到期,它还必须从世界中移除sss TextBox变量。

恭喜!你已经完成了我们新的 MazeWorld 版本。编译并尝试运行它。点击游戏中的击晕、减速和说话动作。如果你在游戏中遇到任何问题或错误,并且难以解决,请将你的版本与完成的版本在www.packtpub.com/support进行比较。

小贴士

MazeWorld 场景仅是为了帮助展示第七章中涵盖的概念,即人工智能和当前章节。因此,它实际上并不好玩,但它确实有很大的潜力。利用你在第五章,交互式应用程序设计和理论中获得的游戏设计知识,尝试对 MazeWorld 进行修改,以增强其可玩性。

摘要

你现在正式成为了一名 Greenfoot 编程忍者。你懂得如何创建包含生动智能演员的 Greenfoot 游戏和模拟,这些演员拥有各种方法,允许用户/玩家进行交互。你可以实现键盘/鼠标控制、按钮、菜单和自定义界面。

在下一章中,我们将为我们的 Greenfoot 场景添加游戏手柄控制器支持。游戏手柄是捕捉用户输入的绝佳方式,尤其是对于游戏来说。

第九章。Greenfoot 中的游戏手柄

*"只有你能掌控你的未来。"
--苏斯博士

在本章中,我们将介绍如何在 Greenfoot 场景中连接和使用游戏手柄控制器。您提供给用户的控制方案对其体验有着真正的影响。想象一下,如果您必须按U向上移动,D向下移动,L向左移动,R向右移动,您将如何玩我们在这本书的前两章中创建的 Avoider 游戏版本。就像一个糟糕的布局会令用户沮丧一样,一个好的布局会感觉非常自然。

游戏手柄旨在提升游戏体验。它们为玩家提供了一种自然且便捷的方式,来表达他们的决策给游戏,而不会影响游戏本身。在游戏历史的早期,游戏手柄以简单的摇杆形式出现,只有一个按钮用于射击。如今,典型的控制器拥有超过 10 个按钮、模拟摇杆、模拟扳机和数字 D-pad。许多控制器还允许用户构建自定义宏。

在本章中,您将学习如何:

  • 将游戏手柄连接到您的 Greenfoot 场景

  • 使用 Greenfoot GamePad API 监听和响应各种游戏手柄事件

  • 使用控制器映射软件将不兼容的游戏手柄连接到 OS X

将游戏手柄支持添加到您的场景中是增加您创建的游戏可玩性的好方法。它还为您的工作增添了一种专业性。在了解如何连接游戏手柄之后,我们将增强我们在第一章,“让我们直接进入…”,和第二章,“动画”中创建的 Avoider 游戏,使用户能够在使用鼠标或游戏手柄控制游戏之间进行选择。

游戏手柄概述

目前市场上针对 PC 和 Mac 都有许多种类的游戏手柄。有些类似于为流行的游戏机系统(如 Xbox、PlayStation 和 Nintendo)制作的控制器,而有些则拥有自己独特的设计和能力。图 1 展示了一个典型的游戏手柄。这些游戏手柄的设计是为了将许多控制选项置于触手可及之处。

图 1 识别了游戏手柄控制器的几个常见分组。D-pad 是一种常用于允许玩家指示方向的控件(因此,名字中的 D)。它相当平坦,专为拇指使用设计。模拟摇杆作为控制器上的小型摇杆,允许快速和精确的位置控制。例如,一些游戏可能使用它们允许玩家在 3D 世界中环顾四周或瞄准武器。在为 Xbox(以及其他流行游戏机)设计的控制器中,模拟摇杆也可以按下,提供两个额外的动作按钮。动作按钮为用户提供了一种在游戏中指定动作的方法(见 图 1)。这些按钮通常控制跳跃、射击、蹲下和阻挡等动作。最后,我们有辅助按钮,可能用于启动游戏、暂停游戏、重置游戏或简单地提供更多动作按钮。

游戏手柄概述

图 1:这是游戏手柄控制器的典型布局

对于许多游戏来说,游戏手柄将为玩家提供最佳的用户界面(和用户体验)。在本章中,我们将讨论如何将如图 1 所示的控制器连接到您的 Greenfoot 场景。您将能够将 D-Pad、模拟摇杆和动作按钮分配给您选择的用户允许的能力。

Windows 设置

您可以选择数百种游戏手柄,用于购买您的 PC 或 Mac。在本节中,我们将介绍如何设置 Windows 的 Xbox 360 控制器。如果您购买了不同的控制器,请确保根据游戏手柄提供的说明安装相应的驱动程序。如果您有 Mac 并且拥有官方支持 OS X 的游戏手柄,那么这里的说明也应该适用于您。在本章末尾,我们将探讨您如何在 Mac 上使用支持不佳的游戏手柄。

连接您的控制器

在开始 Greenfoot 之前,将您的 Windows Xbox 360 控制器连接到您的 PC,并允许微软更新搜索、下载和安装游戏手柄所需的驱动程序。这需要 5-15 分钟,具体取决于您的网络连接速度。如果您有任何问题,请尝试遵循 support.xbox.com/en-US/xbox-on-other-devices/windows/xbox-controller-for-windows-setup 提供的说明。

Greenfoot 游戏手柄软件

从 Greenfoot 网站上,您可以下载一个模板来构建带有游戏手柄支持的 Greenfoot 场景。这个模板基本上是一个空白 Greenfoot 场景,其中包含了您可以用来访问和控制游戏手柄的附加库。您可以在 www.greenfoot.org/doc/gamepad 下载游戏手柄项目模板。

当您希望创建一个带有游戏手柄支持的场景时,您需要执行以下步骤:

  1. 将从上一个 URL 下载的GamePadTemplate.zip文件移动到您选择的目录。

  2. 解压GamePadTemplate.zip

  3. 将之前步骤中创建的GamePadTemplate文件夹重命名为您希望新场景拥有的名称。

  4. 打开场景并添加您的更改。

图 2显示了完成前面的步骤后您的新 Greenfoot 场景将看起来是什么样子。正如您所看到的,您将像通常一样子类化WorldActor类以向场景添加内容。您还提供了两个额外的类,在其他类部分中可以看到,您将使用这些类来连接和管理游戏手柄。

Greenfoot 游戏手柄软件

图 2:这是一个由游戏手柄模板构建的新 Greenfoot 场景。图中所示的场景被重命名为"Fun"

我们将在下一节讨论GamePadDirection类。

Greenfoot 游戏手柄 API

Greenfoot 游戏手柄 API 支持图 1中显示的所有控制,除了顶部只有两个辅助按钮(橙色)。首先,我们将从概念层面讨论 API,然后查看实现 API 的具体类。

概述

理论上,从游戏手柄接收用户输入与从键盘接收输入非常相似。我们将轮询游戏手柄上的按钮和模拟摇杆,以查看它们是否当前被按下。模拟摇杆稍微复杂一些,因为它们的状态不仅限于被按下或未被按下。对于它们,你需要知道它们被推的方向和推力的大小。

由于可能有多个游戏手柄连接到您的计算机,因此 API 还提供了访问所有游戏手柄以及仅连接到您指定的游戏手柄的方法。

正如我们在游戏手柄模板场景中看到的,游戏手柄 API 在两个类中实现。第一个是GamePad类,第二个是Direction类。

注意

静态关键字

在 Java 中,你可以使用一个关键字来改变变量和方法如何被访问以及如何管理它们的内存。这个关键字被称为static。将此关键字添加到类变量或方法的声明中,确保无论创建了该类的多少个对象,该变量或方法都只存储一次。例如,如果你声明了一个名为counter的变量并将其初始值设置为1,那么类的所有对象都会看到该变量的值为1。如果一个对象增加了counter,那么所有创建的对象现在都会看到该变量的值为2

当用于方法时,可以在不创建该类实例的情况下调用这些方法。例如,Greenfoot 类中包含的许多方法都是static的,例如getRandomNumber()setWorld()。请注意,当我们调用这些方法时,我们并没有创建类的实例。我们只需添加以下代码:

int randomNumber = Greenfoot.getRandomNumber(10);

游戏手柄和方向类

GamePad 类是一种称为 单例 的特殊类型的类。对于单例类,构造函数被声明为 private;因此,类外部的代码不能创建该类的新实例。所有其他尝试创建新对象的尝试都将失败,并显示一个错误信息,指出构造函数具有私有访问权限。这是一个单例类,因为你想要确保只有一个对象代表游戏手柄控制器。

你将从这个类中常用到的几个方法是 getGamePad()isDown()getAxis()runConfigurePad()。要在你的场景中使用游戏手柄,你需要首先调用 getGamePad() 方法。此方法将返回一个 GamePad 对象,它代表连接到你的计算机的游戏手柄控制器。以下是其使用示例:

GamePad pad = GamePad.getGamePad();

一旦你有了控制器的 GamePad 对象,你可以通过调用 isDown() 方法来检查用户是否按下了动作按钮(如图 1 所示)。isDown() 方法的使用方式与我们在检测键盘输入时使用的 isKeyDown() Greenfoot 方法完全相同。为了检测键盘输入,我们提供我们感兴趣的键的名称。对于游戏手柄,你使用 GamePad.Button 枚举来指定你感兴趣的按钮,该枚举提供了以下与游戏手柄按钮对应的标签:ACTION_DOWNACTION_LEFTACTION_RIGHTACTION_UPL1L2L3R1R2R3SELECTSTART。因此,为了确定用户是否按下了图 1 中显示的蓝色动作按钮,你会使用以下代码行:

if( pad.isDown(GamePad.Button.ACTION_UP) ) {
  System.out.println("The ACTION_UP key is being pressed.");
}

从模拟摇杆获取用户输入是一个两步的过程。首先,你以以下方式从模拟摇杆获取方向信息:

Direction direction = getGamePad().getAxis(GamePad.Axis.LEFT );

Axis 枚举提供了你可以用来指定方向键、左模拟摇杆或右模拟摇杆的标签。标签分别是 DPADLEFTRIGHT。其次,一旦你有了 Direction 对象,你可以确定模拟摇杆被推的方向和推的程度。以下两行代码演示了如何提取这些信息:

int angle = direction.getAngle();
float strength = direction.getStrength();

你将经常使用的最后一个方法是 runConfigurePad() 方法。此方法将提供一个用户界面,用户可以使用它来指定他们的游戏手柄上的控制如何映射到 GamePad.Button 枚举和 GamePad.Axis 枚举中提供的标签。这是必需的,因为并非所有游戏手柄都有相同的布局。

更多信息,请参阅此类的官方文档:www.greenfoot.org/files/gamepad/GamePad.html

注意

单例类

设计模式是针对已知或常见问题的解决方案。它们提供了一个程序员可以轻松遵循的蓝图。Java 中最常用的设计模式之一是单例模式。当您想要确保一个类只有一个实例时,您会使用这个设计模式。这有什么用呢?好吧,想象一下,您想在应用程序中管理和共享资源,比如打印机或网络连接。只允许创建一个代表该单一资源的对象要简单得多,也高效得多。遵循单例设计模式的类强制执行此行为。

使用游戏手柄的避免者游戏

我们已经讲解了如何将游戏手柄连接到 Greenfoot 场景以及如何使用 Gamepad API。现在,是时候编写代码了。我们将为我们在第二章中创建的避免者游戏版本添加游戏手柄支持,该版本在动画中完成。您可以在www.packtpub.com/support获取该场景的副本。

我们需要对避免者游戏进行两项主要更改。首先,我们需要添加一个与我们的控制器关联的GamePad对象的引用,并在该场景中的三个世界之间传递该引用:AvoiderGameIntroScreenAvoiderWorldAvoiderGameOverWorld。其次,如果存在,我们需要将Avatar类更改为由游戏手柄控制;否则,我们默认使用鼠标控制。

以下代码中没有显示整个AvoiderWorld类;只显示了需要更改的方法。以下是AvoiderWorld的更改:

private GamePad pad;

public AvoiderWorld(GamePad p) {
  super(600, 400, 1, false);

  bkgMusic = new GreenfootSound("sounds/UFO_T-Balt.mp3");
  // Music Credit: T-Balt at
  // http://www.newgrounds.com/audio/listen/504436
  bkgMusic.playLoop();

  // set gamepad
 pad = p;

  setPaintOrder(Eye.class, Avatar.class,
  Enemy.class, PowerItems.class,
  Counter.class);
  prepare();
  generateInitialStarField();
}

首先,我们需要一个名为pad的实例变量来保存对游戏手柄的引用。将构造函数更改为接受一个GamePad对象的引用,然后使用该值初始化我们的pad变量。此值将从AvoiderGameIntroScreen传递给我们。我们还需要将pad的值传递给AvoiderGameOverWorld,因此我们需要修改以下代码中的endgame()方法:

public void endGame() {
  bkgMusic.stop();
 AvoiderGameOverWorld go = new AvoiderGameOverWorld(pad);
  Greenfoot.setWorld(go);
}

我们在AvoiderWorld中需要更改的最后一件事是将pad实例变量传递到我们在游戏中创建的单个Avatar对象。因此,我们需要在prepare()方法中更改一行代码,如下所示:

private void prepare()
{
 Avatar avatar = new Avatar(pad);
  addObject(avatar, 287, 232);
  scoreBoard = new Counter("Score: ");
  addObject(scoreBoard, 70, 20);
}

AvoiderGameIntroScreen负责检测和配置游戏手柄。以下是实现此功能的更改:

import greenfoot.*; 
import java.lang.IllegalArgumentException;

public class AvoiderGameIntroScreen extends World
{
 private GamePad pad;

  public AvoiderGameIntroScreen() {
    super(600, 400, 1); 

 try {
 pad = GamePad.getGamePad();
 pad.runConfigurePad();
 } catch(IllegalArgumentException e) {
 System.out.println( "Exception caught: " + e.getMessage() );
 pad = null;
 }
  }

  public void act() {
    if( Greenfoot.mouseClicked(this) ) {
 AvoiderWorld world = new AvoiderWorld(pad);
      Greenfoot.setWorld(world);
    }
  }
}

首先,我们在类中添加一个实例变量pad,然后使用游戏手柄 API 的GamePad.getGamePad()方法初始化该变量。我们必须在GamePad.getGamePad()调用周围使用 try-catch 块,因为如果没有将游戏手柄插入到计算机中,getGamePad()方法将抛出异常。抛出的异常类型是IllegalArgumentException,因此我们必须捕获它。您会注意到我们在顶部添加了另一个导入语句来定义IllegalArgumentException类。如果没有游戏手柄,我们将pad设置为null。我们还在 try 块中调用了runConfigurePad()方法。这将弹出一个对话框,提示用户是否想要重新定义控制器的按钮。最后,我们在act()方法中将pad传递给AvoiderWorld

注意

异常

Java 异常提供了一种有组织和灵活的方式来处理运行时错误。它们允许您将代码从错误检测代码中解耦,使代码更易于阅读和维护。与 Java 中的异常处理相关的主要关键字是throwtrycatch。要了解更多关于 Java 异常的信息,请参阅docs.oracle.com/javase/tutorial/essential/exceptions/.

AvoiderGameOverScreen类所需的更改很简单。它只需要传递从先前的AvoiderWorld实例获得的游戏手柄引用,并在玩家点击屏幕再次玩游戏时将其传递给新的AvoiderWorld实例。以下是更改内容:

import greenfoot.*; 

public class AvoiderGameOverWorld extends World
{
  private GamePad pad;

  public AvoiderGameOverWorld(GamePad p) {  
    super(600, 400, 1); 
    pad = p;
  }

  public void act() {
    if( Greenfoot.mouseClicked(this) ) {
      AvoiderWorld world = new AvoiderWorld(pad);
      Greenfoot.setWorld(world);
    }
  }
}

直接处理从游戏手柄接收事件的类是Avatar类。我们需要修改这个类,以便使用游戏手柄来接受用户输入,如果没有游戏手柄,则默认使用鼠标。

这里是Avatar类的更改:

import greenfoot.*;

public class Avatar extends Actor {
  private static final float MIN_STRENGTH = 0.5F;
  private int health = 3;
  private int hitDelay = 0;
  private int stunDelay = -1;
  private int lagDelay = -1;
  private int nextImage = 0;
  private Eye leftEye;
  private Eye rightEye;
 private GamePad pad;
 private boolean useGamepad = true;
 private int gpStepX = 3;
 private int gpStepY = 3;
 private int gpLagStepX = 1;
 private int gpLagStepY = 1;

 public Avatar( GamePad p ) {
 pad = p;
 if( pad == null ) {
 useGamepad = false;
 }
 }
  protected void addedToWorld(World w) {
    leftEye = new Eye();
    rightEye = new Eye();
    w.addObject(leftEye, getX()-10, getY()-8);
    w.addObject(rightEye, getX()+10, getY()-8);
  }

  public void act() {
    userControls();
    checkForCollisions();
  }

  public void addHealth() {
    if( health < 3 ) {
      health++;
      if( --nextImage == 0 ) {
        setImage("skull.png");
      } else {
        setImage("skull" + nextImage + ".png");
      }
    }
  }

  public void lagControls() {
    lagDelay = 150;
  }

  public void stun() {
    stunDelay = 50;
  }

  private void checkForCollisions() {
    Actor enemy = getOneIntersectingObject(Enemy.class);
    if( hitDelay == 0 && enemy != null ) {
      if( health == 0 ) {
        AvoiderWorld world = (AvoiderWorld) getWorld();
        world.endGame();
      }
      else {
        health--;
        setImage("skull" + ++nextImage + ".png");
        hitDelay = 50;
      }
    }
    if( hitDelay > 0 ) hitDelay--;
  }

  private void userControls() {
    if( stunDelay < 0 ) {
      if( lagDelay > 0 ) {
        if( useGamepad ) {
          moveViaGamepad(true);
        } else {
          moveViaMouse(true);
        }
        --lagDelay;
      } else {
        if( useGamepad ) {
          moveViaGamepad(false);
        } else {
          moveViaMouse(false);
        }
      }

      leftEye.setLocation(getX()-10, getY()-8);
      rightEye.setLocation(getX()+10, getY()-8);
    } else {
      stunDelay--;
    }
  }

  private void moveViaGamepad(boolean lag) {
    int stepX = lag ? gpLagStepX : gpStepX;
    int stepY = lag ? gpLagStepY : gpStepY;

    Direction dir = pad.getAxis( GamePad.Axis.DPAD );
    if ( dir.getStrength() == 0 ) {
      dir = pad.getAxis( GamePad.Axis.LEFT );
    }

    if ( dir.getStrength() > MIN_STRENGTH ) {
      final int angle = dir.getAngle();

      if ( angle > 315 || angle <= 45 ) {
        setLocation(getX()+stepX, getY());
      } else if ( angle > 45 && angle <= 135 ) {
        setLocation(getX(), getY()+stepY);
      } else if ( angle > 135 && angle <= 225 ) {
        setLocation(getX()-stepX, getY());
      } else {
        setLocation(getX(), getY()-stepY);
      }
    }
  }

  private void moveViaMouse(boolean lag) {
    MouseInfo mi = Greenfoot.getMouseInfo();

    if( mi != null ) {
      if( lag ) {
        int stepX = (mi.getX() - getX())/40;
        int stepY = (mi.getY() - getY())/40;
        setLocation(stepX + getX(), stepY + getY());
      } else {
        setLocation(mi.getX(), mi.getY());
      }
    }
  }
}

Avatar类的开头,我们定义了一些额外的变量,这些变量将允许类的实例通过游戏手柄进行控制。我们声明pad来保存游戏手柄的引用和一些整数来指定如何快速移动Avatar对象。我们还声明了将在类方法中稍后检查的布尔变量useGamePad

在构造函数中,我们初始化pad并设置useGamePad。您会记得,如果没有检测到游戏手柄,我们在AvoiderGameIntroScreen中将pad设置为null

我们重构了userControls()方法。延迟和眩晕延迟的工作方式相同,但现在我们调用一个方法来实际移动对象。如果useGamePadtrue,则调用moveViaGamepad();否则,调用moveViaMouse()moveViaMouse()方法包含我们之前用来移动对象的相同逻辑。moveViaGamepad()方法完全是新的,并包含通过检测用户游戏手柄的输入来移动Avatar对象的逻辑。

moveViaGamepad()函数中,我们首先设置移动速度。如果我们有延迟,我们会走得更慢。游戏手柄的延迟实现与使用鼠标的延迟实现略有不同。然而,在两种情况下,效果都是减缓用户移动。接下来,我们检查用户是否正在按下 D-pad,通过检查推力的强度。如果等于 0,那么我们假设用户正在使用左侧模拟摇杆。然后我们检测用户推 D-pad(或模拟摇杆)的角度,并将该角度转换为方向——上、下、左或右。

尝试一下

我们已经添加了所有必要的代码,以便使用我们的 Avoider Game 版本与游戏手柄控制器。编译你之前输入的所有更改,修复任何错误,然后玩游戏。我真的觉得用游戏手柄玩游戏更自然、更令人满意。

你会注意到游戏手柄上还有很多未使用的按钮。你能为游戏添加什么功能来利用这些按钮呢?

OS X 设置/解决方案

OS X 不直接支持许多游戏手柄。如果你的游戏手柄不支持,你仍然可以使用该游戏手柄来控制你的 Greenfoot 游戏。

游戏手柄映射软件

有几个 OS X 应用程序可以将游戏手柄控制器映射到键盘键和鼠标动作。例如,你可以将 D-pad 的上、下、左、右动作映射到WSAD键。通常,这些应用程序比 Greenfoot 游戏手柄支持核心的JInput有更好的游戏手柄支持。因此,它将允许更广泛的控制器连接到你的游戏。另一个优点是,你可以无需考虑游戏手柄支持来编程你的场景。你假设标准键盘和鼠标控制,游戏手柄映射软件处理其余部分。以下是一些执行此映射的流行程序:

使用游戏手柄导出游戏

当你将游戏手柄支持添加到你的 Greenfoot 场景时,你需要记住一件事。如果你有它,那么你的游戏将无法在 Greenfoot 网站上玩。这是因为没有 Java 支持通过 Web 应用程序连接到游戏手柄。然而,如果你遵循www.greenfoot.org/doc/gamepad_export中的简单步骤,你仍然可以将你的场景导出为桌面应用程序。

摘要

Greenfoot 游戏手柄 API 设置和使用简单,并允许您为用户提供一个精心设计的控制界面。通过为用户提供使用鼠标、键盘或游戏手柄控制的选择,您让他们能够以对他们来说自然和舒适的方式与您的 Greenfoot 创作互动。在前几章中,您学习了如何使用键盘和鼠标,而在本章中,您学习了如何使用游戏手柄。

第十章。接下来要深入研究什么…

"悲伤的最佳良药,梅林回答,开始吹嘘,‘是学习。这是唯一不会失败的事情。你可能会在你的解剖结构中颤抖,你可能在夜晚醒来,听着你血管的混乱,你可能失去你唯一的爱情,你可能看到你周围的世界被邪恶的疯子摧毁,或者知道你的荣誉在卑鄙的心灵的下水道中被践踏。那时,唯一的事情就是学习。学习为什么世界摇摆,以及它摇摆什么。这是唯一的事情,心灵永远不会耗尽,永远不会疏远,永远不会被折磨,永远不会害怕或怀疑,永远不会梦想后悔。学习是你唯一的事情。看看有多少东西可以学习。"
--T.H.怀特,《曾经和未来的国王》

无论你是作为新手程序员、经验丰富的程序员、艺术家、讲故事的人,还是只是一个极度好奇的人开始阅读这本书的,我敢肯定你在旅途中学到了很多。我们已经涵盖了在编写和开发交互式程序中遇到的许多常见问题的解决方案。编写交互式程序不仅需要技术专长,还需要清楚地了解如何吸引和娱乐用户。我们还涵盖了软件设计、代码组织、调试以及面向对象语言的软件开发接受流程。这些技能将转移到未来的编程项目中,即使你最终使用的是不同的编程语言。

在这本书中,我们涵盖了以下主题:

  • 动画

  • 碰撞检测

  • 弹射物

  • 交互式应用程序设计和理论

  • 滚动和映射的世界

  • 人工智能

  • 用户界面

  • 游戏手柄

无论你开始阅读这本书时的背景如何,你现在都拥有了一组令人印象深刻的创意技能。让我们不要浪费它们!向前看,我想挑战你锻炼和提高你的技能。在本章中,我们将探讨实现这一目标的行动方案。

建造更大的东西

虽然我们已经构建了一些规模的程序,但我们出于教学和实践的原因,保持了工作的范围较小。然而,你拥有创建大型、复杂娱乐形式的能力。为自己策划一个项目,你觉得这个项目可以通过学习或玩耍,让人保持超过一个小时的兴趣。在你的项目的设计、故事和内容上投入相当多的时间。在编码之前,创建一个故事板,这将作为你项目的提纲,以及你可以向用户(或玩家)展示以获得早期反馈的初始作品。

注意

故事板

分镜图板是探索电影、戏剧、书籍或任何形式互动娱乐设计的有效方式。对于游戏来说,它们尤其有用。简单来说,分镜图板非常类似于创作你讲述的故事的漫画书。通过分镜图板,漫画书的各个画面被放置在单独的纸张上,这使得重新排列它们的顺序或插入/删除特定场景变得容易。

分镜图板提供了一个快速理解作品顺序、内容和流程的媒介。由于它们可以轻松地钉在墙上以便观看,因此它们在帮助作家、程序员、音乐家和艺术家之间的合作中也非常有效。迪士尼是第一个在他们的流程中使用它们的公司(20 世纪 30 年代)来创造动画故事。

你需要回过头去复习第五章,交互式应用设计理论,并遵循那里讨论的想法和过程,仔细地发展你的项目,使其能够吸引用户/玩家。记住,这样的项目需要你通过用户/玩家反馈随着时间的推移扩展项目。

你是否有艺术技能,或者认识一两个擅长创作数字艺术的朋友?请他们帮忙,真正让你的项目看起来更加精致和专业。注意所有细节,并提升项目的各个方面,以改善整体体验。你是一位音乐家吗?或者认识一位?为你的项目添加原创音乐和音效,可以真正提升其影响力。

分享你的作品

Greenfoot 为你提供了几种与他人分享你的作品的方式。音乐老师通常会为他的学生安排音乐会,给他们真正的改进动力。同样,你应该始终计划与更广泛的受众分享你的作品。知道你的作品将被展出,这会给你额外的动力,让你在作品中更加细致和详细。更重要的是,分享你的作品提供了收集来自玩家、程序员和游戏设计师宝贵反馈的机会。来自这个受众群体的反馈对于完善你的技能至关重要。

在 Greenfoot.org 上发布

Greenfoot 允许你轻松地立即在线分享你的 Greenfoot 场景。在你的场景窗口右上角,你会看到一个分享…按钮。此按钮将允许你直接在 Greenfoot 的在线画廊中分享你的场景。通过画廊,任何互联网用户都可以访问、播放、下载并对你的作品发表评论。Greenfoot 在线社区庞大且非常支持人,可以为你提供丰富的反馈和信息。要分享你的作品,请执行以下步骤:

  1. 点击分享按钮。

  2. 点击发布标签。

  3. 填写图 1所示的表格。

  4. 点击提交

如果一切顺利,您的项目将在您的网络浏览器中打开。请确保经常检查您的评论,并迅速回复。

在 Greenfoot.org 上发布

图 1:这是 Greenfoot 的场景共享窗口。请注意,您需要 Greenfoot 账户才能在线分享您的作品

桌面应用程序

将 Greenfoot 场景导出为桌面应用程序甚至更容易。为此,请执行以下步骤:

  1. 点击分享…按钮。

  2. 点击应用程序选项卡。

  3. 选择您想要创建可执行文件的位置。

  4. 点击导出

您现在可以双击创建的.jar文件,您的场景将运行。图 2显示了您的应用程序在此环境中的外观。请注意,您没有 Greenfoot 的代码编辑功能。

桌面应用程序

图 2:这是 Greenfoot 中将场景导出为应用程序的示例

导出为网页

通过前面提到的相同共享机制,您可以导出您的场景为网页。您只有在拥有自己的网络空间或托管服务,允许您上传自定义网页的情况下才需要使用此选项。

探索其他输入设备

我们在第九章Greenfoot 中的游戏手柄中花费了不少时间,讨论了游戏手柄及其如何增强用户体验。您还可以连接其他非常有趣的设备到您的 Greenfoot 场景中。例如,您可以将 Leap Motion 或 Microsoft Kinect 设备连接起来,提供一种非常独特且引人入胜的用户交互形式。此外,您还可以使用 Greenfoot 来控制 Finch 机器人等设备。

这为您提供了一个全新的途径来锻炼您的创造力。以下是可以咨询的资源,以了解更多关于连接这些设备的信息:

学习更多 Java

您已经学到了很多关于 Java 的知识。在本书的整个过程中,您已经使用了变量、方法、类、对象、继承和多态,但我们没有涵盖 Java 的一些关键领域,包括高级文件 I/O、网络、线程和 Swing(一个 GUI 小部件工具包)。Java 是一种工业级语言,用于从编程烤面包机到提供大型在线金融系统的一切。了解 Java 将使您能够创建游戏、移动应用程序、Web 应用程序以及更多更多。为了继续您的 Java 学习,您应该考虑阅读以下资源:

摘要

在撰写这本书的过程中,我试图想象你在阅读本书时的创造力、遇到的困难和成功。我希望为你提供一条既具有挑战性,又不会让你因与当前任务无关的事实和信息而负担过重的路径。现在,我意识到我将错过与你进行这次讨论的机会。我希望你在这本书中找到了一些有价值的内容,并从中获得了灵感去创造。这是我们讨论的结束,但却是你与 Greenfoot 一起进行创造性旅程的开始。

posted @ 2025-09-10 14:11  绝不原创的飞龙  阅读(34)  评论(0)    收藏  举报