嗨翻 C# 第四版(六)
原文:
zh.annas-archive.org/md5/aa741d4f28e1a4c90ce956b8c4755b7e译者:飞龙
第十六章:Unity 实验室#5:射线投射
当您在 Unity 中设置场景时,您正在为游戏中的角色创建一个虚拟的 3D 世界,使它们可以在其中移动。但在大多数游戏中,游戏中的大部分事物并不直接由玩家控制。那么这些对象如何在场景中找到它们的路?
实验五和六的目标是让您熟悉 Unity 的寻路和导航系统,这是一个复杂的 AI 系统,让您可以创建能够在您创建的世界中找到路的角色。在这个实验中,您将使用 GameObject 构建场景,并使用导航将角色移动到周围。
您将使用射线投射编写响应场景几何的代码,捕捉输入并用它将一个 GameObject 移动到玩家点击的点。同样重要的是,您将练习编写包括类、字段、引用等在内的 C#代码,这些都是我们讨论过的主题。
创建一个新的 Unity 项目并开始设置场景
开始之前,请关闭任何打开的 Unity 项目。同时关闭 Visual Studio——我们将让 Unity 来打开它。使用 3D 模板创建一个新的 Unity 项目,将布局设置为 Wide,使其与我们的截图匹配,并起一个名字,比如Unity Labs 5 and 6,以便您以后可以回来查看。
首先创建一个玩家将要在其中导航的游戏区域。在层级窗口中右键单击,并创建一个 Plane(GameObject >> 3D Object >> Plane)。将您的新 Plane GameObject 命名为Floor。
右键单击项目窗口中的 Assets 文件夹,创建一个名为 Materials 的文件夹。然后在您创建的新 Materials 文件夹上右键单击,并选择创建 >> Material。将新材质命名为FloorMaterial。现在,让我们保持这个材质简单——我们只需使它成为一种颜色。在项目窗口中选择 Floor,然后单击检视器中 Albedo 词右侧的白色框。
在颜色窗口中,使用外环选择地面的颜色。我们在截图中使用了一个颜色,编号为 4E51CB,您可以将其输入到十六进制框中。
将项目窗口中的材料拖到层级窗口中的 Plane GameObject上。您的地面平面现在应该是您选择的颜色。
注意
仔细思考并猜一下。然后使用检视器窗口尝试各种 Y 比例值,看看平面是否按照您的预期行动。(别忘了把它们设回来!)
注意
平面是一个平方形的平面对象,长宽为 10 个单位(在 X-Z 平面),高度为 0 个单位(在 Y 平面)。Unity 创建它,使得平面的中心点位于 (0,0,0)。这个平面的中心点决定了它在场景中的位置。和其他对象一样,你可以通过检视器或工具来移动它的位置和旋转。你也可以改变它的比例,但因为它没有高度,你只能改变 X 和 Z 的比例—任何放入 Y 比例的正数都会被忽略。
使用 3D 对象菜单创建的对象(平面、球体、立方体、圆柱体以及其他几个基本形状)被称为基本对象。你可以通过从帮助菜单打开 Unity 手册并搜索“基本和占位对象”帮助页面来了解更多信息。现在花一分钟打开这个帮助页面。阅读它对于平面、球体、立方体和圆柱体的介绍。
设置摄像机
在最近的两个 Unity 实验中,你学到了 GameObject 本质上是组件的“容器”,而主摄像机只有三个组件:一个 Transform,一个 Camera,和一个 Audio Listener。这很合理,因为摄像机所需做的就是位于某个位置并记录它所看到和听到的内容。查看检视器窗口中摄像机的 Transform 组件。
注意位置是 (0, 1, –10)。点击位置行中的 Z 标签并向上或向下拖动。你会看到摄像机在场景窗口中前后移动。仔细观察摄像头前方的方框和四条线。它们代表摄像机的视口,或者玩家屏幕上可见的区域。
使用移动工具(W)和旋转工具(E)移动摄像机并在场景中旋转它,就像你在场景中操作其他 GameObject 一样。摄像机预览窗口会实时更新,显示摄像机所见的内容。移动摄像机时保持关注摄像机预览。地面将会随着摄像机视角的改变而移动。
使用检视器窗口中的上下文菜单重置主摄像机的 Transform 组件。注意这不会将摄像机重置到原始位置—它会将摄像机的位置和旋转都重置为 (0, 0, 0)。你会看到摄像机与场景窗口中的平面相交。
现在让我们把摄像机直接对准地面。首先点击旋转旁边的 X 标签并上下拖动。你会看到摄像机预览中的视口移动。现在在检视器窗口中将摄像机的 X 旋转设置为 90 度,以使其直接朝下。
你会注意到在摄像机预览中再也看不到任何内容,这很合理,因为摄像机直接看向无限薄的平面下方。点击 Transform 组件中的 Y 位置标签并向上拖动,直到在摄像机预览中看到整个平面。
现在在层次视图中选择 Floor。注意到摄像机预览消失了—只有在选择摄像机时才会出现。你也可以在场景和游戏窗口之间切换,看看摄像机的视角。
使用平面的 Transform 组件在检视器窗口中,将 Floor GameObject 的缩放设置为 (4, 1, 2),使其长度为宽度的两倍。由于平面宽度和长度均为 10 单位,这个缩放将使其长度为 40 单位,宽度为 20 单位。平面将完全填满视口,因此将摄像机沿 Y 轴向上移动,直到整个平面都能看到。
注意
你可以在场景和游戏窗口之间切换,看看摄像机的视角。
创建一个玩家的 GameObject
你的游戏需要一个玩家来控制。我们将创建一个简单的类人形玩家,它有一个圆柱体作为身体和一个球体作为头部。确保你没有选择任何对象,通过点击层次视图中的场景(或空白处)。
创建一个 Cylinder GameObject(3D Object >> Cylinder)— 你将在场景中央看到一个圆柱体。将其名称改为 Player,然后从上下文菜单中选择 Reset,以确保其具有所有默认值。接下来,创建一个 Sphere GameObject(3D Object >> Sphere)。将其名称改为 Head,并重置其 Transform 组件。它们将分别在层次视图中各占一行。
但我们不想要分开的 GameObject,我们希望有一个由单个 C# 脚本控制的单一 GameObject。这就是为什么 Unity 引入了父子关系的概念。在层次视图中点击 Head,然后将其拖动到 Player 上。这样 Player 就成为了 Head 的父对象。现在 Head GameObject 被嵌套在 Player 下面。
在层次视图中选择 Head。它像你创建的所有其他球体一样被创建在 (0, 0, 0)。你可以看到球体的轮廓,但由于被平面和圆柱体遮挡住了,看不到球体本身。使用检视器窗口中的 Transform 组件,将球体的 Y 位置更改为 1.5。现在球体出现在圆柱体上方,正好是玩家头部的位置。
现在在层次视图中选择 Player。由于其 Y 位置为 0,柱体的一半被平面遮挡住了。将其 Y 位置设置为 1。柱体突出平面上方。注意头部球体也随之移动了。移动 Player 会导致头部也跟随移动,因为移动父 GameObject 会同时移动其子对象—事实上,任何对其 Transform 组件的更改都会自动应用到子对象上。如果你缩放它,其子对象也会缩放。
切换到游戏窗口—你的玩家位于游戏区域中央。
注意
当你修改一个有嵌套子对象的 GameObject 的 Transform 组件时,子对象也会随之移动、旋转和缩放。
介绍 Unity 的导航系统
视频游戏中的最基本事情之一是移动东西。玩家、敌人、角色、物品、障碍物……所有这些东西都可以移动。这就是为什么 Unity 配备了一个复杂的基于人工智能的导航和路径查找系统,以帮助 GameObjects 移动到您的场景中。我们将利用导航系统使玩家朝一个目标移动。
Unity 的导航和路径查找系统允许您的角色智能地在游戏世界中找到自己的路。要使用它,您需要设置基本组件,以告诉 Unity 玩家可以去哪里:
-
首先,你需要告诉 Unity 你的角色可以去哪里。你可以通过设置 NavMesh 来完成这一点,其中包含场景中可行走区域的所有信息:坡度、楼梯、障碍物,甚至称为离网链接的点,它们允许你设置特定的玩家操作,如打开门。
-
其次,您为需要导航的任何 GameObject 添加导航网格代理组件。此组件自动移动 GameObject,使用其 AI 找到到目标的最有效路径并避免障碍物,选项 ally 和其他导航网格代理。
-
对于 Unity 来说,导航复杂的 NavMeshes 需要大量计算。正因为如此,Unity 有一个烘焙功能,让你提前设置 NavMesh,并预先计算(或烘焙)几何细节,以使代理工作更高效。
注意
Unity 提供了一个复杂的 AI 导航和路径查找系统,可以实时移动 GameObjects 周围的场景,通过找到避免障碍物的有效路径。
设置 NavMesh
让我们设置一个仅包含地板平面的导航网格。我们将在“导航”窗口中执行此操作。选择 AI >> 导航从窗口菜单中添加导航窗口到你的 Unity 工作区。它应该显示为与“检查器”窗口同一面板中的标签。然后使用导航窗口标记地面 GameObject“导航静态”和“可行走:”
-
在导航窗口顶部按**“对象”按钮**。
-
在层 选择“地板平面” 在“层次结构”窗口中。
-
检查**“导航静态”复选框**。这告诉 Unity 在烘焙 NavMesh 时包含地板。
-
从“导航区域”下拉菜单中选择“可行走”。这告诉 Unity 地板平面是任何具有导航网格代理的 GameObject 可以导航的表面。
由于游戏中唯一可以行走的区域就是地板,所以在对象部分,我们已经完成了。如果场景中有很多可行走的表面或不可行走的障碍物,每个单独的 GameObject 需要被适当地标记。
在导航窗口顶部按**“烘焙”按钮**查看烘焙选项。
现在在导航窗口的底部点击其他 Bake 按钮。它会短暂地变成取消,然后切换回 Bake。你注意到场景窗口有什么变化了吗?在检查器和导航窗口之间来回切换。当导航窗口处于活动状态时,场景窗口显示 NavMesh 显示,并在标记为导航静态和可行走的游戏对象上显示蓝色的叠加层。在这种情况下,它突出显示了您标记为导航静态和可行走的平面。
现在你的 NavMesh 已经设置好了。
使你的玩家自动导航播放区域
让我们给 Player GameObject 添加一个 NavMesh Agent。在 Hierarchy 窗口中选择 Player,然后返回 Inspector 窗口,点击添加组件按钮,并选择Navigation >> NavMesh Agent来添加 NavMesh Agent 组件。圆柱体的身体高 2 个单位,球形头部高 1 个单位,所以你希望你的代理高度为 3 个单位——所以将高度设置为 3。现在 NavMesh Agent 已经准备好在 NavMesh 中移动 Player GameObject 了。
创建一个脚本文件夹,并添加名为MoveToClick.cs的脚本。这个脚本将允许您点击播放区域,并告诉 NavMesh Agent 将游戏对象移动到该位置。您在#encapsulation_keep_your_privateshellippr 中了解了私有字段。这个脚本将使用一个字段来存储对 NavMeshAgent 的引用,以便 GameObject 的代码可以告诉代理去哪里,因此您将调用 GetComponent 方法来获取该引用,并将其保存在名为私有 NavMeshAgent 字段的agent中:
agent = GetComponent<NavMeshAgent>();
导航系统使用 UnityEngine.AI 命名空间中的类,因此您需要将此using行添加到MoveToClick.cs文件的顶部:
是的!我们正在使用一个非常有用的工具,称为射线投射。
在第二个 Unity 实验室中,您使用 Debug.DrawRay 探索了如何通过绘制从(0, 0, 0)开始的射线来工作的 3D 向量。您的 MoveToClick 脚本的 Update 方法实际上做了类似的事情。它使用Physics.Raycast 方法“投射”一条射线——就像您用来探索向量的那条射线一样——它从相机开始,经过用户单击的点,并检查射线是否击中地板。如果是,则 Physics.Raycast 方法会提供击中地板的位置。然后脚本设置 NavMesh Agent 的destination 字段,这会导致 NavMesh Agent自动将玩家移动到该位置。
第十七章:卓越队长:对象的死亡
一个对象的生命周期
这里是我们对对象生命周期的快速回顾:
-
当你创建一个对象时,CLR(运行您的.NET 应用程序并管理内存)在堆上为其分配足够的内存,堆是您计算机内存的一部分,专门用于对象及其数据。
-
它被一个引用“保持活动”,可以存储在变量中,集合中,或者另一个对象的属性或字段中。
-
可以有很多引用指向同一个对象,就像你在#types_and_references_getting_the_referen 中看到的那样,当你把
lloyd和lucinda引用变量指向同一个 Elephant 实例时。 -
当您取消对 Elephant 对象的最后一个引用时,CLR 会标记它进行垃圾收集。
-
最终 CLR 移除了 Elephant 对象并回收了内存,以便用于稍后您的程序将要创建的新对象实例。
现在我们将更详细地探讨所有这些点,编写一些小程序来展示垃圾收集的工作原理。
但在我们开始实验垃圾收集之前,我们需要退一步。您之前学到,对象被“标记”为垃圾收集,但实际上对象的移除可以随时发生(或永远不会!)。我们需要一种方法来知道何时对象已经被垃圾收集,并且一种强制垃圾收集发生的方法。因此,这就是我们的起点。
使用 GC 类(慎用)来强制垃圾收集
.NET 提供了一个控制垃圾收集器的GC 类。我们将使用它的静态方法,比如 GetTotalMemory,它返回一个大致的堆上当前被认为分配的字节数:
您可能会想:“为什么是大致的?被认为分配的意思是什么?垃圾收集器怎么可能不知道到底分配了多少内存?”这反映了垃圾收集的基本规则之一:您绝对可以 100%依赖垃圾收集,但有很多未知和近似之处。
在本章中,我们将使用几个 GC 函数:
-
GC.GetTotalMemory 返回当前被认为在堆上分配的字节数。
-
GC.GetTotalAllocatedBytes 返回自程序启动以来大约分配的字节数。
-
GC.Collect 强制垃圾收集器立即回收所有未引用的对象。
关于这些方法只有一件事:我们正在用它们来学习和探索,但除非你真的知道你在做什么,不要在真实项目的代码中调用 GC.Collect。.NET 垃圾收集器是一个精心调试的工程组件。一般来说,当确定何时收集对象时,它比我们聪明,并且我们应该信任它来完成它的工作。
你最后的机会去执行一些操作……你对象的终结器。
有时你需要确保在对象被垃圾收集之前发生一些事情,比如释放非托管资源。
你对象中的一个特殊方法称为终结器,它允许你编写当你的对象被销毁时始终执行的代码。它无论如何都会最后执行。
让我们通过终结器做一些实验。创建一个新的控制台应用程序,并添加带有终结器的这个类:
注意
一般来说,你不会为仅拥有托管资源的对象编写终结器。到目前为止,在本书中遇到的所有内容都是由 CLR 管理的。但是有时程序员需要访问不在 .NET 命名空间中的 Windows 资源。例如,如果你在互联网上找到带有 [DllImport] 的声明,你可能正在使用非托管资源。而其中一些非 .NET 资源可能会在系统中保持不稳定,如果它们没有被“清理掉”。这就是终结器的作用。
什么时候确切地运行终结器?
你对象的终结器在所有引用消失之后,但在对象被垃圾收集之前运行。垃圾收集仅在所有对对象的引用消失后发生,但并不总是在最后一个引用消失后立即发生。
假设你有一个有引用的对象。CLR 发送垃圾收集器开始工作,它检查你的对象。但由于有对你对象的引用,垃圾收集器忽略它并继续。你的对象继续在内存中存在。
然后,发生了一些事情。持有对你的对象的最后一个引用的对象移除了该引用。现在你的对象在内存中,没有引用。它无法被访问。它基本上是一个无用的对象。
但有一件事:垃圾收集是 CLR 控制的,而不是你的对象。因此,如果垃圾收集器在几秒钟,甚至几分钟内没有再次启动,你的对象仍然存在于内存中。它无法使用,但它还没有被垃圾收集。并且对象的终结器(尚未)无法运行。
最后,CLR 再次发送垃圾收集器。它检查你的对象,发现没有引用,然后运行终结器……可能是在最后一个对对象的引用被移除或更改后的几分钟。现在它已经被终结,你的对象已经死了,收集器将其丢弃。
你可以建议 .NET 执行垃圾回收。
.NET 确实让你建议进行垃圾收集是个好主意。大多数情况下,你不会使用这个方法,因为垃圾收集已调整为响应 CLR 中的许多条件,直接调用并不是一个好主意。但只是为了看看终结器是如何工作的,你可以自己调用垃圾收集,使用 GC.Collect。
但要小心。该方法并不强制CLR 立即垃圾回收事物。它只是说,“尽快进行垃圾收集。”
一个对象的生命和死亡...一个时间表
-
你的对象正在堆上过着它最好的生活。另一个对象引用它,使其保持存活状态。
-
另一个对象更改它的引用,所以现在没有其他对象引用你的对象。
-
CLR 标记你的对象进行垃圾收集。
-
最终,垃圾收集器运行对象的终结器并从堆中移除对象。
注意
我们正在使用 GC.Collect 作为学习工具来帮助你理解垃圾收集的工作原理。你绝对不应该在非玩具程序中使用它(除非你真正理解.NET 中的垃圾收集工作原理比本书深入讨论的更多)。
终结器不能依赖其他对象。
当你编写一个终结器时,你不能依赖它在任何特定时间运行。即使你调用了 GC.Collect,你只是建议垃圾收集器运行。这并不保证会立即发生。而且一旦发生,你无法知道对象收集的顺序。
那在实际应用中意味着什么呢?想想如果你有两个对象彼此引用。如果首先收集对象#1,那么对象#2 对它的引用指向一个不再存在的对象。但如果首先收集对象#2,那么对象#1 的引用是无效的。这意味着你不能依赖于对象终结器中的引用。这意味着试图在终结器中执行依赖于引用有效性的操作是一个非常糟糕的主意。
不要为序列化使用终结器。
序列化真的是一个很好的例子,你不应该在终结器内部执行它。如果你的对象引用了一堆其他对象,序列化依赖于所有这些对象仍然存在于内存中...以及所有这些对象引用的对象,以此类推。因此,如果在进行垃圾收集时尝试序列化,你可能会因为一些对象在终结器运行之前被收集了而丢失程序的关键部分。
幸运的是,C#为我们提供了一个非常好的解决方案:IDisposable。任何可能修改你的核心数据或依赖于其他对象存在于内存中的事情都应该作为 Dispose 方法的一部分而不是终结器的一部分。
有些人喜欢把终结器看作是 Dispose 方法的一种故障安全机制。这是有道理的——你在 Clone 对象中看到,仅仅因为你实现了 IDisposable,并不意味着对象的 Dispose 方法会被调用。但你需要小心——如果你的 Dispose 方法依赖于堆上的其他对象,那么在终结器中调用 Dispose 可能会导致问题。解决这个问题的最佳方法是,确保始终使用using语句来创建 IDisposable 对象。
从相互引用的两个对象开始。
如果堆上的所有其他对象删除对对象#1 和#2 的引用,它们都将被标记为收集。
如果对象#1 先被收集,那么当 CLR 运行对象#2 的终结器时,它的数据将不可用。
另一方面,对象#2 可能在对象#1 之前消失。你无法知道顺序。
这就是为什么一个对象的终结器不能依赖于堆上任何其他对象仍然存在。
今晚的辩论:Dispose 方法和终结器争夺谁对你,作为 C#开发者更有价值。
| Dispose: | Finalizer: |
|---|---|
| 老实说,我被邀请来这里有点惊讶。我以为编程界已经达成共识。我的意思是,作为 C#工具,我显然比你更有价值。真的,你相当脆弱。你甚至不能依赖于其他对象在你被调用时仍然存在。相当不稳定,不是吗? | 对不起?真是滑稽。我“脆弱”?好吧。嗯,我本来不想降到这种水平,但既然我们已经这么做了……至少我不需要一个接口才能开始工作。没有 IDisposable 接口,嗯,面对现实吧……你只是另一个无用的方法而已。 |
| 之所以有一个特定的接口因为我如此重要。事实上,它里面只有一个方法! | 对,对……继续骗自己吧。如果有人在实例化对象时忘记使用using语句会发生什么?那时你就会不见踪影了。 |
好吧,你说得对,程序员需要知道他们将需要我,要么直接调用我,要么使用using语句调用我。但他们总是知道我何时运行,并且可以利用我来做任何需要清理对象后的工作。我功能强大、可靠且易于使用。我是三重威胁。而你呢?没有人确切知道你何时运行,或者当你最终决定出现时应用程序的状态如何。 | 但如果你需要在对象被垃圾收集之前的最后一刻做些事情,没有我是不可能的。我可以释放网络资源和 Windows 句柄以及其他可能会导致应用程序出问题的任何东西。我可以确保你的对象更优雅地处理被丢弃的情况,这一点不容小觑。句柄是你的程序在绕过.NET 和 CLR 直接与 Windows 交互时使用的。由于.NET 不知道它们,因此不能为你清理它们。 |
| 你以为你很厉害,因为你总是与 GC 一起运行,但至少我可以依赖其他对象。 | 是的,朋友,但我总是运行。你需要别人来帮你运行。我不需要任何人或任何东西! |
结构体看起来像一个对象...
我们一直在谈论堆,因为那是你的对象所在的地方。但这不是对象居住的唯一内存部分。在.NET 中我们还没有多谈到的一种类型是结构体,我们将用它来探索 C#中生命和死亡的另一个方面。结构体简称为结构,结构体看起来很像对象。它们有字段和属性,就像对象一样。你甚至可以将它们传递给以对象类型参数为参数的方法:
...但并不是一个对象。
但结构体不是对象。它们可以有方法和字段,但是它们不能有终结器。它们也不能从类或其他结构体继承,或者有类或结构体从它们继承—你可以在结构体的声明中使用冒号:运算符,但只能跟着一个或多个接口。
注
所有结构体都扩展自 System.ValueType,而 System.ValueType 又扩展自 System.Object。这就是为什么每个结构体都有一个 ToString 方法—它从 Object 那里继承而来。但这是结构体被允许做的唯一继承。
对象的力量在于它们通过继承和多态来模仿现实世界的行为。
结构体最适合用于存储数据,但继承和引用的缺失可能是一个严重的限制。
值被复制;引用被赋值
我们已经看到引用对于垃圾收集是多么重要——重新分配最后一个引用给一个对象,它就会被标记为待收集。但我们也知道,这些规则对于值来说并不完全合理。如果我们想更好地了解对象和值在 CLR 内存中是如何存活和死亡的,我们需要更仔细地看一看值和引用:它们如何相似,更重要的是,它们如何不同。
你已经知道一些类型如何与其他类型不同。一方面,你有像 int、bool 和 decimal 这样的值类型。另一方面,你有像 List、Stream 和 Exception 这样的对象。它们的工作方式并不完全相同,是吧?
当你使用等号将一个值类型变量设置为另一个时,它复制了值,之后这两个变量不再连接到彼此。另一方面,当你使用等号与引用时,你所做的是指向同一个对象的两个引用。
-
变量声明和赋值在值类型和对象类型中的工作方式相同:
-
但是一旦你开始赋值,你就能看到它们之间的不同。所有值类型都通过复制来处理。这是一个例子——这应该是熟悉的内容:
这里的输出显示
fifteenMore和howMany实际上没有连接: -
但正如我们所知,当涉及到对象时,你是在赋予引用而不是值:
因此,改变列表意味着两个引用都看到更新,因为它们都指向同一个列表对象。通过写一行输出来验证这一点:
这里的输出表明copy和temps实际上指向同一个对象:
temps has 3, copy has 3
结构体是值类型;对象是引用类型
让我们更仔细地看看结构体的工作原理,这样你就可以开始理解何时可能需要使用结构体而不是对象。当你创建一个结构体时,你正在创建一个值类型。这意味着当你使用等号将一个结构体变量设置为另一个时,你在新变量中创建了一个全新的副本。因此,即使结构体看起来像一个对象,它并不像一个对象那样行事。
就这样!
-
创建一个名为 Dog 的结构体。
这是一个简单的结构体,用来追踪一只狗。它看起来就像一个对象,但实际上不是。将其添加到一个新的控制台应用程序中:
public struct Dog { public string Name { get; set; } public string Breed { get; set; } public Dog(string name, string breed) { this.Name = name; this.Breed = breed; } public void Speak() { Console.WriteLine("My name is {0} and I’m a {1}.", Name, Breed); } } -
创建一个名为 Canine 的类。
制作一份完全相同的 Dog 结构体的副本,除了用 class 替换 struct,然后用 Canine 替换 Dog。不要忘记重命名 Dog 的构造函数。现在你将拥有一个几乎完全等同于 Dog 结构体的 Canine 类,你可以玩弄一下。
-
添加一个 Main 方法来创建一些 Dog 和 Canine 数据的副本。
这是 Main 方法的代码:
Canine spot = new Canine("Spot", "pug"); Canine bob = spot; bob.Name = "Spike"; bob.Breed = "beagle"; spot.Speak(); Dog jake = new Dog("Jake", "poodle"); Dog betty = jake; betty.Name = "Betty"; betty.Breed = "pit bull"; jake.Speak(); -
在运行程序之前……
写下你认为在运行这段代码时将会被输出到控制台的内容:
...................................................................................
...................................................................................
这就是发生的事情......
bob 和 spot 引用都指向同一个对象,因此它们都更改了相同的字段并访问了相同的 Speak 方法。但是结构体不是这样工作的。当您创建betty时,您复制了jake中的数据。这两个结构体是完全独立的。
注意
当您将一个结构体设置为另一个结构体时,您正在创建数据内部的一个新的复制。这是因为结构体是一个值类型(而不是对象或引用类型)。
栈 vs. 堆:更多关于内存的信息
让我们快速回顾一下结构体与对象的区别。您已经看到,只需使用等号就可以制作结构体的新副本,而这是您无法用对象做到的。但背后的真正情况又是怎样的呢?
CLR 将数据分为内存的两个地方:堆和栈。您已经知道对象存储在堆上。CLR 还保留了另一个内存部分称为栈,用于存储您在方法中声明的局部变量和传递给这些方法的参数。您可以将栈视为一堆可以放置值的槽。当调用方法时,CLR 会向栈顶添加更多槽。当方法返回时,它的槽会被移除。
了解通过值复制的结构体与通过引用复制的对象之间的不同是非常重要的。
有时您需要编写一个方法,可以接受值类型或者引用类型 —— 也许是可以处理 Dog 结构体或 Canine 对象的方法。如果您发现自己处于这种情况下,可以使用object关键字:
public void WalkDogOrCanine(object getsWalked) { ... }
如果您向此方法发送一个结构体,该结构体会被包装成一个特殊的“包装器”对象,使其可以存储在堆上。当包装器在堆上时,您无法对结构体做太多操作。您需要“拆包”结构体才能处理它。幸运的是,当您将一个对象设置为值类型或将值类型传递给一个期望对象的方法时,所有这些操作都会自动发生。
注意
您还可以使用“is”关键字来查看一个对象是否是被装箱并放置在堆上的结构体或任何其他值类型。
-
这是在您创建一个对象变量并将其设置为 Dog 结构体后,栈和堆看起来的样子。
Dog sid = new Dog("Sid", "husky"); WalkDogOrCanine(sid); -
如果您想要解包对象,您只需要将其强制转换为正确的类型,它会自动解包。
is关键字对结构体也可以正常工作,但要小心,因为as关键字不适用于值类型。if (getsWalked is Dog doggo) doggo.Speak();
注意
打开 Unity 项目并悬停在 Vector3 上——它是一个 struct。垃圾收集(或 GC)可能会严重降低应用程序的性能,而游戏中的许多对象实例可能会触发额外的 GC 并降低帧速率。游戏通常会使用大量向量。将它们设为 struct 意味着它们的数据保存在堆栈上,因此即使创建数百万个向量也不会导致额外的 GC,从而降低游戏的运行速度。
使用out参数使方法返回多个值
谈到参数和参数,还有几种将值传递到程序中的方法。它们都涉及向方法声明中添加修饰符。其中一种常见的方法是使用**out修饰符**指定输出参数。你已经多次见过out修饰符——每次调用 int.TryParse 方法时都会用到它。你也可以在自己的方法中使用out修饰符。创建一个新的控制台应用程序,并将这个空方法声明添加到表单中。注意这两个参数上的out修饰符:
做这个!
注意
通过使用 out 参数,一个方法可以返回多个值。
仔细看看这两个错误:
-
‘half’ 输出参数必须在控制离开当前方法之前赋值
-
‘twice’ 输出参数必须在控制离开当前方法之前赋值
每当使用out参数时,你总是需要在方法返回之前设置它,就像你总是需要在方法声明中使用return语句一样。
这是应用程序的所有代码:
当你运行应用程序时,看看它是什么样子:
Enter a number: 17
Outputs: plus one = 18, half = 8.50, twice = 34
使用ref修饰符进行引用传递
你一直见过的一件事是,每次你把 int、double、struct 或任何其他值类型传递给一个方法时,你都在把该值的副本传递给该方法。这有一个名称:按值传递,这意味着参数的整个值都会被复制。
但是还有另一种将参数传递给方法的方式,称为按引用传递。你可以使用**ref**关键字允许方法直接使用传递给它的参数。与out修饰符一样,当声明方法和调用方法时都需要使用**ref**。无论是值类型还是引用类型,通过ref参数传递给方法的任何变量都将直接被该方法改变。
要查看它是如何工作的,请创建一个新的控制台应用程序,其中包含这个 Guy 类和这些方法:
注意
在底层,一个“out”参数就像一个“ref”参数,唯一的区别是它在进入方法之前不需要被赋值,但在方法返回之前必须被赋值。
使用可选参数来设置默认值
很多时候,你的方法将会以相同的参数被多次调用,但是偶尔会改变。如果你能设置一个默认值,那么当调用方法时只需要在参数不同的时候指定它就足够了。
这正是可选参数的作用。你可以通过在方法声明中使用等号后跟该参数的默认值来指定一个可选参数。你可以拥有任意数量的可选参数,但所有的可选参数都必须在必需参数之后。
这是一个使用可选参数检查某人是否发烧的方法示例:
此方法有两个可选参数:tooHigh 的默认值为 99.5,tooLow 的默认值为 96.5。调用 CheckTemperature 时只传递一个参数将使用这两个参数的默认值。如果你传递两个参数,它将使用第二个参数作为 tooHigh 的值,但仍然使用 tooLow 的默认值。你可以指定所有三个参数来为所有三个参数传递值。
如果你想使用一些(但不是所有)默认值,你可以使用命名参数来仅传递你想传递的那些参数的值。你只需要给出每个参数的名称,后跟一个冒号和它的值。如果你使用多个命名参数,请确保用逗号分隔它们,就像任何其他参数一样。
将 CheckTemperature 方法添加到控制台应用程序,然后添加这个 Main 方法:
static void Main(string[] args)
{
// Those values are fine for your average person
CheckTemperature(101.3);
// A dog’s temperature should be between 100.5 and 102.5 Fahrenheit
CheckTemperature(101.3, 102.5, 100.5);
// Bob’s temperature is always a little low, so set tooLow to 95.5
CheckTemperature(96.2, tooLow: 95.5);
}
它打印出这个输出,根据可选参数的不同值而有不同的工作方式:
Uh-oh 101.3 degrees F -- better see a doctor!
101.3 degrees F - feeling good!
96.2 degrees F - feeling good!
在希望方法具有默认值时,请使用可选参数和命名参数。
空引用不指向任何对象。
当你创建一个新的引用并且没有设置它的值时,它有一个值。它最初设置为**null**,这意味着它不指向任何东西。让我们来实验一下空引用。
就这样吧!
-
创建一个新的控制台应用程序,并添加你用来实验
ref关键字的 Guy 类。 -
然后添加以下代码,创建一个新的 Guy 对象,但是不设置其 Name 属性:
static void Main(string[] args) { Guy guy; guy = new Guy() { Age = 25 }; Console.WriteLine("guy.Name is {0} letters long", guy.Name.Length); } -
在 Main 方法的最后一行上设置断点,然后调试你的应用程序。
当它遇到断点时,悬停在
guy上以检查其属性值:注意
String 是一个引用类型。由于你没有在 Guy 对象中设置它的值,所以它仍然保持默认值:null。
-
继续运行代码。 Console.WriteLine 尝试访问 guy.Name 属性引用的 String 对象的 Length 属性,并抛出异常:
注意
当 CLR 抛出 NullReferenceException(开发人员通常称之为 NRE)时,它告诉你它试图访问对象的成员,但用于访问该成员的引用为 null。开发人员尽量避免空引用异常。
非可空引用类型帮助你避免 NRE
避免空引用异常(或者 NRE)的最简单方法是设计代码以使引用不能为 null。幸运的是,C#编译器为此提供了一个非常有用的工具。在 Guy 类的顶部添加以下代码——可以放在命名空间声明的内部或外部:
#nullable enable
以#开头的行是一个指令,或者说是告诉编译器设置特定选项的一种方式。在这种情况下,它告诉编译器将任何引用视为非可空引用类型。一旦添加了该指令,Visual Studio 会在 Name 属性下方绘制一个警告波浪线。将鼠标悬停在属性上以查看警告:
C#编译器做了一件非常有趣的事情:它使用了流分析(或者说一种分析代码中各种路径的方法)来确定Name 属性有可能被赋予空值。这意味着你的代码可能会抛出 NullReferenceException。
你可以通过在类型后添加?字符来强制 Name 属性成为可空引用类型,从而消除警告。
但是虽然这样可以消除错误消息,却并不能真正防止任何异常。
使用封装来防止属性为空
回到#encapsulation_keep_your_privateshellippr,你学习了如何使用封装来保持类成员不受无效值的影响。因此,将 Name 属性设为私有,然后添加一个构造函数来设置其值:
一旦封装了 Name 属性,就可以防止其被设置为null,这样警告就消失了。
空合并运算符 ?? 对空值有帮助
有时候无法避免与空值一起工作。例如,你已经学习了如何使用 StringReader 从字符串中读取数据,详见#reading_and_writing_files_save_the_last。创建一个新的控制台应用程序,并添加以下代码:
运行代码——你会得到一个 NRE。我们能做些什么来解决这个问题?
?? 检查 null 并返回替代值
防止访问(或者解引用)空引用的一种方法是使用空合并运算符 ?? 来评估可能为空的表达式——在本例中是调用 stringReader.ReadLine,并在其为空时返回替代值。修改using块的第一行,在行末添加?? String.Empty:
一旦添加了这个,警告就会消失。这是因为 null 合并运算符告诉 C#编译器执行 stringReader.ReadLine;如果返回的值不为 null,则使用它,但如果为 null,则使用您提供的值(在本例中为空字符串)。
??=仅在变量为 null 时赋值
当您处理 null 值时,编写代码检查值是否为 null 并将其赋予非 null 值以避免 NRE 是非常常见的。例如,如果您想要修改程序以打印第一行代码,您可能会编写如下代码:
if (nextLine == null)
nextLine = "(the first line is null)";
// Code that works with nextLine and needs it to be non-null
你可以使用null 赋值 **??=**运算符重写该条件语句:
nextLine ??= "(the first line was empty)";
??=运算符检查表达式左侧的变量、属性或字段(在本例中是 nextLine),看看它是否为 null。如果是,该运算符将右侧表达式的值赋给它。如果不是,则保留原值。
可空值类型可以是 null...并且可以安全处理
当你声明一个 int、bool 或其他值类型时,如果没有指定值,CLR 会为它分配一个默认值,如 0 或 true。但假设你正在编写代码来存储调查数据,其中有一个可选的是/否问题。如果需要表示可能为 true 或 false,或者根本没有值的布尔值,该怎么办?
这就是可空值类型非常有用的地方。可空值类型可以具有值或设置为 null。它利用了一个泛型结构 Nullable,可以用来包装一个值(或包含该值并提供成员以访问和处理它)。如果将可空值类型设置为 null,则它没有值——Nullable为您提供了方便的成员,让您即使在这种情况下也可以安全地使用它。
您可以像这样声明一个可空布尔值:
Nullable<bool> optionalYesNoAnswer = null;
C#还有一个快捷方式——对于值类型 T,您可以像这样声明 Nullable:T?。
bool? anotherYesNoAnswer = false;
可空类型Nullable<T>结构有一个名为 Value 的属性,用于获取或设置值。bool?将具有 bool 类型的值,int?将具有 int 类型的值,等等。它们还有一个名为 HasValue 的属性,如果值不为 null 则返回 true。
您始终可以将值类型转换为可空类型:
int? myNullableInt = 9321;
并且您可以使用其方便的 Value 属性获取值:
int = myNullableInt.Value;
但是 Value 调用最终只是使用(int)myNullableInt将值强制转换,如果值为 null,它将引发 InvalidOperationException。这就是为什么 Nullable还有一个 HasValue 属性,如果值不为 null 则返回 true,否则返回 false。您还可以使用方便的 GetValueOrDefault 方法,如果 Nullable 没有值,则安全地返回默认值。您可以选择传递一个默认值来使用,或者使用类型的正常默认值。
“Captain” Amazing...not so much
到目前为止,您应该对不那么强大、更疲惫的“Captain Amazing”发生了什么有了相当好的了解。事实上,那根本不是 Captain Amazing,而是一个装箱结构:
结构体对封装可能是有价值的,因为返回结构体的只读属性总是生成它的新副本。
池子拼图
你的 任务 是从池子中取出片段,并将它们放入代码的空白行中。你可以多次使用相同的片段,而且你不需要使用所有的片段。你的目标是在执行此应用程序时,使代码将下面显示的输出写入控制台。
池子拼图解
扩展方法向现有的类添加新行为
有时你需要扩展一个无法继承的类,比如封闭类(许多 .NET 类都是封闭的,所以你无法从它们继承)。而 C# 给了你一个灵活的工具:扩展方法。当你将一个具有扩展方法的类添加到你的项目中时,它会添加新的方法,这些方法出现在已经存在的类上。你所需做的就是创建一个静态类,并添加一个静态方法,该方法以该类的实例作为其第一个参数,使用 this 关键字。
注意
记住来自 #interfacescomma_castingcomma_and_quotati 的封闭修饰符吗?它是你设置一个无法被扩展的类的方法。
所以假设你有一个封闭的 OrdinaryHuman 类:
一旦 AmazeballsSerum 类被添加到项目中,OrdinaryHuman 就会获得一个 BreakWalls 方法。因此,现在你的 Main 方法可以使用它:
static void Main(string[] args){
OrdinaryHuman steve = new OrdinaryHuman(185);
Console.WriteLine(steve.BreakWalls(89.2));
}
就是这样!你所需做的就是将 AmazeballsSerum 类添加到你的项目中,突然间每个 OrdinaryHuman 类都会得到一个全新的 BreakWalls 方法。
注意
当程序创建 OrdinaryHuman 类的实例时,只要 AmazeballsSerum 类存在于项目中,就可以直接访问 BreakWalls 方法。继续,试试看吧!创建一个新的控制台应用程序,并将这两个类和 Main 方法添加进去。调试进入 BreakWalls 方法,看看发生了什么。
嗯... 书中稍早我们通过向我们的代码顶部添加 using 指令来“神奇地”向类添加了方法。你还记得那是在哪里吗?
注意
关于扩展方法还有一点需要记住:通过创建扩展方法,你不会访问类的任何内部内容,因此它仍然像一个局外人一样工作。
是的!LINQ 是基于扩展方法的。
除了扩展类之外,你还可以扩展接口。你所需做的就是在扩展方法的第一个参数的 this 关键字之后使用一个接口名称。扩展方法将被添加到实现该接口的每个类中。这正是 .NET 团队在创建 LINQ 时所做的——所有 LINQ 方法都是 IEnumerable 接口的静态扩展方法。
它的工作原理如下。当你在代码顶部添加using System.Linq;时,它会让你的代码“看到”一个名为 System.Linq.Enumerable 的静态类。你已经使用了它的一些方法,比如 Enumerable.Range,但它还有扩展方法。去 IDE 中输入Enumerable.First,然后查看声明。它以(extension)开头,告诉你它是一个扩展方法,它的第一个参数使用了this关键字,就像你写的扩展方法一样。对于每个 LINQ 方法,你会看到相同的模式。
扩展基本类型:字符串
让我们通过扩展 String 类来探索扩展方法的工作原理。创建一个新的控制台应用程序项目,并添加一个名为HumanExtensions.cs的文件。
做这个!
-
将所有扩展方法放在单独的命名空间中。
将你所有的扩展方法放在一个不同的命名空间中是一个好主意。这样,你就不会在其他程序中使用它们时遇到麻烦。为你的方法设置一个静态类来存放:
-
创建静态扩展方法,并将其第一个参数定义为 this,然后是你要扩展的类型。
当你声明一个扩展方法时,你需要知道的两件主要事情是,方法必须是静态的,并且它将扩展的类作为它的第一个参数:
-
完成扩展方法。
这个方法检查字符串是否包含单词“Help!”——如果包含,那么这个字符串就是一个求助呼叫,每个超级英雄都会答应:
-
使用你的新 IsDistressCall 扩展方法。
在你的 Program 类文件顶部添加
using AmazingExtensions;。然后在类中添加代码,创建一个字符串并调用它的 IsDistressCall 方法。你会在 IntelliSense 窗口中看到你的扩展方法:
扩展磁铁
排列磁铁以产生此输出:
一个铜板生更多的铜板
扩展磁铁解决方案
你的工作是排列磁铁以产生此输出:
一个铜板生更多的铜板
第十八章:异常处理:处理异常开始变得老套
程序员不应该成为消防员。
你努力工作,浏览技术手册和一些引人入胜的Head First图书,最终成为你职业生涯的顶峰。但你仍然在半夜因为程序崩溃或行为不符合预期而接到恐慌的电话。没有什么能像修复一个奇怪的错误那样让你从编程状态中脱颖而出...但是通过异常处理,你可以编写代码来处理出现的问题。更重要的是,你甚至可以为这些问题做好准备,并在问题发生时保持系统运行。
你的十六进制转储程序从命令行读取文件名
在 #reading_and_writing_files_save_the_last 结尾,你构建了一个十六进制转储程序,该程序使用命令行参数转储任何文件。你使用 IDE 中的项目属性设置调试器的参数,并学习了如何从 Windows 命令提示符或 macOS Terminal 窗口调用它。
但是如果你给 HexDump 一个无效的文件名会发生什么?
当你修改你的 HexDump 应用程序以使用命令行参数时,我们要求你务必指定一个有效的文件名。当你提供一个无效的文件名时会发生什么?尝试再次从命令行运行你的应用程序,但这次给它传递参数 invalid-filename。现在它抛出一个异常。
使用项目设置将程序的参数设置为一个无效的文件名,并在 IDE 的调试器中运行应用程序。现在你会看到它抛出一个异常,类名相同(System.IO.FileNotFoundException),并显示类似的“找不到文件”的消息。
注意
实际上,你不会连续遇到所有这些异常 —— 程序会抛出第一个异常然后停止。只有在修复第一个异常后才会遇到第二个异常。
当你的程序抛出一个异常时,CLR 会生成一个异常对象
你一直在研究 CLR 在程序中告诉你出了问题的方式:一个异常。当你的代码中发生异常时,会创建一个对象来表示这个问题。这就是——毫不奇怪——异常。
例如,假设你有一个包含四个项目的数组,然后你尝试访问第 16 个项目(由于我们是以零为基础的,所以索引是 15):
注意
ex-cep-tion,名词。
一个被排除在一般声明之外或不遵循规则的人或物。尽管杰米通常讨厌花生酱,他们对帕克的花生酱夹心薄片做了个例外。
当 IDE 因为代码抛出异常而停止时,你可以通过在 Locals 窗口中**展开$exception**来查看异常的详细信息。Locals 窗口显示当前范围内的所有变量(这意味着当前语句可以访问它们)。
CLR 会创建一个对象,因为它想要提供有关引发异常的所有信息。你可能需要修复代码,或者只需对程序中的特定情况进行一些更改。
这个特定的异常是IndexOutOfRangeException,它告诉你问题所在:你正在尝试访问数组中超出范围的索引。你还可以获取有关问题发生位置的详细信息,这使得跟踪和解决问题变得更容易(即使你的代码有数千行)。
所有的异常对象都继承自 System.Exception
.NET 有许多不同的异常可能需要报告。由于许多异常具有许多相似的特征,因此继承起了作用。.NET 定义了一个基类叫做 Exception,所有特定的异常类型都从这个基类继承。
Exception 类有几个有用的成员。Message 属性存储了关于出错原因的易读信息。StackTrace 告诉你在异常发生时正在执行的代码以及导致异常的过程。(还有其他的,但我们首先使用这些。)
没错。异常是一个非常有用的工具,可以帮助你找到代码行为不符合预期的地方。
很多程序员第一次看到异常时会感到沮丧。但是异常是非常有用的,你可以利用它们来优化你的程序。当你看到异常时,它提供了很多线索,帮助你找出代码为什么会以你意料之外的方式反应。这对你有好处:它让你知道程序必须处理的新情况,并为你提供了解决问题的机会。
异常主要是帮助你找到并修复代码表现出意料之外行为的情况。
有一些文件你是无法转储的
在#linq_and_lambdas_get_control_of_your_dat 中,我们讨论了如何使你的代码更加健壮,以便处理不良数据、格式错误的输入、用户错误和其他意外情况。如果没有通过命令行传递文件或文件不存在,则倒置 stdin 是使十六进制转储器更加健壮的一个很好的起点。
但是还有一些情况需要我们处理吗?例如,如果文件存在但不可读怎么办?让我们看看如果我们移除文件的读取权限,然后尝试读取会发生什么:
-
在 Windows 上: 在 Windows 资源管理器中右键点击文件,转到安全选项卡,然后点击编辑以修改权限。勾选所有的拒绝框。
-
在 Mac 上: 在终端窗口中,切换到包含要转储文件的文件夹,并运行以下命令,将
binarydata.dat替换为你的文件名:chmod 000 binarydata.dat.
现在你已经从文件中删除了读取权限,尝试再次运行你的应用程序,可以在 IDE 中或从命令行中执行。
你会看到一个异常—堆栈跟踪显示**using语句调用了 GetInputStream 方法**,最终导致 FileStream 抛出了 System.UnauthorizedAccessException 异常:
C:\HexDump\bin\Debug\netcoreapp3.1>hexdump binarydata.dat
Unhandled exception. System.UnauthorizedAccessException: Access to the path ’C:\HexDump\bin\Debug\
netcoreapp3.1\binarydata.dat’ is denied.
at System.IO.FileStream.ValidateFileHandle(SafeFileHandle fileHandle)
at System.IO.FileStream.CreateFileOpenHandle(FileMode mode, ..., FileOptions options)
at System.IO.FileStream..ctor(String path, ..., Int32 bufferSize, FileOptions options)
at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share)
at System.IO.File.OpenRead(String path)
at HexDump.Program.GetInputStream(String[] args) in C:\HexDump\Program.cs:line 14
at HexDump.Program.Main(String[] args) in C:\HexDump\Program.cs:line 20
实际上,对此是有办法的。
是的,用户确实经常出错。他们会向你的程序提供糟糕的数据,奇怪的输入,点击你甚至不知道存在的东西。这是生活的一部分,但这并不意味着你无法应对。C#为你提供了非常有用的异常处理工具,帮助你使程序更加健壮。因为虽然你不能控制用户如何使用你的应用程序,但你可以确保他们这样做时你的应用程序不会崩溃。
当你想调用的方法存在风险时会发生什么?
用户是不可预测的。他们会将各种奇怪的数据输入到你的程序中,并以你意想不到的方式点击东西。这没问题,因为你可以通过添加异常处理来处理代码抛出的异常,从而执行特殊的代码。
-
假设你的程序中调用的方法接受用户输入。
-
那个方法可能会在运行时出现风险。
-
你需要知道你调用的方法是有风险的。
注意
如果你能想出一种避免抛出异常的风险较小的方法,那就是最好的结果!但有些风险是无法避免的,这时候你就需要这样做。
-
然后,如果异常发生,你可以编写代码来处理异常。务必做好准备,以防万一。
使用 try 和 catch 处理异常
当你在代码中添加异常处理时,你会使用try和catch关键字创建一个代码块,该代码块在抛出异常时执行。
你的try/catch代码基本上告诉 C#编译器:“试试这段代码,如果出现异常,用这段其他代码捕获它。”你试图的代码部分是try块,处理异常的部分称为catch块。在catch块中,你可以做一些事情,比如打印友好的错误消息,而不是让程序停止运行。
让我们再来看看 HexDump 场景中堆栈跟踪的最后三行,帮助我们确定在哪里放置我们的异常处理代码:
at System.IO.File.OpenRead(String path)
at HexDump.Program.GetInputStream(String[] args) in Program.cs:line 14
at HexDump.Program.Main(String[] args) in Program.cs:line 20
UnauthorizedAccessException是由调用File.OpenRead的GetInputStream中的那一行引起的。由于我们无法阻止该异常,让我们修改GetInputStream以使用try/catch块:
在我们的异常处理程序中保持简单。首先,我们使用 Console.Error 写入了一行到错误输出(stderr),告知用户发生了错误,然后我们回退到从标准输入读取数据,以便程序仍然执行某些操作。注意catch块中有一个return语句。该方法返回一个流,因此如果处理异常,则仍需要返回一个流;否则,您将会得到“not all code paths return a value”编译器错误。
使用调试器跟踪 try/catch 流程
异常处理的重要部分是,当try块中的语句抛出异常时,块中的其余代码会被短路。程序立即跳转到catch块中的第一行。让我们使用 IDE 的调试器来探索这是如何工作的。
调试这个!
-
将您的 HexDump 应用程序中的 GetInputStream 方法替换为我们刚刚展示的方法,以处理 UnauthorizedAccessException。
-
修改项目选项,将参数设置为包含不可读文件的路径。
-
在 GetInputStream 的第一条语句上设置断点,然后开始调试您的项目。
-
当程序运行到断点时,跳过接下来的几个语句,直到到达
File.OpenRead。继续执行——应用程序跳转到catch块的第一行。 -
继续逐步执行
catch块的其余部分。它将向控制台写入一行,然后返回 Console.OpenStandardInput 并恢复 Main 方法。
如果您有代码始终需要运行,请使用 finally 块
当程序抛出异常时,可能会发生几件事情。如果异常未被处理,程序将停止处理并崩溃。如果异常被处理,代码将跳转到catch块。那么try块中的其余代码呢?如果您正在关闭流或清理重要资源怎么办?该代码需要运行,即使发生异常,否则程序状态将混乱。这就是您将使用**finally 块**的地方。它位于try和catch之后。**finally**块始终运行,无论是否抛出异常。让我们使用调试器来探索finally块的工作原理。
调试这个!
-
创建一个新的控制台应用程序项目。
在文件顶部添加
using System.IO;,然后添加以下Main方法:注意
您将在 Locals 窗口中看到异常,就像您之前看到的那样。
-
在 Main 方法的第一行添加断点。
调试你的应用程序并逐步执行它。
try块中的第一行尝试访问args[0],但由于您没有指定任何命令行参数,args数组为空,它会抛出一个异常——具体来说,是System.IndexOutOfRangeException,并显示消息*“Index was outside the bounds of the array.”* 在打印消息后,它执行**finally**块,然后程序退出。 -
设置一个带有有效文件路径的命令行参数。
使用项目属性向应用程序传递命令行参数。给它一个有效文件的完整路径。确保文件名中没有空格,否则应用程序会将其解释为两个参数。再次调试你的应用程序——在完成
try块后,它执行**finally**块。 -
设置一个带有无效文件路径的命令行参数。
返回到项目属性,更改命令行参数,将应用程序命名为不存在的文件。再次运行你的应用程序。这次它捕获了不同的异常:
System.IO.FileNotFoundException。然后它执行**finally**块。
通用异常捕获处理 System.Exception
你刚刚让你的控制台应用程序抛出了两种不同类型的异常——一个是IndexOutOfRangeException,另一个是FileNotFoundException,它们都被处理了。仔细看一下catch块:
catch (Exception ex)
这是一个通用异常捕获:catch块后的类型指示要处理的异常类型,由于所有异常都扩展自System.Exception类,指定Exception作为类型告诉try/catch块捕获任何异常。
避免使用多个catch块来捕获所有异常
尽量预料代码可能抛出的具体异常并处理它们是更好的做法。例如,我们知道如果没有指定文件名,此代码可能抛出IndexOutOfRangeException异常,如果找到无效文件,则可能抛出FileNotFoundException异常。我们还在本章的前面看到,尝试读取一个不可读文件会导致 CLR 抛出UnauthorizedAccessException。您可以通过在代码中添加多个catch块来处理这些不同类型的异常:
现在你的应用程序会根据处理的异常不同写入不同的错误消息。注意,前两个catch块未指定变量名(如ex)。只有在需要使用异常对象时才需要指定变量名。
池谜题
你的任务是从池中取出代码片段,并将它们放入程序中的空白行中。你可以多次使用相同的片段,而且不需要使用所有的片段。你的目标是使程序产生下面的输出。
池谜题解决方案
未处理的异常会上升。
信不信由你,留下未处理的异常确实非常有用。现实生活中的程序具有复杂的逻辑,当程序出现问题时,特别是在程序深处发生问题时,正确恢复通常很困难。通过仅处理特定异常并避免使用捕获所有异常的处理程序,你可以让意外的异常“冒泡上浮”:而不是在当前方法中处理它们,它们会被调用堆栈中下一个语句捕获。预期和处理你期望的异常,并让未处理的异常冒泡上浮,是构建更健壮应用程序的一个很好方法。
有时候重新抛出异常是有用的,这意味着你在方法中处理异常但仍然将其上抛给调用它的语句。重新抛出异常只需在catch块中调用throw;,它捕获的异常将立即上抛:
注意
职业提示:许多 C#编程工作面试都会问到你如何在构造函数中处理异常。
使用合适的异常处理情况
当你使用集成开发环境(IDE)生成一个方法时,它会添加以下代码:
private void MyGeneratedMethod()
{
throw new NotImplementedException();
}
NotImplementedException 用于任何未实现的操作或方法。它是一种很好的方法来添加占位符 —— 一旦你看到它,你就知道有需要编写的代码。这只是.NET 提供的众多异常之一。
选择正确的异常可以使你的代码更易读,并使异常处理更清晰和更健壮。例如,一个验证其参数的方法中的代码可以抛出 ArgumentException,它有一个重载的构造函数,用于指定造成问题的参数。考虑一下 Guy 类,它在#objectshellipget_orientedexclamation_mar 中返回,具有一个 ReceiveCash 方法,检查amount参数以确保接收到正数金额。这是一个很好的机会来抛出 ArgumentException:
花点时间查看一下.NET API 中的异常列表 —— 你可以在代码中抛出其中任何一个:docs.microsoft.com/en-us/dotnet/api/system.systemexception。
捕获扩展自 System.Exception 的自定义异常
有时候你希望程序因为运行时可能发生的特殊情况而抛出异常。让我们回到从#objectshellipget_orientedexclamation_mar 开始的 Guy 类。假设你在一个应用程序中使用它,这个应用程序绝对依赖于 Guy 始终具有正数金额。你可以添加一个扩展自 System.Exception 的自定义异常:
现在你可以抛出这个新异常,并像处理任何其他异常一样捕获它:
异常磁铁
安排磁铁,使应用程序将以下输出写入控制台:
当它解冻时它抛出。
class Program {
public static void Main(string[] args) {
Console.Write("when it ");
ExTestDrive.Zero("yes");
Console.Write(" it ");
ExTestDrive.Zero("no");
Console.WriteLine(".");
}
}
class MyException : Exception { }
异常磁铁解决方案
安排磁铁,使应用程序将以下输出写入控制台:
当它解冻时它抛出。
class Program {
public static void Main(string[] args) {
Console.Write("when it ");
ExTestDrive.Zero("yes");
Console.Write(" it ");
ExTestDrive.Zero("no");
Console.WriteLine(".");
}
}
异常过滤器帮助你创建精确的处理程序
假设我们正在建立一个设定在 20 世纪 30 年代经典黑手党犯罪地带的游戏,我们有一个 LoanShark 类需要使用 Guy.GiveCash 方法从 Guy 的实例中收集现金,并且使用老式黑手党风格的方式处理任何 OutOfCashException。
问题是,每个放高利贷的人都知道一个黄金法则:不要试图向大黑手党老板收钱。这就是异常过滤器可以派上用场的地方。异常过滤器使用when关键字告诉你的异常处理程序仅在特定条件下捕获异常。
这是一个异常过滤器如何工作的示例:
构建尽可能精确的异常处理程序总是更好的。
异常处理远不止打印通用错误消息那么简单。有时你希望对不同的异常做不同的处理——就像十六进制转储器从 FileNotFoundException 和 UnauthorizedAccessException 中不同处理一样。总是要为意外情况做计划。有时可以预防这些情况,有时希望处理它们,有时希望异常上升至上层。这里的一个重要教训是,处理意外情况没有一种“一刀切”的方法,这也是为什么 IDE 不只是在try/catch块中包裹所有内容。
注
这就是为什么有那么多继承自 Exception 的类,也是为什么你甚至可能想要编写自己的类来继承 Exception 的原因。
史上最糟糕的 catch 块:万能加注释
如果你愿意,catch块会让你的程序继续运行。异常被抛出,你捕捉异常,而不是关闭并给出错误消息,你继续进行。但有时候,这并不是件好事。
看看这个Calculator类,它似乎总是表现得很奇怪。发生了什么?
应该处理你的异常,而不是掩埋它们
仅仅因为你可以让程序继续运行,并不意味着你已经处理了你的异常。在上面的代码中,计算器不会崩溃……至少在 Divide 方法中不会。如果其他代码调用了该方法,并尝试打印结果呢?如果除数为零,那么该方法可能返回一个不正确(且意外的)值。
不要仅仅添加评论并隐藏异常,你需要处理异常。如果你无法处理问题,***不要留下空的或注释掉的catch块!***那只会让其他人更难追踪问题所在。最好让程序继续抛出异常,因为这样更容易找出问题所在。
注意
请记住,当你的代码无法处理异常时,异常会沿调用堆栈向上冒泡。让异常冒泡是一种完全有效的处理异常的方式,在某些情况下,这比使用空的 catch 块来隐藏异常更合理。
临时解决方案是可以接受的(暂时的)
有时你会发现问题,并且知道这是一个问题,但不确定该怎么办。在这些情况下,你可能希望记录问题并注明正在发生的情况。虽然这不如处理异常好,但比什么都不做要好。
这里是计算器问题的临时解决方案:
注意
...但在现实生活中,“临时”解决方案往往会变成永久性解决方案的不良习惯。
注意
花点时间思考一下这个catch块。如果StreamWriter无法写入到 C:\Logs\文件夹会发生什么?你可以嵌套另一个try/catch块来减少风险。你能想到更好的解决方案吗?
处理异常并不总是意味着修复异常。
让程序崩溃永远不是好事。更糟糕的是,如果不知道程序为何崩溃或它对用户数据造成了什么影响。这就是为什么你需要确保始终处理你能预测到的错误,并记录你无法预测到的错误。虽然日志对于追踪问题很有用,但在首次出现问题之前预防问题是更好、更永久的解决方案。
第十九章:Unity 实验室#6 场景导航
在上一个 Unity 实验室中,你创建了一个带有地板(一个平面)和玩家(一个球体嵌套在圆柱体下)的场景,并使用了 NavMesh、NavMesh Agent 和射线投射让你的玩家根据鼠标点击在场景中移动。
现在我们将继续上一个 Unity 实验室的工作。这些实验室的目标是让你熟悉 Unity 的寻路和导航系统,这是一个复杂的 AI 系统,可以让你创建能够在你创建的世界中找到路的角色。在这个实验室中,你将使用 Unity 的导航系统使你的游戏对象在场景中自动移动。
在此过程中,你将学习到一些有用的工具:你将创建一个更复杂的场景,并烘焙一个 NavMesh 以让一个代理人在其中导航,你将创建静态和移动障碍物,而且最重要的是,你将得到更多编写 C#代码的实践。
让我们继续上一个 Unity 实验室的工作
在上一个 Unity 实验室中,你通过一个球形头部嵌套在圆柱体身体下创建了一个玩家。然后,你添加了 NavMesh Agent 组件,用于使玩家在场景中移动,使用射线投射来找到玩家点击的地板上的点。在这个实验室中,你将继续上一个实验室的工作。你将向场景中添加游戏对象,包括楼梯和障碍物,以便观察 Unity 的导航 AI 如何处理它们。然后,你将添加一个移动障碍物,真正测试 NavMesh Agent 的性能。
现在,打开你在上一个 Unity 实验室结尾保存的 Unity 项目。如果你一直在保存 Unity 实验室以便一口气做完,那么你现在可能已经准备好立即开始了!但如果不是,请花几分钟再翻阅一下上一个 Unity 实验室,并查看你为其编写的代码。
注意
如果你正在使用我们的书籍,因为你正在准备成为一名专业开发者,那么能够回顾和重构你旧项目中的代码是一个非常重要的技能——不仅仅是为了游戏开发!
向你的场景添加一个平台
让我们通过一些对 Unity 导航系统的实验来进行一些试验。为了帮助我们做到这一点,我们将添加更多的游戏对象来建立一个带有楼梯、斜坡和障碍物的平台。这是它将会看起来的样子:
如果我们切换到等距视图或者不显示透视的视图,更容易看清楚正在发生的事情。在透视视图中,远处的物体看起来较小,而近处的物体看起来较大。在等距视图中,无论物体距离摄像机有多远,它们始终保持相同大小。
注意
有时,如果切换到等距视图,你能更容易地看到场景中正在发生的事情。如果你迷失了视角,你可以随时重置布局。
将 10 个游戏对象 添加到你的场景中。在你的材质文件夹中创建一个名为 Platform 的新材质,使用 Albedo 颜色 CC472F,并将其添加到除了障碍物之外的所有游戏对象上,障碍物则使用来自第一个 Unity 实验室的 名为 8 Ball 的新材质,并且带有 8 Ball 纹理映射。这张表展示了它们的名称、类型和位置:
使用烘焙选项使平台可行走
使用 Shift+点击选择你在场景中添加的所有新游戏对象,然后使用 Control+点击(或者在 Mac 上使用 Command+点击)取消选择障碍物。转到导航窗口并点击对象按钮,然后 通过 勾选导航静态并设置导航区域为可行走来 使它们全部可以行走。通过选择障碍物,点击导航静态,并将导航区域设置为不可行走来 使障碍物游戏对象不可行走。
现在按照之前使用的相同步骤来 烘焙 NavMesh:点击导航窗口顶部的烘焙按钮切换到烘焙视图,然后点击底部的烘焙按钮。
看起来好像奏效了!NavMesh 现在显示在平台的顶部,并且障碍物周围有空间。试着运行游戏。点击平台的顶部看看会发生什么。
嗯,等等。事情并没有按我们预期的方式运行。当你点击平台顶部时,玩家却在其下方。如果你仔细观察在导航窗口查看时显示的 NavMesh,你会发现它周围有楼梯和坡道的空间,但实际上并没有将它们包含在 NavMesh 中。玩家无法到达你点击的点,所以 AI 尽其所能靠近该点。
在你的 NavMesh 中包括楼梯和坡道
一个不能将你的玩家上下坡或楼梯的 AI 不会很智能。幸运的是,Unity 的路径 finding 系统可以处理这两种情况。我们只需要在烘焙 NavMesh 时对选项进行一些小的调整。让我们从楼梯开始。返回到烘焙窗口并注意步高的默认值是 0.4。仔细查看你的台阶测量值 —— 它们都是 0.5 单位高。因此,为了告诉导航系统包括高度为 0.5 单位的台阶,将步高改为 0.5。你会看到图表中的台阶图片变高,上面的数字从默认的 0.4 改变为 0.5。
我们仍然需要将坡道包含在 NavMesh 中。当你为平台创建游戏对象时,将坡道的 X 旋转设置为 -46,这意味着它是一个 46 度的斜坡。最大坡度设置默认为 45,这意味着它只会包括最多 45 度的坡道、山坡或其他斜坡。所以 将最大坡度更改为 46,然后 再次烘焙 NavMesh。现在它将包括坡道和楼梯。
启动你的游戏,测试一下你的新 NavMesh 更改。
修复 NavMesh 中的高度问题
现在我们控制了摄像机,可以清楚地看到平台下面发生了什么问题。启动游戏,然后旋转摄像机并放大视角,以清晰查看障碍物在平台下方的情况。点击障碍物一侧的地面,然后点击另一侧。看起来玩家直接穿过了障碍物!而且还穿过了坡道的尽头。
但如果你把玩家移回平台顶部,它会很好地避开障碍物。出了什么问题?
仔细观察障碍物上下的 NavMesh 部分。注意它们之间有什么区别吗?
回到上一个实验的部分,那里你设置了 NavMesh Agent 组件,具体来说是设置了高度为 3。现在你只需要对 NavMesh 做同样的设置。返回导航窗口的烘焙选项,将代理高度设置为 3,然后重新烘焙你的网格。
这在障碍物下面的 NavMesh 中创建了一个缺口,并扩展了坡道下面的空隙。现在玩家在平台下移动时既不会撞到障碍物也不会撞到坡道。
添加一个 NavMesh 障碍物
你已经在平台中间添加了一个静态障碍物:你创建了一个拉长的胶囊并标记为不可行走,当你烘焙 NavMesh 时,围绕障碍物有一个空洞,所以玩家必须绕过它。如果你想要一个移动的障碍物呢?试试移动障碍物——NavMesh 不会改变!它仍然在障碍物原来的位置创建了一个空洞,而不是它当前所在的位置。如果重新烘焙,它只会在障碍物新位置周围创建一个空洞。要添加一个移动的障碍物,给游戏对象添加一个NavMesh 障碍组件。
现在就来做吧。向场景中添加一个立方体,位置为(-5.75, 1, -1),缩放为(2, 2, 0.25)。为它创建一个新的材质,颜色设为深灰色(333333),并命名你的新游戏对象为移动障碍物。这将充当坡道底部的一种门,可以向上移动以让玩家通过,或向下以阻挡玩家。
我们只需要再做一件事。在检视器窗口底部点击“添加组件”按钮,选择导航 >> Nav Mesh Obstacle,为你的立方体游戏对象添加 NavMesh 障碍组件。
如果你保留所有选项的默认设置,你将得到一个 NavMesh 代理无法穿过的障碍物。相反,代理会撞上它并停下来。勾选雕刻框——这会导致障碍物在 NavMesh 中创建一个随着 GameObject 移动的移动洞口。现在你的移动障碍物 GameObject 可以阻止玩家在斜坡上下移动。由于 NavMesh 的高度设置为 3,如果障碍物低于地面 3 单位,它将在其下创建一个 NavMesh 中的洞口。如果它的高度超过这个高度,洞口就会消失。
注意
Unity 手册详细且易读地解释了各种组件。点击检视器中 Nav Mesh 障碍物面板顶部的打开参考按钮()以打开手册页面。花点时间阅读它——它很好地解释了这些选项。
添加一个脚本来上下移动障碍物
此脚本使用OnMouseDrag方法。它的工作方式类似于你在上一个实验中使用的 OnMouseDown 方法,只是当 GameObject 被拖动时调用它。
注意
第一个 if 语句阻止块移动到地板下方,第二个阻止它移动太高。你能搞清楚它们是如何工作的吗?
将你的脚本拖放到移动障碍物 GameObject 上并运行游戏——哎呀,出了些问题。你可以点击并拖动障碍物上下移动,但这也会移动玩家。通过给 GameObject 添加标签来修复这个问题。
然后修改你的 MoveToClick 脚本以检查标签:
再次运行你的游戏。如果你点击障碍物,你可以拖动它上下移动,并且当它碰到地面或者高度过高时会停下来。在其他任何地方点击,玩家会像以前一样移动。现在你可以尝试使用 NavMesh 障碍物选项进行实验(如果你减少玩家的 NavMesh 代理速度会更容易):
-
开始你的游戏。在层级窗口中点击移动障碍物,然后取消勾选雕刻选项。将你的玩家移到斜坡顶部,然后点击斜坡底部—玩家将会撞到障碍物并停下。拖动障碍物向上移动,玩家将继续移动。
-
现在勾选雕刻框并尝试同样的操作。当你上下移动障碍物时,玩家将重新计算其路线,绕开障碍物的长路,实时改变航线。
发挥创意!
你能找到改进游戏并练习编写代码的方法吗?以下是一些创意建议帮助你:
-
扩展场景——添加更多的斜坡、楼梯、平台和障碍物。寻找使用材料的创意方式。搜索网络以找到新的纹理地图。让它看起来有趣!
-
当玩家按住 Shift 键时使 NavMesh 代理移动更快。在脚本参考中搜索“KeyCode”以找到左/右 Shift 键的代码。
-
你在上次实验中使用了 OnMouseDown、Rotate、RotateAround 和 Destroy。看看你能否使用它们创建旋转或在点击时消失的障碍物。
-
我们实际上还没有一个游戏,只是一个在场景中导航的玩家。你能找到方法把你的程序变成一个计时障碍课程吗?
你已经掌握了足够的 Unity 知识来开始构建有趣的游戏——这是一个很好的练习方式,让你可以不断提高作为开发者的水平。
这是你实验的机会。发挥你的创造力是快速提升编码技能的有效方式。
可下载的练习:动物匹配老板战
如果你玩过很多视频游戏(我们非常确定你玩过!),那么你一定经历过很多老板战——那些在关卡或章节结束时,你要面对比你之前见过的更大更强的对手的战斗。在本书结束前,我们为你准备了最后一个挑战——把它看作是*Head First C#*的老板战。
在#start_building_with_chash_build_somethin 中,你构建了一个动物匹配游戏。这是一个很好的开始,但缺少了一些东西。你能想出如何将你的动物匹配游戏变成记忆游戏吗?去我们的 GitHub 页面下载这个项目的 PDF 文件——或者如果你想在困难模式下进行这场老板战斗,就直接开始尝试看看你是否能独自完成。
这里有更多的可下载材料!书籍已经结束,但我们可以继续学习。我们为重要的 C#主题准备了更多可下载材料。我们还通过额外的 Unity 实验室甚至是一个 Unity 老板战继续 Unity 学习路径。
我们希望你学到了很多——更重要的是,我们希望你的 C#学习之旅才刚刚开始。优秀的开发者永远不会停止学习。
请访问我们的 GitHub 页面获取更多信息:github.com/head-first-csharp/fourth-edition。
感谢阅读我们的书!
为自己喝彩吧——这是一个真正的成就!我们希望这段旅程对你和我们一样有意义,并且希望你享受沿途编写的所有项目和代码。
但是等等,还有更多!你的旅程才刚刚开始……
在一些章节中,我们提供了一些额外的项目,你可以从我们的 GitHub 页面下载:github.com/head-first-csharp/fourth-edition。
注
检查这些优秀的 C#和.NET 资源!
连接到.NET 开发者社区:dotnet.microsoft.com/platform/community。
观看直播和与构建.NET 和 C#的团队交流:dotnet.microsoft.com/platform/community/standup.
在文档中了解更多信息:docs.microsoft.com/en-us/dotnet.
GitHub 页面包含大量额外资料。仍有更多知识可以学习,更多项目可以实施!
继续你的 C#学习之旅,下载 PDF 继续*Head First C#*的故事,并涵盖C#的基本主题,包括:
-
事件处理程序
-
委托
-
MVVM 模式(包括复古街机游戏项目)
-
......还有更多!
当你在这里时,还有更多关于 Unity 的学习。你可以下载:
-
此书中所有 Unity 实验室的 PDF 版本
-
还有更多的 Unity 实验室,涵盖物理学、碰撞等内容!
-
一个Unity 实验室的老板战,以测试你的 Unity 开发技能
-
一个完整的Unity 实验室项目,从头开始创建游戏
还可以查看这些由我们的朋友和同事撰写的基础(和令人惊叹!)书籍,这些书籍也由 O’REILLY 出版。
附录 A. ASP.NET Core Blazor 项目:Visual Studio for Mac 学习指南
你的 Mac 是 C# 和 .NET 世界的一流公民。
我们在编写 Head First C# 时考虑了我们的 Mac 读者,这就是为什么我们为你们专门创建了这个特别的 学习指南。本书中的大多数项目都是 .NET Core 控制台应用程序,可以在 Windows 和 Mac 上运行。一些章节有一个使用桌面 Windows 应用程序技术构建的项目。这个学习指南为所有这些项目提供了 替代方案,包括一个 完整替代 #start_building_with_chash_build_somethin,使用 C# 创建 Blazor WebAssembly 应用程序,这些应用程序在浏览器中运行,与 Windows 应用程序等效。你将使用 Visual Studio for Mac 来完成所有这些工作,这是一个编写代码的好工具,也是探索 C# 的 宝贵学习工具。让我们立即开始编码吧!
为什么你应该学习 C#
C# 是一种简单、现代的语言,让你可以做很多令人惊讶的事情。当你学习 C# 时,你不仅仅是在学习一种语言。C# 开启了 .NET 的整个世界,这是一个非常强大的开源平台,用于构建各种应用程序。
Visual Studio 是你进入 C# 的大门
如果你还没有安装 Visual Studio 2019,现在就是时候了。
前往 visualstudio.microsoft.com 并 下载 Visual Studio for Mac。(如果已安装,请运行 Visual Studio for Mac 安装程序以更新已安装的选项。)
安装 .NET Core
一旦下载了 Visual Studio for Mac 安装程序,请运行它以安装 Visual Studio。确保已选中 .NET Core 目标。
注意
确保你安装的是 Visual Studio for Mac,而不是 Visual Studio Code。
注意
Visual Studio Code 是一个令人惊叹的开源、跨平台代码编辑器,但它并不像 Visual Studio 那样专为 .NET 开发量身定制。这就是为什么在本书中我们可以使用 Visual Studio 作为学习和探索工具。
你也可以使用 Visual Studio for Windows 来构建 Blazor Web 应用程序
Head First C# 中的大多数项目都是 .NET Core 控制台应用程序,你可以使用 macOS 或 Windows 创建这些应用程序。有些章节还包括一个使用 Windows Presentation Foundation (WPF) 构建的 Windows 桌面应用项目。由于 WPF 是一种仅适用于 Windows 的技术,我们编写了这个 Visual Studio for Mac 学习指南,以便你可以使用 Web 技术—具体来说是 ASP.NET Core Blazor WebAssembly 项目—在 Mac 上创建等效的项目。
如果你是 Windows 用户,并想学习使用 Blazor 构建丰富的 Web 应用程序,那么你很幸运!你可以使用 Windows 的 Visual Studio 来完成本指南中的项目。前往 Visual Studio 安装程序,并确保选择了**“ASP.NET 和 Web 开发”选项**。虽然你的 IDE 截图可能与本指南中的不完全相同,但所有的代码都是一样的。
Visual Studio 是一个编写代码和探索 C#的工具
你可以使用 TextEdit 或其他文本编辑器来编写你的 C#代码,但有一个更好的选择。一个IDE——这是集成开发环境的缩写——是一个文本编辑器、视觉设计器、文件管理器、调试器……它就像一个你需要编写代码所需的多功能工具。
这些只是 Visual Studio 帮助你完成的一些事情:
-
快速构建应用程序。 C#语言灵活且易于学习,而 Visual Studio IDE 通过自动完成大量手动工作,使得学习变得更加容易。以下只是 Visual Studio 为你做的一些事情:
-
管理所有你的项目文件
-
简化编辑项目代码的过程
-
跟踪你项目的图形、音频、图标和其他资源
-
通过逐行调试来帮助你调试代码
-
-
编写和运行你的 C#代码。 Visual Studio IDE 是目前为止使用最简单的编写代码工具之一。微软开发团队在使你编写代码的工作尽可能简单方面投入了大量的工作。
-
构建视觉效果出色的 Web 应用程序。 在这本 Visual Studio for Mac 学习指南中,你将构建能在浏览器中运行的 Web 应用程序。你将使用Blazor,这是一种使用 C#构建交互式 Web 应用程序的技术。当你结合 C#与 HTML 和 CSS时,你将拥有一个强大的 Web 开发工具包。
-
学习和探索 C#与.NET。 Visual Studio 不仅是一个世界级的开发工具,还是一个出色的学习工具。我们将使用 IDE 来探索 C#,这将使我们更快速地掌握重要的编程概念。
注意
在本书中,我们经常将 Visual Studio 简称为“IDE”。
Visual Studio 是一个令人惊叹的开发环境,但我们还将把它作为学习工具来探索 C#。
在 Visual Studio for Mac 中创建你的第一个项目
学习 C#的最佳方式是开始编写代码,因此我们将使用 Visual Studio 来创建一个新项目……并立即开始编写代码!
注意
执行此操作!
注意
当你看到“Do this!”(或“Now do this!”或“Debug this!”等),前往 Visual Studio 并跟着操作。我们会告诉你确切的操作步骤,并指出需要注意的示例中的内容,以便让你得到最大的收益。
-
创建一个新的控制台项目。
启动 Visual Studio 2019 for Mac。当它启动时,会显示一个窗口,让你创建一个新项目或打开一个现有项目。**点击“新建”**来创建一个新项目。如果你不小心关闭了窗口,别担心——你可以通过选择文件 >> 新建解决方案...(
)菜单项来重新打开它。
从左侧面板选择.NET,然后选择控制台项目:
-
将项目命名为 MyFirstConsoleApp。
在“项目名称”框中输入MyFirstConsoleApp,然后点击创建按钮来创建项目。
-
查看你的新应用的代码。
当 Visual Studio 创建一个新项目时,它会为你提供一个可以构建的起点。一旦它完成创建应用程序的新文件,它会打开并显示一个名为Program.cs的文件,其中包含以下代码:
使用 Visual Studio IDE 探索你的应用程序
-
探索 Visual Studio IDE——以及它为你创建的文件。
当你创建新项目时,Visual Studio 会自动为你创建几个文件,并将它们捆绑成一个解决方案。IDE 左侧的解决方案窗口显示这些文件,解决方案(MyFirstConsoleApp)位于顶部。解决方案包含一个与解决方案同名的项目。
-
运行你的新应用。
Visual Studio for Mac 为你创建的应用程序已经准备就绪。在 Visual Studio IDE 的顶部找到“运行”按钮(带有“播放”三角形)。点击该按钮来运行你的应用程序:
-
查看你程序的输出。
当你运行程序时,终端窗口会出现在 IDE 底部,并显示程序的输出:
学习一门语言的最佳方法是大量编写代码,所以你将在这本书中构建许多程序。其中许多将是控制台应用程序项目,所以让我们更仔细地看看你刚刚做了什么。
终端窗口顶部显示着程序的输出:
Hello World!点击代码中的任何位置来隐藏终端窗口。然后再次按下底部的
按钮来重新打开它——你会看到程序的相同输出。IDE 在你的应用程序退出后会自动隐藏终端窗口。
按下运行按钮再次运行你的程序。然后从运行菜单中选择“开始调试”,或者使用其快捷键(
)。这是你在整本书中运行所有控制台应用程序项目的方式。
让我们来构建一个游戏!
你已经构建了你的第一个 C# 应用程序,这太棒了!既然你已经做到了,那么让我们构建一个稍微复杂一点的东西。我们将创建一个动物配对游戏,玩家会看到一个包含 16 只动物的网格,需要点击成对的动物使它们消失。
你的动物配对游戏是一个 Blazor WebAssembly 应用程序
如果你只需要输入和输出文本,控制台应用程序非常适合。如果你想要一个显示在浏览器页面上的视觉应用程序,你需要使用不同的技术。这就是为什么你的动物匹配游戏将是一个Blazor WebAssembly 应用程序。Blazor 让你可以创建可以在任何现代浏览器中运行的丰富 Web 应用程序。本书的大部分章节将涉及到一个 Blazor 应用程序。这个项目的目标是向你介绍 Blazor,并为你提供构建丰富 Web 应用程序以及控制台应用程序的工具。
注意
在你的 C#学习工具箱中,构建不同类型的项目是一个重要的工具。我们在本书的 Mac 项目中选择了 Blazor,因为它为您提供了设计可以在任何现代浏览器上运行的丰富 Web 应用程序的工具。
但是 C#不仅仅适用于 Web 开发和控制台应用程序!在这本 Mac 学习指南中的每个项目都有一个相应的 Windows 项目。
你是 Windows 用户,但仍然想学习 Blazor 并使用 C#构建 Web 应用程序吗?那么你很幸运!本 Mac 学习指南中的所有项目也可以在 Visual Studio for Windows 中完成。
完成这个项目后,您将更加熟悉本书中学习和探索 C#所依赖的工具。
下面是如何构建你的游戏的方法
本章的其余部分将指导你逐步构建你的动物匹配游戏,你将在一系列单独的部分完成:
-
首先,您将在 Visual Studio 中创建一个新的 Blazor WebAssembly App 项目。
-
然后,您将布置页面,并编写 C#代码来洗牌动物。
-
游戏需要让用户点击一对表情符号来匹配它们。
-
你将编写更多的 C#代码来检测玩家何时赢得游戏。
-
最后,通过添加计时器使游戏更加令人兴奋。
注意
这个项目可能需要 15 分钟到一个小时不等的时间,具体取决于您打字的速度。我们在不感到赶时间的情况下学得更好,所以请给自己足够的时间。
注意
请注意,书中散布的这些“游戏设计...以及更多”元素。我们将使用游戏设计原则作为学习和探索重要编程概念和想法的途径,这些概念和想法适用于任何类型的项目,而不仅仅是视频游戏。
在 Visual Studio 中创建一个 Blazor WebAssembly App
构建游戏的第一步是在 Visual Studio 中创建一个新项目。
-
从菜单中选择**文件 >> 新建解决方案... (
)**以打开新项目窗口。这与您开始 Console App 项目的方式相同。
在左侧的“Web 和 Console”下点击App,然后选择Blazor WebAssembly App并点击下一步。
-
IDE 将会给你一个带有选项的页面。
将所有选项保持默认值并点击下一步。
如果您在这个项目中遇到任何问题,请访问我们的 GitHub 页面,并寻找视频教程链接:
-
输入 BlazorMatchGame 作为项目名称,就像您的控制台应用程序一样。
然后单击创建以创建项目解决方案。
-
IDE 将创建一个新的 BlazorMatchGame 项目,并显示其内容,就像您的第一个控制台应用程序一样。展开解决方案窗口中的 Pages 文件夹以查看其内容,然后双击 Index.razor 以在编辑器中打开它。
在浏览器中运行您的 Blazor Web 应用程序
运行 Blazor Web 应用程序时,有两部分:一个服务器和一个Web 应用程序。Visual Studio 通过一个按钮同时启动它们。
注意
就这样!
-
选择要运行您的 Web 应用程序的浏览器。
在 Visual Studio IDE 顶部找到三角形形状的运行按钮:
应调试>旁边列出您的默认浏览器。单击浏览器名称以查看已安装浏览器的下拉列表,并选择 Microsoft Edge 或 Google Chrome 中的任一浏览器。
-
运行您的 Web 应用程序。
单击运行按钮启动您的应用程序。您也可以从运行菜单中选择开始调试
。IDE 首先会打开一个生成输出窗口(底部,就像打开终端窗口一样),然后是一个应用程序输出窗口。之后,它将弹出一个运行您的应用程序的浏览器。
-
将 Index.razor 中的代码与浏览器中看到的内容进行比较。
您的浏览器中的 Web 应用程序有两部分:左侧有导航菜单,其中包含指向不同页面(主页、计数器和获取数据)的链接,右侧显示一个页面。将 Index.razor 文件中的 HTML 标记与浏览器中显示的应用程序进行比较。
-
将“Hello, world!”更改为其他内容。
更改 Index.razor 文件的第三行,使其显示其他内容:
<h1>Elementary, my dear Watson.</h1>现在返回浏览器并重新加载页面。等一下,什么都没变化 - 它仍然显示“Hello, world!”这是因为您更改了代码,但您从未更新服务器。
单击停止按钮
或从运行菜单中选择停止
。现在返回并重新加载浏览器 - 由于您已停止应用程序,它会显示“网站无法访问”的页面。
重新启动您的应用程序,然后在浏览器中重新加载页面。现在您将看到更新后的文本。
是否有额外的浏览器实例打开?每次运行 Blazor Web 应用程序时,Visual Studio 都会打开一个新的浏览器。养成在停止应用程序(
)之前关闭浏览器(
)的习惯。
现在,您已准备好开始为游戏编写代码了。
你创建了一个新的应用程序,Visual Studio 为你生成了一堆文件。现在是时候添加 C#代码来让你的游戏开始运行(以及 HTML 标记来使它看起来正确)。
你的动物配对游戏页面布局是如何工作的
你的动物配对游戏按网格布局排列——或者说看起来是这样。实际上,它由 16 个正方形按钮组成。如果你把浏览器变得非常窄,它们将重新排列成一个长列。
你将通过创建一个宽度为 400 像素的容器来布置页面(当浏览器处于默认缩放时,CSS“像素”为 1/96 英寸),其中包含 100 像素宽的按钮。我们将提供所有输入到 IDE 的 C#和 HTML 代码。请注意这段代码,很快将其添加到您的项目中—通过将 C#代码与 HTML 混合,实现了“魔法”:
Visual Studio 帮助你编写 C#代码
Blazor 让你创建丰富、交互式的应用程序,结合了 HTML 标记和 C#代码。幸运的是,Visual Studio IDE 有很多有用的功能帮助你编写这些 C#代码。
-
向Index.razor文件添加 C#代码。
首先在Index.razor文件末尾添加一个@code 块。(暂时保留文件的现有内容—稍后将删除它们。)转到文件的最后一行,键入
@code {。IDE 会为您填写右大括号}。按 Enter 在两个括号之间添加一行: -
使用 IDE 的 IntelliSense 窗口帮助你编写 C#代码。
将光标定位在
{大括号之间的行上,并键入字母**L**。IDE 将弹出一个IntelliSense 窗口,显示自动完成建议。从弹出窗口中选择List<>:IDE 将填写
List。添加一个尖括号(大于号)—IDE 将自动填写闭合尖括号,并将光标定位在它们之间。 -
开始创建一个列表来存储你的动物表情符号。
输入 s来显示另一个 IntelliSense 窗口:
选择
string—IDE 会在括号之间添加它。按下右箭头然后空格键,然后输入animalEmoji = new。再次按下空格键以弹出另一个 IntelliSense 窗口。按 Enter选择选项中的默认值,List<string>。现在你的代码应该看起来像这样:
List<string> animalEmoji = new List<string> -
完成创建动物表情符号的列表。
首先在Index.razor文件末尾添加一个
@code块。转到最后一行,键入**@code {.** IDE 会为您填写右大括号}。按 Enter 在两个大括号之间添加一行,然后:-
键入一个开括号(左圆括号)—IDE 将填写右圆括号。
-
按右箭头移动到括号后面。
-
输入一个 左大括号 {—IDE 将自动添加右大括号。
-
按 Enter 在括号之间添加一行,然后在右括号后添加一个分号 ;。
您的 Index.razor 文件底部的最后六行现在应该如下所示:
-
-
使用字符查看器输入表情符号。
接下来,选择 Edit >> Emoji & Symbols (
空格) 从菜单中打开 macOS 字符查看器。将光标放在引号之间,然后在字符查看器中搜索“dog”:
您的 Index.razor 文件底部的最后六行现在应该如下所示:
完成您的表情符号列表并在应用程序中显示它
您刚刚将一只狗表情符号添加到您的 animalEmoji 列表中。现在通过在第二个引号后添加逗号、空格、另一个引号、另一只狗表情符号和逗号,再添加一个第二只狗表情符号:
现在在其后添加第二行,与之前完全相同,只是用一对狼表情符号替换狗表情符号。然后再添加六行,每行分别包含一对牛、狐狸、猫、狮子、老虎和仓鼠表情符号。您现在的 animalEmoji 列表中应该有八对表情符号:
替换页面的内容
删除页面顶部的这些行:
然后将光标放在页面的第三行上,并输入 <st—IDE 将弹出 IntelliSense 窗口:
从列表中选择 style,然后输入 >。IDE 将添加一个闭合的 HTML 标签:<style></style>
将光标放在 <style> 和 </style> 之间,按 Enter,然后仔细输入以下所有代码。确保您的应用程序中的代码与其完全匹配。
转到下一行,使用 IntelliSense 输入一个开放和闭合的
<style> 一样。然后仔细输入下面的代码,确保完全匹配:
注
确保在运行应用程序时它看起来像这张屏幕截图一样。一旦看到这个界面,您就知道已经输入了所有代码而没有任何拼写错误。
将动物随机排序以创建一个新的顺序
如果动物对都排在一起,我们的匹配游戏将会太简单。让我们添加 C# 代码来打乱动物的顺序,以便每次玩家重新加载页面时它们都会以不同的顺序出现。
-
将光标放在底部 Index.razor 附近的右大括号
}上方的分号;后面,按 Enter 两次。然后像之前一样使用 IntelliSense 弹出窗口输入以下行代码:List<string> shuffledAnimals = new List<string>(); -
下一步 输入
protected override(IntelliSense 可以自动完成这些关键字)。一旦输入并键入空格,您将看到 IntelliSense 弹出窗口—从列表中选择 OnInitialized():IDE 将填充一个名为 OnInitialized 的方法的代码(我们将在#dive_into_chash_statementscomma_classesc 中更多地讨论方法):
-
用
SetUpGame()替换base.OnInitialized(),这样你的方法看起来像这样:protected override void OnInitialized() { SetUpGame(); }然后在你的 OnInitialized 方法下面添加这个 SetUpGame 方法——再次,智能感知窗口将帮助你正确地完成它:
当你在 SetUpGame 方法中输入代码时,你会注意到 IDE 弹出许多智能感知窗口,帮助你更快地输入代码。你使用 Visual Studio 编写 C#代码的次数越多,这些窗口就会变得越有帮助——最终你会发现它们显著加快速度。现在,利用它们来避免输入拼写错误——你的代码需要与我们的代码完全匹配,否则你的应用程序将无法运行。
-
滚动回到 HTML 并找到这段代码:
@foreach (var animal in animalEmoji)双击
animalEmoji来选择它,然后输入 s。IDE 将弹出一个智能感知窗口。从列表中选择shuffledAnimals:现在再次运行你的应用程序。你的动物应该被洗牌,所以它们是随机顺序的。在浏览器中重新加载页面——它们将以不同的顺序重新洗牌。每次重新加载,它都会重新洗牌动物。
注意
再次确保当你运行时你的应用程序看起来像这个截图。一旦它这样做了,你就会知道你输入了所有的代码而没有任何拼写错误。在你的游戏在重新加载浏览器页面时每次都在重新洗牌之前不要继续。
你正在调试你的游戏
当你点击运行按钮 或选择从运行菜单中选择开始调试
来启动程序运行时,你就把 Visual Studio 置于调试模式。
当你看到工具栏中出现调试控件时,你可以知道你正在调试一个应用程序。开始按钮已被方形的停止按钮 替换,选择要启动的浏览器的下拉菜单变灰,还出现了一组额外的控件。
将鼠标悬停在暂停执行按钮上以查看其工具提示:
你可以通过点击停止按钮或从运行菜单中选择停止 来停止你的应用程序。
你已经为接下来要添加的部分做好了准备。
当你构建一个新游戏时,你不仅仅是在编写代码。你也在运行一个项目。一个非常有效的运行项目的方式是逐步构建它,沿途检查确保事情朝着良好的方向发展。这样你就有很多机会改变方向。
注意
这是一个纸笔练习。把所有这些练习都做完绝对值得,因为它们将帮助你更快地掌握重要的 C#概念。
提高你的代码理解能力将使你成为一个更好的开发者。
铅笔和纸上的练习不是可选的。它们让你的大脑以不同的方式吸收信息。但更重要的是:它们给了你犯错的机会。犯错是学习的一部分,我们都犯过很多错误(你甚至可能在这本书中找到一两个拼写错误!)。没有人第一次写出完美的代码 —— 真正优秀的程序员总是假设他们今天写的代码可能明天就需要修改。事实上,书中的后面部分会介绍重构,即改进已编写代码的编程技术。
注意
我们将添加类似这样的项目要点,以快速总结你到目前为止看到的许多想法和工具。
将你的新项目添加到源代码控制中
在这本书中,你将建立许多不同的项目。如果有一种简单的方法可以备份它们并随时访问它们,那不是很好吗?如果你犯了错误,如果你能方便地回滚到以前的代码版本,那不是非常方便吗?好吧,你很幸运!这正是源代码控制做的事情:它为你提供了一种简单的方法来备份你的所有代码,并跟踪你所做的每一个更改。Visual Studio 让你很容易地将你的项目添加到源代码控制中。
Git是一个流行的版本控制系统,而 Visual Studio 会将你的源代码发布到任何 Git 存储库(或repo)。我们认为GitHub是使用最简单的 Git 提供者之一。你需要一个 GitHub 账号来向其推送代码,所以如果你还没有,请访问github.com并创建。
设置好你的 GitHub 账号后,你可以使用 IDE 的内置版本控制功能。从菜单中选择版本控制 >> 发布到版本控制... 来打开克隆存储库窗口:
注意
**Visual Studio for Mac 文档完整介绍了在 GitHub 上创建项目并从 Visual Studio 发布的步骤。它包括了为在 GitHub 上创建远程仓库和直接从 Visual Studio 向 Git 发布项目提供逐步说明。我们认为将所有Head First C#项目发布到 GitHub 是一个好主意,这样你以后可以轻松地返回到它们。docs.microsoft.com/en-us/visualstudio/mac/set-up-git-repository。
添加 C#代码来处理鼠标点击
你有带有随机动物表情符号的按钮。现在你需要点击它们时让它们做一些事情。操作方式如下:
给你的按钮添加点击事件处理程序
当你点击一个按钮时,它需要做一些事情。在网页中,点击是一个事件。网页还有其他事件,比如当页面完成加载时,或者当输入框发生变化时。一个事件处理程序是一段在特定事件发生时执行的 C# 代码。我们将添加一个事件处理程序来实现按钮的功能。
这是事件处理程序的代码
将这段代码添加到你的 Razor 页面底部,就在底部的 **}** 上方:
将你的事件处理程序连接到按钮上
现在你只需要修改按钮,使其在点击时调用 ButtonClick 方法:
注意
当我们要求你在代码块中更新一个内容时,我们可能会使其余的代码变得浅一些,并使你修改的部分变为粗体。
注意
哎呀——这段代码中有一个 bug!你能找出来吗?我们将在下一节追踪并修复它。
测试你的事件处理程序
再次运行你的应用程序。当它启动时,通过单击按钮来测试你的事件处理程序,然后再单击带有匹配表情符号的按钮。它们应该都会消失。
依次单击另一个,然后再依次单击另一个。你应该能够继续单击成对,直到所有按钮都变空。恭喜,你找到了所有的配对!
但是如果你连续点击同一个按钮会发生什么?
在浏览器中重新加载页面以重置游戏。但这一次,而不是找到一对,连续点击两次同一个按钮。等等——游戏中有一个 bug! 它本应忽略点击,但实际上像你找到了一对一样。
使用调试器来解决问题
你可能以前听过“bug”这个词。你甚至可能曾经对你的朋友说过类似的话:“那个游戏有很多 bug,有这么多故障。”每个 bug 都有一个解释——你程序中的每件事都有其原因——但不是每个 bug 都容易追踪。
理解 bug 是修复它的第一步。 幸运的是,Visual Studio 调试器是一个很好的工具。(这就是为什么它被称为调试器:它是帮助你消除 bug 的工具!)
-
考虑一下出了什么问题。
首先要注意的是,你的 bug 是可复现的:每次你连续单击相同的按钮两次时,它总是像你点击了一个匹配的对。
第二件需要注意的事情是,你对 bug 的位置有一个相当好的想法。问题只发生在你添加了处理 Click 事件的代码之后,所以那是一个很好的起点。
-
为你刚刚编写的 Click 事件处理程序的代码添加断点。
点击 ButtonClick 方法的第一行,然后从菜单中选择 Run >> Toggle Breakpoint (
)。该行将改变颜色,并在左边缘看到一个点:
继续调试你的事件处理程序
现在你的断点已经设置好了,使用它来了解代码的运行情况。
-
点击一个动物以触发断点。
如果你的应用程序已经在运行,请停止它并关闭所有浏览器窗口。然后再次运行你的应用程序并点击任何一个动物按钮。Visual Studio 应该弹出到前景。你切换了断点的行应该现在以不同的颜色高亮显示:
将鼠标移动到方法的第一行,该行以
private void开头,并将光标悬停在 animal 上。将弹出一个小窗口,显示你点击的动物:按下Step Over按钮或选择 Run >> Step Over (
)菜单。高亮将移动到
**{**行。再次跨过以将高亮移至下一个语句:再次跨过一次以执行该语句,然后悬停在
lastAnimalFound上:你刚刚跨过的声明将
lastAnimalFound的值设为与animal相匹配。这就是代码如何跟踪玩家点击的第一个动物。
-
继续执行。
按下Continue Execution按钮或选择 Run >> Continue Debugging (
)菜单。切换回浏览器 - 你的游戏将继续进行直到再次触发断点。
-
点击配对中的相匹配动物。
找到具有匹配表情符号的按钮并点击它。IDE 将触发断点并再次暂停应用程序。按下Step Over - 它会跳过第一个块并跳转到第二个:
悬停在
lastAnimalFound和animal上 — 它们应该都有相同的表情符号。这就是事件处理程序知道你找到匹配项的方式。再跨过三次:现在悬停在
shuffledAnimals上。你会看到弹出窗口中有几个项目。点击shuffledAnimals旁边的三角形以展开它,然后展开_items以查看所有动物:再次按下Step Over以执行从列表中移除匹配项的语句。然后再次悬停在
**shuffledAnimals**上 并查看它的项目。现在匹配表情符号的位置有两个(null)值:我们已经筛选了大量证据并收集了一些重要线索。你认为问题的根源是什么?
追踪引起问题的错误...
那么如果你两次点击相同的动物按钮会发生什么呢?让我们找出来!重复刚才做过的步骤,但这次两次点击相同的动物。观察当你到达步骤时发生了什么。
悬停在animal和lastAnimalFound上,就像之前一样。它们是相同的!这是因为事件处理程序没有办法区分不同按钮上相同的动物。
...并修复错误!
现在我们知道是什么导致了这个错误,我们知道如何修复它:给事件处理程序一种区分两个具有相同表情符号按钮的方法。
首先,对 ButtonClick 事件处理程序进行这些更改(确保不会漏掉任何更改):
然后用另一种循环替换foreach 循环,即for循环——这个 for 循环计算动物的数量:
现在再次通过应用程序进行调试,就像之前一样。这次当你两次点击相同的动物时,它会跳到事件处理程序的末尾。错误已修复!
当玩家赢得比赛时,添加重置游戏的代码
游戏进行得很顺利——你的玩家从一个充满动物的网格开始匹配,他们可以点击成对的动物,当它们匹配时它们会消失。但是当所有匹配项都被找到时会发生什么?我们需要一种重置游戏的方式,让玩家有第二次机会。
注意
当你看到脑力元素时,花一分钟时间真正思考它问的问题。
通过添加计时器完成游戏
如果玩家可以尝试击败他们的最佳时间,你的动物匹配游戏将会更加令人兴奋。我们将添加一个计时器,通过重复调用方法在固定间隔后“滴答”。
注意
计时器通过反复调用方法以固定间隔“滴答”。你将使用一个计时器,当玩家开始游戏时启动,最后一个动物匹配时结束。
在你游戏的代码中添加一个计时器
添加这个!
-
首先找到Index.razor文件的顶部这一行:
@page "/"在下面添加这行代码 —— 你需要它来在你的 C#代码中使用计时器:
@using System.Timers -
你需要更新 HTML 标记以显示时间。将其添加到练习中第一个添加的代码块的下面:
</div> <div class="row"> <h2>Matches found: @matchesFound</h2> </div> <div class="row"> <h2>Time: @timeDisplay</h2> </div> </div> -
你的页面需要一个计时器。它还需要跟踪经过的时间:
List<string> shuffledAnimals = new List<string>(); int matchesFound = 0; Timer timer; int tenthsOfSecondsElapsed = 0; string timeDisplay; -
你需要告诉计时器每隔多久“滴答”一次以及调用什么方法。你将在 OnInitialized 方法中做这些,这个方法在页面加载后调用一次:
protected override void OnInitialized() { timer = new Timer(100); timer.Elapsed += Timer_Tick; SetUpGame(); } -
当你设置游戏时重置计时器:
private void SetUpGame() { Random random = new Random(); shuffledAnimals = animalEmoji .OrderBy(item => random.Next()) .ToList(); matchesFound = 0; tenthsOfSecondsElapsed = 0; } -
你需要停止并重新启动计时器。在 ButtonClick 方法的顶部附近添加这行代码来在玩家点击第一个按钮时启动计时器:
if (lastAnimalFound == string.Empty) { // First selection of the pair. Remember it. lastAnimalFound = animal; lastDescription = animalDescription; timer.Start(); }最后,在 ButtonClick 方法的更深处添加这两行代码来停止计时器,并在玩家找到最后一对匹配后显示“再玩一次?”消息:
matchesFound++; if (matchesFound == 8) { timer.Stop(); timeDisplay += " - Play Again?"; SetUpGame(); } -
最后,你的计时器需要知道每次滴答时该做什么。就像按钮有 Click 事件处理程序一样,计时器有 Tick 事件处理程序:每次计时器滴答时执行的方法。
将此代码添加到页面的最底部,就在闭合大括号
}的上方:private void Timer_Tick(Object source, ElapsedEventArgs e) { InvokeAsync(() => { tenthsOfSecondsElapsed++; timeDisplay = (tenthsOfSecondsElapsed / 10F) .ToString("0.0s"); StateHasChanged(); }); }
注意
当玩家点击第一个动物时计时器开始计时,并且当找到最后一对匹配时停止。这并不会从根本上改变游戏的运行方式,但会让游戏更加令人兴奋。
清理导航菜单
你的游戏正在运行!但是你是否注意到你的应用中还有其他页面?尝试在左侧导航菜单中点击“Counter”或“Fetch data”。在创建 Blazor WebAssembly 应用程序项目时,Visual Studio 添加了这些额外的示例页面。你可以安全地将它们移除。
首先,展开wwwroot 文件夹并编辑index.html。找到以<title>开头的行并修改它,使其看起来像这样:<title> 动物配对游戏 </title>
接下来,展开解决方案中的Shared 文件夹,并双击 NavMenu.razor。找到这一行:
<a class="navbar-brand" href="">BlazorMatchGame</a>
并用这个替换它:
<a class="navbar-brand" href="">Animal Matching Game</a>
然后删除这些行:
最后,按住 (Command 键)并点击以多选这些文件在解决方案窗口中:在 Pages 文件夹中的Counter.razor和FetchData.razor,在 Shared 文件夹中的SurveyPrompt.razor,以及在 wwwroot 文件夹中的整个 sample-data文件夹。一旦它们全部选择完毕,右键单击其中一个并从菜单中选择删除(
图标)来删除它们。
现在你的游戏完成了!
每当你有一个大项目时,将其拆分成小部分总是一个好主意。
你可以培养的最有用的编程技能之一是能够看待一个庞大而困难的问题,并将其分解成更小、更容易解决的问题。
在开始一个大项目时很容易感到不知所措,然后想,“哇,这太大了!”但如果你能找到一个小部分可以着手,然后你就可以开始了。完成了那一部分后,你可以继续下一个小部分,然后下一个,再下一个。随着每个部分的建设,你会在途中了解更多关于你的大项目的信息。
更好的建议...
你的游戏做得相当不错!但每个游戏——事实上,几乎每个程序——都可以改进。以下是我们考虑到的一些可以让游戏变得更好的建议:
-
添加不同种类的动物,这样每次不会出现相同的动物。
-
记录玩家的最佳时间,这样他们可以尝试超越它。
-
让计时器倒计时而不是计时上升,这样玩家就有限定的时间了。
注意
我们是认真的——花几分钟时间去做这件事。退后一步,思考一下你刚刚完成的项目,这是将学到的经验融入你的大脑的好方法。
来自#dive_into_chash_statementscomma_classesc 深入学习 C#
注意
这是#dive_into_chash_statementscomma_classesc 中 Windows 桌面项目的 Blazor 版本。
注意
#dive_into_chash_statementscomma_classesc 的最后一部分是一个 Windows 项目,用于尝试不同类型的控件。我们将使用 Blazor 来构建一个类似的项目,以尝试 Web 控件。
控件驱动用户界面的机制
在上一章中,你使用了按钮控件来制作了一个游戏。但是有很多不同的方式可以使用控件,而你选择使用哪些控件会真正改变你的应用。听起来很奇怪吗?其实这与我们在游戏设计中做选择的方式非常相似。如果你设计一个需要随机数生成器的桌面游戏,你可以选择使用骰子、旋转器或者卡片。如果你设计一个平台游戏,你可以选择让你的玩家跳跃、双重跳跃、墙壁跳跃或者飞行(或者在不同时间做不同的事情)。对应用程序也是一样的:如果你设计一个用户需要输入数字的应用程序,你可以选择不同的控件让他们这样做 —— 而这个选择会影响用户体验应用程序的方式。
-
文本框 允许用户输入任何文本。但我们需要一种方法来确保他们只输入数字,而不是任意文本。
-
滑块 专门用于选择数字。电话号码也只是数字,所以从技术上讲,你可以使用滑块来选择电话号码。你认为这是一个好的选择吗?
-
选择器 是专门设计用来从列表中选择特定类型值的控件。例如,日期选择器 允许你通过选择年、月和日来指定日期,而 颜色选择器 则允许你使用色谱滑块或其数值来选择颜色。
-
单选按钮 允许你限制用户的选择。它们通常看起来像有点的圆圈,但你也可以将它们样式化成普通按钮的样子。
创建一个新的 Blazor WebAssembly 应用程序项目
在此Visual Studio for Mac 学习指南 的前面,你为你的动物匹配游戏创建了一个 Blazor WebAssembly 应用程序项目。你也将为这个项目做同样的事情。
这里是创建 Blazor WebAssembly 应用程序项目、更改主页标题文本和删除 Visual Studio 创建的额外文件的简洁步骤集。我们不会在本指南的每个附加项目中重复这些步骤 —— 你应该能够对所有未来的 Blazor WebAssembly 应用程序项目都使用相同的指令。
-
创建一个新的 Blazor WebAssembly 应用程序项目。
要么启动 Visual Studio 2019 for Mac,或者从菜单中选择 File >> New Solution...(
)来打开新项目窗口。点击 New 创建一个新项目。将其命名为 ExperimentWithControlsBlazor。
-
更改标题和导航菜单。
在动物匹配游戏项目的结尾,您修改了标题和导航栏文本。对于此项目,也要执行相同的操作。展开 wwwroot 文件夹 并编辑 Index.html。找到以
<title>开头的行,并修改它,使其看起来像这样:<title> **Experiment with Controls** </title>在解决方案中扩展 Shared 文件夹 并双击 NavMenu.razor。 找到这一行:
<a class="navbar-brand" href="">ExperimentWithControlsBlazor</a>并用此代码替换它:
<a class="navbar-brand" href="">Experiment With Controls</a> -
删除额外的导航菜单选项及其对应的文件。
这就像您在动物匹配游戏项目的结尾所做的那样。双击 NavMenu.razor 并删除这些行:
然后按住
(Command 键),并单击以多选这些文件在解决方案窗口中:在 Pages 文件夹中的 Counter.razor 和 FetchData.razor,在 Shared 文件夹中的 SurveyPrompt.razor,以及 wwwroot 文件夹中的 entire sample-data 文件夹。一旦它们都被选中,右键单击其中一个文件,然后从菜单中选择 Delete(
)来删除它们。
创建一个带有滑块控件的页面
您的许多程序都需要用户输入数字,而输入数字的最基本控件之一是滑块,也称为范围输入。让我们创建一个新的 Razor 页面,使用滑块来更新一个值。
-
替换 Index.razor 页面。
打开 Index.razor 并用此 HTML 标记替换其所有内容:
-
运行您的应用程序。
运行您的应用程序,就像您在 #start_building_with_chash_build_somethin 中所做的那样。将 HTML 标记与浏览器中显示的页面进行比较 - 将各个
<div>块与页面上显示的内容匹配起来。 -
将 C# 代码添加到您的页面中。
返回到 Index.razor 并在文件底部添加此 C# 代码:
-
将您的范围控件连接到刚刚添加的 Change 事件处理程序。
为您的范围控件添加一个
@onchange属性:
向您的应用程序添加一个文本输入
该项目的目标是尝试不同类型的控件,因此让我们添加一个文本输入控件,使用户可以在应用程序中输入文本并在页面底部显示。
-
向您页面的 HTML 标记中添加一个文本输入控件。
添加一个
**<input ... />**标签,几乎与您为滑块添加的标签相同。唯一的区别在于,您将type属性设置为"text"而不是"range"。以下是 HTML 标记:再次运行你的应用程序 —— 现在它有一个文本输入控件。无论你输入什么文本,它都会显示在页面底部。尝试修改文本,然后移动滑块,然后再次修改文本。每次修改控件时,页面底部的值都会更改。
-
添加一个仅接受数值的事件处理方法。
如果你只想从用户那里接受数值输入,那该怎么办?在 Razor 页面底部的大括号之间添加此方法:
-
更改文本输入框以使用新的事件处理程序方法。
修改你的文本控件的
@onchange属性,以调用新的事件处理程序:<input type="text" placeholder="Enter text" @onchange="UpdateNumericValue" />现在尝试将文本输入到文本输入框中 —— 除非你输入的文本是整数值,否则它不会更新页面底部的值。
为你的应用添加颜色选择器和日期选择器
选择器只是不同类型的输入。日期选择器的输入类型是 "date",颜色选择器的输入类型是 "color" — 除此之外,这些输入类型的 HTML 标记是相同的。
修改你的应用程序,添加一个日期选择器和一个颜色选择器。这是 HTML 标记 — 将它添加到包含显示值的 <div> 标记的上方:
注意
这就是项目的结尾 — 做得很棒!你可以在结尾处继续学习 #dive_into_chash_statementscomma_classesc,在那里有一个坐在椅子上思考的人,他在想:用户有很多不同的选择数字的方式!
来自 #objectshellipget_orientedexclamation_mar 的对象…… 以获取方向!
注意
这是 Windows 桌面项目在 #objectshellipget_orientedexclamation_mar 的 Blazor 版本。
注意
在 #objectshellipget_orientedexclamation_mar 的中途,有一个项目,你将构建一个 Windows 版本的卡片选择器应用程序。我们将使用 Blazor 来构建同样功能的基于 Web 的版本。
接下来:构建你的卡片选择应用的 Blazor 版本
在接下来的项目中,你将构建一个名为 PickACardBlazor 的 Blazor 应用程序。它将使用滑块来让你选择随机抽取的卡片数量,并在列表中显示这些卡片。以下是它的外观:
在一个新的 Blazor 应用中重用你的 CardPicker 类
重用这个!
如果你已经为一个程序编写了一个类,你通常会希望在另一个程序中使用相同的行为。这就是使用类的一个重要优势 — 它们使得代码重用更加容易。让我们为你的卡片选择器应用程序设计一个闪亮的新用户界面,但通过重用你的 CardPicker 类来保持相同的行为。
-
创建一个名为 PickACardBlazor 的新 Blazor WebAssembly 应用项目。
你将按照创建动物匹配游戏中使用的完全相同的步骤来创建你的应用程序#start_building_with_chash_build_somethin:
-
打开 Visual Studio 并创建一个新项目。
-
选择Blazor WebAssembly App,就像你之前在其他 Blazor 应用程序中做的那样。
-
给你的新应用程序取名为PickACardBlazor。Visual Studio 将创建该项目。
-
-
添加 CardPicker 类,该类是你为控制台应用程序项目创建的。
右键单击项目名称,然后从菜单中选择添加 >> 现有文件...:
转到包含你的控制台应用程序的文件夹,然后单击CardPicker.cs将其添加到你的项目中。Visual Studio 会询问你是否要复制、移动或链接文件。告诉 Visual Studio复制文件。你的项目现在应该有来自控制台应用程序的CardPicker.cs文件的副本。
-
更改 CardPicker 类的命名空间。
双击CardPicker.cs在解决方案窗口中。它仍然具有来自控制台应用程序的命名空间。更改命名空间以匹配你的项目名称:
恭喜,你已经重用了你的 CardPicker 类! 你应该在解决方案窗口中看到这个类,并且可以在你的 Blazor 应用程序的代码中使用它。
页面使用行和列布局。
在#start_building_with_chash_build_somethin 和#dive_into_chash_statementscomma_classesc 中使用 HTML 标记来创建行和列,而这个新应用程序也是如此。下面是显示应用程序布局的图片:
滑块使用数据绑定来更新一个变量。
页面底部的代码将从一个名为numberOfCards的变量开始:
@code {
int numberOfCards = 5;
你可以使用事件处理程序来更新numberOfCards,但是 Blazor 有更好的方法:数据绑定,它允许你设置输入控件以自动更新你的 C#代码,并且可以自动将你的 C#代码的值插入页面中。
下面是页眉、范围输入和显示其值的文本的 HTML 标记:
仔细查看<input>标签的属性。min和max属性限制输入值为 1 到 15 之间。**@bind**属性设置数据绑定,因此每当滑块更改时,Blazor 会自动更新numberOfCards。
<input>标签后面是<div class="col-2">**@numberOfCards</div>**—这段标记添加了文本(ml-2在左边距添加了空间)。这也使用数据绑定,但是反向操作:每当numberOfCards字段更新时,Blazor 会自动更新该<div>标签内的文本。
注意
这就是项目的结束——干得好!你可以回到#objectshellipget_orientedexclamation_mar 并在标题为“Ana's prototypes look great...”的部分继续。
来自#types_and_references_getting_the_referen 类型和引用
注意
这是 Windows 桌面项目的 Blazor 版本,位于#types_and_references_getting_the_referen。
在#types_and_references_getting_the_referen 的结尾有一个 Windows 项目。我们将构建其 Blazor 版本。
欢迎来到 Sloppy Joe's Budget House o' Discount Sandwiches!
Sloppy Joe 有一堆肉,一大堆面包,比你能想象的调味品还多。但他没有菜单!你能建立一个每天为他制作新的随机菜单的程序吗?你绝对可以……通过一个新的 Blazor WebAssembly 应用程序项目,一些数组和一些有用的新技术。
做这个!
-
向你的项目添加一个新的 MenuItem 类,并添加其字段。
看一看类图。它有六个字段:一个 Random 实例,三个数组来保存各种三明治部件,以及用于保存描述和价格的字符串字段。数组字段使用集合初始化器,让你可以通过将项目放在大括号内来定义数组中的项目。
class MenuItem { public Random Randomizer = new Random(); public string[] Proteins = { "Roast beef", "Salami", "Turkey", "Ham", "Pastrami", "Tofu" }; public string[] Condiments = { "yellow mustard", "brown mustard", "honey mustard", "mayo", "relish", "french dressing" }; public string[] Breads = { "rye", "white", "wheat", "pumpernickel", "a roll" }; public string Description = ""; public string Price; } -
将 Generate 方法添加到 MenuItem 类中。
此方法使用了你多次看到的 Random.Next 方法,从 Proteins、Condiments 和 Breads 字段的数组中随机选择项目,并将它们连接成一个字符串。
public void Generate() { string randomProtein = Proteins[Randomizer.Next(Proteins.Length)]; string randomCondiment = Condiments[Randomizer.Next(Condiments.Length)]; string randomBread = Breads[Randomizer.Next(Breads.Length)]; Description = randomProtein + " with " + randomCondiment + " on " + randomBread; decimal bucks = Randomizer.Next(2, 5); decimal cents = Randomizer.Next(1, 98); decimal price = bucks + (cents * .01M); Price = price.ToString("c"); }注意
Generate 方法通过将两个随机整数转换为小数来生成介于 2.01 到 5.97 之间的随机价格。仔细看最后一行——它返回
price.ToString("c")。ToString 方法的参数是一个格式。在这种情况下,"c"格式告诉 ToString 使用本地货币格式化值:如果你在美国,你会看到$;在英国,你会看到£;在欧盟,你会看到€等等。 -
将页面布局添加到你的 Index.razor 文件中。
菜单页面由一系列 Bootstrap 行组成,每个菜单项一个。每行有两列,
col-9显示菜单项描述,col-3显示价格。页面底部有最后一行,col-6居中显示鳄梨酱。
注意
项目结束了!在#types_and_references_getting_the_referen 的最后的项目符号处继续。