UNITY 3D – 游戏编程 – 第 9 部分





5.00/5 (12投票s)
本系列第九篇文章,讨论 Unity 3D 以及如何开始您的 3D 项目。
引言
在第九部分的文章中,我们将为游戏的整体外观和感觉引入更多增强功能。具体来说,我们将引入一个主菜单、音效和视觉效果!从而提升我们小游戏的吸引力!
如果您还没有阅读,请花点时间阅读
- Unity 3D – 游戏编程 – 第 1 部分
-
Unity 3D – 游戏编程 – 第 9 部分
Unity 3D 网络文章
Unity 3D Leap Motion 和 Oculus Rift 文章
在本系列的第一部分,我们从 Unity 3D 环境的基础知识开始。熟悉 IDE 以及您将在整个项目中使用的不同部分。我们还涵盖了如何在设计器中使用工具对选定的游戏对象应用不同的变换:定位、旋转和缩放。最后,我们探讨了如何创建第一个脚本,并使用脚本在我们的立方体绕 Y 轴旋转。
在本系列的第二部分中,我们更多地研究了通过编码对给定对象进行变换。我们还研究了如何创建对场景中对象的渲染至关重要的光源。
在本系列的第三部分中,我们研究了如何通过键盘处理用户输入,并根据键码采取特定操作。
在本系列的第四部分,我们研究了创建简单的用户界面。我们开发的界面为用户提供了反馈机制,也为用户提供了输入到我们的游戏或模拟中的另一种方法。
在第五部分,我们提出了一个简单游戏的构想。我们还研究了如何将 3D 模型导入游戏引擎。
在第六部分,我们扩展了我们的游戏构想,使其更有趣、更完整。我们引入了几个关于游戏玩法和游戏设计的概念,并为我们的游戏创建了一个简单的用户界面。
在第七部分,我们回答了第六部分提出的一些问题。
在第八部分,我们为玩家创造了更好的用户体验和视觉效果。
在本系列的第九部分,我们将继续增强我们小游戏的视觉效果。我们还将为游戏引入一个开始菜单,用户可以在其中选择一些选项,并确定游戏的难度。此外,还将添加音效和视觉效果,以增强游戏的整体外观和感觉。
Windows Phone 8.x 演示
我提供了一个免费的手机应用程序,您可以下载并在 Windows Phone 上预览演示。要下载移动应用程序,请按照链接:CodeProjectArticleSample
文章代码和视觉效果的实时预览
实时预览链接:http://www.noorcon.com/CodeProject/CodeProjectArticlePreview.html
背景
注意:对于本篇文章,我将使用 Expression Design 创建一些简单的菜单、按钮或其他视觉组件,它们将用于增强游戏的视觉吸引力!
注意:对于本篇文章,我将使用 SketchUp 创建一些简单的积木,并将其导入 Unity!我不是 3D 建模师或设计师,所以请耐心等待并原谅我的不足!
假设本文读者对编程概念有普遍的了解。还假定读者对 C# 语言有理解和经验。还建议文章读者熟悉面向对象编程和设计概念。我们将根据需要简要介绍它们,但不会深入细节,因为它们是完全独立的主题。我们还假设您热衷于学习 3D 编程,并具备 3D 图形和向量数学的基本理论概念。
最后,本文使用的是 Unity 3D 版本 4.6.1,这是初始发布日期时的最新公开版本。系列中讨论的大多数主题都将与旧版本的游戏引擎兼容,甚至可能与今年晚些时候将发布的新版本兼容。然而,当前 4.6.1 版本有一个主题与旧版本游戏引擎相比有显著差异,那就是 UI(用户界面)管道。这是因为引擎中新的 UI 架构比我们发布之前拥有的要优越得多。我个人对新的 UI 架构非常满意。
使用代码
下载项目/源代码以供文章系列使用:下载源代码。
随着后续文章的提交,项目/源代码也将随之扩展。新的项目文件和源文件将包含系列中较旧的部分。
注意:要获取最新代码,请转到本系列最新发布的部分并下载代码。
改进游戏
在第 8 部分,我们设计并开发了一个炮塔,它应该像玩家的障碍物一样。炮塔的设计是根据我们逻辑中定义的一些随机规范发射炮弹。我们还讨论了声音以及如何为我们的游戏添加声音。
注意:我们没有涵盖音频的所有内容。这是一个庞大而广泛的主题,因此,读者可以自行进一步研究。
金币模型
无论您的项目大小如何,其最重要的方面之一将是视觉部分。对于我们到第 8 部分为止的金币,我们使用了一个胶囊体图元并为其分配了一个我们在之前系列文章中定义的材质。
我决定用更吸引人的东西替换我们的金币模型。有很多免费的 3D 模型可供您在项目中使用。我决定获取一个免费的金币模型。
|
|
我不知道您怎么想,但我认为这看起来比我们以前的好多了!无论如何,模型已包含在您的下载源代码中。
原始模型位于models文件夹下,名为TyveKrone。对于我们的游戏,我对原始模型进行了一些修改,因此,我创建了一个新的 Prefab 并将其放置在 Assets 文件夹下的以下文件夹中:prefabs->part_9->Gold_Coin。
您将注意到该 Prefab 的几件事
-
比例增加了 10 倍!这是因为原始模型由于其原始单位和尺寸非常小。因此,为了使其在我们的游戏中可行,我必须将其放大 10 倍。
-
它在 X 轴上旋转了 90 度。
-
我为其附加了一个球体碰撞器,并将其Is Trigger设置为True。此外,碰撞器的Radius设置为0.01。添加此碰撞器是为了调用处理 On Trigger Enter 事件。
-
两个脚本也已附加到 Prefab:GoldCoinRotation.cs和Coin.cs。GoldCoinRotation.cs与rotateCoin.cs脚本之间的唯一区别是旋转轴。由于新模型的轴与我们原来的金币图元不同,我决定创建一个新脚本来仅为新模型应用旋转,而不破坏文章的旧部分。Coin.cs与我们以前的相同。
为了使用新模型,我们只需要做的是在playerInput.cs脚本中替换引用。所以我们的 Coin Prefab 引用应该指向我们的 GoldCoin Prefab。
为炮塔/炮弹添加音效
为了让我们的游戏更有趣,最好为它添加一些音效。尤其是在发射炮弹时!为我们的项目添加简单的音效非常简单。首先,我们需要决定为我们的游戏想要什么类型和种类的音效,然后我们需要自己创建这些音效,或者获取一些已经存在的。
我想要一个炮弹音效,因此,我做了一些在线搜索,找到一个我满意的。我将本系列文章使用的所有音频都放在了audio文件夹下。在 audio 文件夹中,您会注意到一个名为CannonBall的文件,这是我将用于炮弹射击效果的声音。
为了将此音频纳入我们的游戏,我们需要修改我们的CannonBall Prefab。您需要为 Prefab 添加一个新的Audio Source Component。一旦有了 Audio Source 组件,您所要做的就是将Audio Clip属性与 audio 文件夹下的实际音频剪辑(声音文件)关联起来,在本例中是 CannonBall。勾选Play On Awake属性,并确保Loop属性为 false。我们只希望音频在 Awake 时播放一次;即对象实际初始化时!
向用户界面添加更多指标
我们的用户界面设计中已经有了两个指标:分数和计时器。分数是收集到的所有金币的总和,而计时器则是玩家收集金币所剩的时间。我为用户界面引入了两个新指标:收集的金币数量和玩家的生命条。
收集的金币数量无需解释,因为它显示了玩家在游戏中收集了多少金币,生命条是为了衡量和显示玩家的生命值。这是因为我们在游戏中实现的炮塔和炮弹作为障碍物。因此,如果玩家被炮弹击中,他们将受到伤害,生命值也将降低。
我不会介绍如何实现收集的金币数量,因为它很简单,并且遵循与我们以前的两个指标分数和时间相同的逻辑和 UI 设计。
我将介绍如何实现玩家的生命条以及其 UI 设计,因为有几种方法可以做到这一点。同样,对于我们这样简单的游戏,我将使用最简单的方法来实现。
让我们来看看实现玩家生命条表示的一种方法。另外请注意,我还添加了一些图标,使其更具吸引力。
设计和实现生命条
我将向您展示一种实现生命条的最简单方法!它真的不能再简单了。我想要实现的生命条将是矩形的,并且将包含两个图形部分。第一个是框架,第二个是代表生命条的实际颜色。
图 4-生命条图像
图 4 展示了我的生命条的外观。技术上,它将由两个独立的图像组成,一个代表框架,另一个代表实际的生命条。当两者结合在一起时,您将获得图 4 中数字 2 所示的效果。
回到 Unity 3D 的 IDE,我们想在玩家上方显示生命条。为了实现这一点,我们需要将一个Canvas UI对象附加到我们的角色玩家上,并将Render Mode属性从Screen Space Overlay更改为World Space。这将根据生命条在 3D 世界中的位置进行渲染,并且从摄像机的角度来看,它不会被渲染为场景中的其他对象。接下来,您应该重置它,使其相对于玩家角色的中心定位。您应该将Rect Transform更改为以下值:(x,y,z) 轴上的值分别为(0,0,0)。默认情况下,Canvas的实际大小将比您的世界大得多,因此调整它的最佳方法不是减小Height和Width,因为那是实际分辨率,您不想减小它,您将使用Scale Vector进行相应缩放,所以在我的情况下,我在 (x,y,z) 轴上将 Canvas 缩放到了(0.03, 0.03, 1)。
上述步骤是生命条视觉实现的重点。我们还没有完成,我们需要在 Canvas 中添加两个Image UI对象,分别用于每种图像类型。继续添加两个Image UI对象,第一个将引用代表实际生命条的图像精灵,第二个将引用代表生命条框架的图像精灵。
您的结构应该如下所示
图 5-将生命条 Canvas 附加到玩家角色
您需要调整每个图像对象上的 Rect Transform 以将它们相应地定位在玩家上方。在我的例子中,以下是 Rect Transform 数字
-
两个图像的锚点都设置为中心。
-
X、Y 和 Z 位置分别设置为 0、20 和 0。
-
imgHealthBar 的宽度和高度设置为 10x1
-
imgHealthBarFrame 的宽度和高度设置为 10x2
正如您所看到的,除了框架的宽度和高度为 10x2 之外,所有属性都相同。框架需要比其包含的内容更大。顺序也很重要,imgHealthBar将在imgHealthBarFrame之前渲染,因此框架将始终位于生命条之上。另请注意,我们没有触及比例因子!这是因为 Canvas 会为我们完成工作!
还有一步重要的事情要做。对于imgHealthBar UI 对象,在Image (Script) Component下,将Image Type从Simple更改为Filled,还将Fill Method更改为Horizontal,将Fill Origin更改为Left,还要确保Fill Amount设置为1,即使我们通过脚本设置它。
最后一步是从我们的脚本中引用生命条并能够控制它。要做到这一点,我们需要引入几个新变量。我使用一个整数来存储玩家的生命值,以及另一个 Image 类型的变量来引用 UI Canvas 中的 imgHealthBar。
这是playerInput.cs的完整脚本列表
using UnityEngine; using UnityEngine.UI; using System.Collections; public class playerInput : MonoBehaviour { public Text lblScore; // text UI element for displaying score public Text lblTimer; // text UI element for displaying timer public Text lblNumOfCoins; // text UI element for displaying coins collected public Canvas endGameCanvas; // Canvas holding UI elements for End of Game public Text lblEndOfGameScore; public Text lblEndOfGameTime; public Text lblEndOfGameCoinCont; public GameObject coinPrefab; // variable used to store the coin prefab public GameObject hitEffect; private int score; // internal score variable public int health; // keep track of player health public Image healthBar; // visual health bar for player public int SCORE { get{ return this.score; } } private float levelTime; // variable holing time to complete level private float timeLeft; // variable for the actual timer count down public bool END_GAME; // variable indicating end of game public int numOfCoinsInLevel; // will be initialized at the Start of the game public int numOfCoinsCollected; // will be incremented each time we collect a coin // Use this for initialization void Start () { //GameObject _GM = GameObject.Find ("_GameMaster") as GameObject; Debug.Log (GameMaster.GameMode); if (this.healthBar != null) this.healthBar.fillAmount = 1.0f; this.health = 100; #region COIN CREATION // This checks to make sure we have the prefab, also so that it does not break previous code if(this.coinPrefab!=null){ // we need to create the coins dynamically per region // Coins for Region 1 for (int i=0; i<16; i++) { // Fill the Top part of Region 1 if(i<4){ GameObject coin = GameObject.Instantiate(this.coinPrefab, new Vector3(Random.Range(-4.5f, 4.5f), 0.25f, Random.Range(3.0f, 4.5f)), this.coinPrefab.transform.rotation) as GameObject; coin.name = "R1TopC"+i; } // Fill the Bottom part of Region 1 if(i>3 && i<8){ GameObject coin = GameObject.Instantiate(this.coinPrefab, new Vector3(Random.Range(-4.5f, 4.5f), 0.25f, Random.Range(-4.5f, -3.0f)), this.coinPrefab.transform.rotation) as GameObject; coin.name = "R1BottomC"+i; } // Fill the Left part of Region 1 if(i>7 && i<12){ GameObject coin = GameObject.Instantiate(this.coinPrefab, new Vector3(Random.Range(-4.5f, -3.0f), 0.25f, Random.Range(-4.5f, 4.5f)), this.coinPrefab.transform.rotation) as GameObject; coin.name = "R1LeftC"+i; } if(i>11 & i<16){ GameObject coin = GameObject.Instantiate(this.coinPrefab, new Vector3(Random.Range(3.0f, 4.5f), 0.25f, Random.Range(-4.5f, 4.5f)), this.coinPrefab.transform.rotation) as GameObject; coin.name = "R1RightC"+i; } } // Coins for Region 2 for (int i=0; i<4; i++) { GameObject coin = GameObject.Instantiate(this.coinPrefab, new Vector3(Random.Range(-2.0f, 2.0f), 0.25f, Random.Range(1.5f, 2.0f)), this.coinPrefab.transform.rotation) as GameObject; coin.name = "R2C"+i; } // Coins for Region 3 for (int i=0; i<4; i++) { GameObject coin = GameObject.Instantiate(this.coinPrefab, new Vector3(Random.Range(-2.0f, 2.0f), 0.25f, Random.Range(-0.5f, 0.5f)), this.coinPrefab.transform.rotation) as GameObject; coin.name = "R3C"+i; } // Coins for Region 4 for (int i=0; i<4; i++) { GameObject coin = GameObject.Instantiate(this.coinPrefab, new Vector3(Random.Range(-2.0f, 2.0f), 0.25f, Random.Range(-2.0f, -1.5f)), this.coinPrefab.transform.rotation) as GameObject; coin.name = "R4C"+i; } } #endregion this.score = 0; this.levelTime = Time.time + Random.Range (30.0f, 60.0f); this.numOfCoinsCollected = 0; this.END_GAME = false; if (this.endGameCanvas != null) { this.endGameCanvas.gameObject.SetActive (false); } // check to make sure labels are defined before updating if (this.lblScore != null) this.lblScore.text = this.score.ToString(); if (this.lblTimer != null) this.lblTimer.text = string.Format("{0:F2}", this.levelTime - Time.time); if (this.lblNumOfCoins != null) this.lblNumOfCoins.text = this.numOfCoinsCollected.ToString (); // get number of coins in the scene at the start of the game this.numOfCoinsInLevel = GameObject.FindGameObjectsWithTag ("coin").Length; } // Update is called once per frame void Update () { if (!this.END_GAME) { // compute time left this.timeLeft = this.levelTime - Time.time; // update UI label for timer if (this.lblTimer != null){ this.lblTimer.text = string.Format("{0:F2}", this.timeLeft); } // check to see if we need to end the game based on the timer if(this.timeLeft<=0.00f || this.numOfCoinsInLevel<=this.numOfCoinsCollected){ this.END_GAME = true; if (this.lblTimer != null && this.lblEndOfGameTime != null){ if(this.timeLeft>=0.00f){ this.lblTimer.text = string.Format("{0:F2}", this.timeLeft); this.lblEndOfGameTime.text = string.Format("{0:F2}", this.timeLeft); }else{ // this else block is written to ensure that if the timer is up, we always get 0.00 // and not positive or negative values, i.e. 0.01, or -0.01 and etc... this.lblTimer.text = string.Format("{0:F2}", 0.00f); this.lblEndOfGameTime.text = string.Format("{0:F2}", 0.00f); } } if(this.lblEndOfGameScore != null && this.lblEndOfGameCoinCont != null){ this.lblEndOfGameScore.text = this.SCORE.ToString(); this.lblEndOfGameCoinCont.text = this.numOfCoinsCollected.ToString(); } } // code for the movement of player (CP) forward if(Input.GetKey(KeyCode.UpArrow)){ this.transform.Translate(Vector3.forward * Time.deltaTime); } // code for the movement of player (CP) backward if(Input.GetKey(KeyCode.DownArrow)){ this.transform.Translate(Vector3.back * Time.deltaTime); } // code for the movement of player (CP) left if(Input.GetKey(KeyCode.LeftArrow)){ this.transform.Rotate(Vector3.up, -5); } // code for the movement of player (CP) right if(Input.GetKey(KeyCode.RightArrow)){ this.transform.Rotate(Vector3.up, 5); } }else{ if(this.endGameCanvas != null){ this.endGameCanvas.gameObject.SetActive(true); } } } // This event will be raised by object that have their Is Trigger attributed enabled. // In our case, the coin GameObject has Is Trigger set to true on its collider. void OnTriggerEnter(Collider c){ // used to detect if we collided with a coin GameObject if(c.tag.Equals("coin")){ Coin coin = c.GetComponent<Coin>(); // increase score this.score += coin.VALUE; this.numOfCoinsCollected += 1; // update score on the UI if (this.lblScore != null) this.lblScore.text = this.score.ToString(); if(this.lblNumOfCoins != null) this.lblNumOfCoins.text = this.numOfCoinsCollected.ToString(); // remove the Coin object from the scene Destroy(c.gameObject); } // used to detect if we collided with a cannon ball GameObject if (c.tag.Equals ("cannon_ball")) { string info = string.Format("{0}-{1}", c.name, "CANNON BALL!!!"); Debug.Log(info); this.health -= 10; // remove the cannon ball object from the scene after a hit Destroy(c.gameObject); if(this.hitEffect!=null) { GameObject fx = GameObject.Instantiate(GameMaster.GM.playerHitFx, this.transform.position, this.transform.rotation) as GameObject; if(this.healthBar != null){ this.healthBar.fillAmount = (this.health / 100.0f); } } } } public void butPlayAgain_Click(){ // get all object of type coin /*GameObject[] coins = GameObject.FindGameObjectsWithTag ("coin"); // remove eahc object from the scene foreach (GameObject coin in coins){ Destroy(coin); } Start ();*/ Application.LoadLevel (Application.loadedLevelName); } }
如果您一直关注本系列,您应该非常熟悉这段代码。唯一的主要变化是OnTriggerEnter(Collider c)函数。它处理与炮弹的碰撞。如果我们撞到炮弹,我们将减少玩家的生命值,并实例化一个代表爆炸效果的 Prefab。
创建游戏菜单
下一步是为游戏创建一个主菜单。所以我使用现有场景并将其另存为新场景。然后我删除了场景中不需要的游戏对象。即玩家角色。为了更好地组织场景,我创建了两个Empty GameObjects并将其命名为:CannonCollection和LightCollection。我将所有Cannon GameObjects移至CannonCollection下,并将所有PointLight GameObjects移至LightCollection下。由于它们在场景中是静态的,因此最好这样组织它们,并在Hierarchy Window中减少混乱。
图 6-主游戏菜单
我使用 Expression Design 为我的菜单和按钮设计了一些 UI 设计元素。我创建了几个单独的 Canvas UI 对象来处理 UI 元素。使用我的精灵将它们配置为我喜欢的方式,并为每个按钮关联适当的事件处理程序。
注意:如果您还没有学习过,请参考本系列的早期部分,了解如何实现和构建 UI 元素的详细说明。
场景中引入了一个名为_GameMaster的新 GameObject。这里的概念很重要,尽管实现非常简单。_GameMaster GameObject是一个Empty GameObject,附加了一个名为GameMaster.cs的脚本。
注意:_GameMaster GameObject 的要点是我想让您从中带走的概念。它以一种简单的方式完成,在更复杂的游戏中,实际的 _GameMaster 会复杂得多。
通常,Game Master 将控制和管理您游戏的所有相关部分。这包括玩家、敌人、音频、音效、视觉效果以及您游戏中的任何其他相关对象。基本上,它将是连接所有主要组件的主类!您的 Game Master 实现将取决于您的 OOD/OOP 技能。我不会在本篇文章中涵盖这些,因为这只是一个示例。
让我们看看GameMaster.cs脚本
using UnityEngine; using UnityEngine.UI; using System.Collections; public class GameMaster : MonoBehaviour { public static GameMaster GM; public static int GameMode; public GameObject playerHitFx; public GameObject cannonBallFireFx; public Image progressBar; public Canvas loadingPanelCanvas; // Use this for initialization void Start () { GameMaster.GM = this; DontDestroyOnLoad (this); if (this.progressBar != null) this.progressBar.fillAmount = 0.0f; } // Update is called once per frame void Update () { if (this.progressBar != null) { this.progressBar.fillAmount = Application.GetStreamProgressForLevel("part_9_game"); } if (Application.CanStreamedLevelBeLoaded ("part_9_game")) { if(this.loadingPanelCanvas!=null) this.loadingPanelCanvas.enabled = false; } } public void butEasy(){ GameMaster.GameMode = 0; this.LoadGame (); } public void butHard(){ GameMaster.GameMode = 1; this.LoadGame (); } private void LoadGame(){ if (Application.CanStreamedLevelBeLoaded ("part_9_game")) { Application.LoadLevel ("part_9_game"); } } }
这里发生了一些事情,让我们仔细看看。我想专注于代码中最重要的部分。第一个是静态变量。第一个是GameMaster类型的,名为GM,第二个是int类型的,名为GameMode。这两个变量是静态的,因为它们在游戏的整个生命周期中不会改变。
注意:代码很粗糙,可以改进!
下一个重要部分是Start()函数。在Start()函数中,我们将GM变量设置为关键字this,代表GameMaster对象的实例。然后我们使用名为DontDestroyOnLoad (this);的函数,它告诉游戏引擎在从内存加载或卸载场景时不要销毁引用的 GameObject。换句话说,_GameMaster GameObject一旦进入内存,除非游戏结束或我们在代码中手动销毁它,否则不会被销毁。
我们没有在第 8 部分介绍的是特定级别的流式加载。您可以在Update()函数中看到其实现。以下内置函数Application.CanStreamedLevelBeLoaded ("<scene_name>");返回一个介于0 和 1之间的值,该值代表特定场景加载的必要 GameObjects 的百分比 (%)。当您在线上和网络上部署游戏时,这非常有用。随着您的游戏变得越来越复杂,游戏的大小也会随之增加。也就是说,随着您的资源增加和场景变得越来越复杂,您的游戏文件的大小也会相应增加。不要惊讶于项目文件夹的大小在 2GB 到 3GB 之间!
在我们的例子中,我们没有太多复杂的模型和纹理,所以我们的模型和纹理大小对游戏来说非常小,但是,由于我们引入了一些音效和视觉效果,我们的项目大小也相应增加了。尤其是音频部分,因为它是占用空间的主要资源!
Update()函数中的实现将确保我们不会加载游戏关卡,直到所有资源都已下载到用户的计算机上。
关注点
本文引入了许多新概念。另外请注意,尽管我们已经放置了游戏模式的代码,但我们并没有真正使用它来调整我们的游戏玩法。这是我想让您进行一些工作,并就您自己的改进提供反馈。
代码结构不佳,可以进行更好的清理和组织。我想听听您是如何改进和/或实现您的版本的。
历史
这是我将逐步为 Code Project 社区贡献的系列文章的第九篇。
- Unity 3D – 游戏编程 – 第 1 部分
-
Unity 3D – 游戏编程 – 第 9 部分
Unity 3D 网络文章
Unity 3D Leap Motion 和 Oculus Rift 文章