4.5 游戏界面
在上一节中已经介绍过了主菜单界面的开发过程,本节将要介绍的游戏界面是本游戏开发的中心场景,其他的场景都是为此场景服务的,游戏场景的开发对于此游戏的可玩性有至关重要的作用。本节将对此界面的开发进行进一步的介绍。
4.5.1 基本场景搭建
游戏场景搭建主要是针对游戏地图、灯光、天空盒等环境因素的设置。通过本节的学习,读者将会了解如何构建出一个基本的游戏世界,接下来将具体介绍场景的搭建步骤。
(1)新建场景。选择“File”→“New Scene”,然后选择“File”→“Save Scene”选项(或者Ctrl+S),在保存对话框中输入场景名“GameScene”,如图4-24所示。
▲图4-24 新建场景
(2)创建光源。选择“GameObject”→“Create Other”→“Directional Light”选项后会自动创建一个定向光源,如图4-25所示。
▲图4-25 创建光源
(3)为灯光添加太阳效果。选择“Assets”→“Import Package”→“Light Flares”选项,导入灯光特殊效果资源包。选择刚才创建的“Directional Light”,然后将此灯光的Flare属性设置为50mm Zoom,当摄像机面向太阳的时候,会出现太阳的光晕效果,如图4-26所示。
▲图4-26 为灯光添加太阳效果
(4)创建地图。创建空物体取名“Map”,用以对所有地图资源进行管理,然后将地图模型Map.fbx拖进场景,并使其成为刚才创建的Map对象的子物体。
(5)将地图的俯视图添加进场景。创建一个空的GameObject,改名为“MiniMap”,调整Transform使其位于合适位置。单击“Add Component”→“Mesh”→“Mesh Renderer”按钮,然后将Mesh Renderer的属性设置如下,如图4-27所示。
▲图4-27 制作俯视图
(6)设置地图模型的边界。创建空的GameObject,改名为“MapCollider”,然后创建四个子物体,为其添加Box Collider,调整各自位置使其处于地图的四个边缘,以防止玩家走到地图的外面。图4-28所示为创建完成之后的效果。
▲图4-28 设置地图Collider
(7)添加天空盒。首先应该从Assets中导入Skyboxes资源包,然后单击“Edit”→“Render Settings”,打开RenderSettings属性窗口,设置Skybox Material属性为Sunny1 Skybox,如图4-29所示。至此,基本的场景搭建完毕。
▲图4-29 添加天空盒
4.5.2 炸弹的创建
上一小节已经介绍了场景的搭建过程,本小节将对游戏中炸弹的创建以及其相关脚本的编写进行详述,其具体的讲解内容如下。
(1)创建一个空物体,改名为“Bomb”。创建两个空物体,取名为“PointA”和“PointB”,调节他们两个位置,使其处于地图上的不同位置,分别代表炸弹的两个不同位置,本游戏的炸弹位置将随机创建在PointA或者PointB之中。
(2)将bomb.fbx模型拖动到场景之中,使其成为Bomb的子物体。创建脚本“BombCtrl.cs”,并拖动到Bomb下面的minebot_head对象上面。在Start函数中随机计算炸弹的位置。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/BombScripts/目录下的BombCtrl.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class BombCtrl : MonoBehaviour { 4 ........//此处省略了其他变量和方法的声明,将会在下面给出 5 public Transform bomb; //炸弹对象引用 6 public Transform bombA; //安装炸弹的A点 7 public Transform bombB; //安装炸弹的A点 8 void Start () { 9 if((int)(Random.value*1.999f) == 0) { //随机数等于零时 10 bomb.position = bombA.position; //将炸弹安装在A点 11 } 12 else { 13 bomb.position = bombB.position; //将炸弹安装在B点 14 }} 15 ......//此处省略了Update方法,将在下面介绍 16 ......//此处省略了OnExplode方法,将在下面介绍 17 }
说明
BombCtrl类声明了炸弹对象引用、炸弹地点的A地和B地的Transform,在Start函数中,每次产生一个随机数,以使炸弹的位置不固定。读者也可以多声明几个地点Transform,来让炸弹有更多的地点可选值。
(3)前面介绍了炸弹位置的初始化,接下来介绍Update方法的编写,此函数用于实现炸弹的闪烁效果。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/BombScripts/目录下的BombCtrl.cs。
1 void Update () { 2 if(isExplode) { //如果爆炸标志位为真 3 return; //返回避免下面的代码执行 4 } 5 timeLevel = 1- currentTime.GetTime()/currentTime.totalTime; //计算时间 6 float deltaBlink = 1/Mathf.Lerp (2, 15, timeLevel); //计算每次闪烁和播放音效的时间间隔 7 if (Time.time > lastBlinkTime + deltaBlink) { //当超过时间间隔的时候 8 lastBlinkTime = Time.time; //得到当前时间 9 Blink(); //调用方法 10 if (CameraAdaption.sound) { //如果可播放声音 11 audio.clip = bombWarningClip; //指定音效 12 audio.Play(); //播放警报音效 13 }}} 14. void OnWillRenderObject () { //系统回调方法 15 renderer.sharedMaterial.SetFloat( 16 "_SelfIllumStrength", blink);//设置炸弹材质着色器中的_SelfIllumStrength变量 17 } 18 void Blink () { 19 blink = 1.0f - blink; //计算闪烁 20 }
第2行~第4行用以检测炸弹是否爆炸,如果爆炸则返回。第5~第13行差值计算炸弹闪烁的时间间隔和音效速度,如果当前允许播放声音,还会播放“嘀嘀嘀”的警报音效。
第14行~第17行是Unity系统的一个回调函数,此函数每次都会在物体的Shader改变之后,进行回调重新渲染此物体。通过不停得对blink值进行计算,实现炸弹的闪烁效果。其中,Shader脚本使用的是Unity官方demo“Angry Bots”中的“EnemySelfIlluminationReflective.shader”。
(4)前面介绍了Update方法,接下来编写OnExplode方法,用以实现炸弹爆炸的效果。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/BombScripts/目录下的BombCtrl.cs。
1 void OnExplode () { 2 isExplode = true; //设置标志位 3 GameObject exploEffect = //实例化粒子系统 4 Instantiate(explodePrefab, Vector3.zero, Quaternion.identity) as GameObject; 5 exploEffect.transform.parent = this.transform; //设置effect的父节点 6 exploEffect.transform.localPosition = Vector3.zero; //设置在父节点的位置 7 explodeEffect = exploEffect.particleEmitter; //设置damageEffect 8 if (CameraAdaption.sound){ //如果可播放声音 9 audio.clip = explodeAudioClip; 10 audio.Play(); //播放警报音效 11 } 12 explodeEffect.Emit(); //发射爆炸粒子特效 13 bombMeshRenderer.enabled = false; //将炸弹隐藏 14 Destroy(gameObject,3.74f); //3.74秒后销毁炸弹 15 }
第3行~第7行实例化一个粒子爆炸特效对象,设定粒子特效所在的父对象,并设置位置使其处于炸弹上,然后第12行则调用Emit函数播放粒子特效,实现炸弹的爆炸效果。
第8地~第11行为如果当前允许播放声音,则将音频赋值为炸弹爆炸的音效,并且播放声音。第13行~第14行实现的效果是炸弹爆炸之后,禁用炸弹的Renderer渲染,以达到隐藏炸弹的效果,然后隔3.74秒之后销毁炸弹对象。
(5)为炸弹添加Trigger。通过一直按住发射子弹按钮来进行拆除炸弹,因此当玩家进入Trigger的时候,需要使玩家的枪不再具有开枪效果。选中Bomb,在属性窗口中勾选Is Trigger标志位,然后调节Size大小,当玩家进入这个范围的时候,将调用Trigger相应方法,如图4-30所示。
▲图4-30 为炸弹添加trigger
(6)在给炸弹添加指定大小的Trigger后,编写脚本“DefuseBomb.cs”,在OnTriggerStay和OnTriggerExit分别实现对玩家进入Trigger进行拆弹和离开Trigger之后的操作。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/BombScripts/目录下的DefuseBomb.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class DefuseBomb : MonoBehaviour { 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 void OnTriggerStay(Collider other) { //当玩家进入拆弹范围时 6 if (GameObject.FindWithTag("Enemy")){ //当敌人没有消灭干净时 7 return; //不允许拆弹 8 } 9 if (other.gameObject.tag == "Player") { //如果是玩家碰撞 10 crosshairScript.canShowCrosshair = false; //不允许显示十字瞄准线 11 weaponManager.selectedWeapon.defusingBomb = true;//拆弹标志位设为true 12 bombProgressBar.SetActive(true); //显示拆弹进度条 13 if(Application.platform == RuntimePlatform.Android ||//在手机平台上时 14 Application.platform == RuntimePlatform.IPhonePlayer) { 15 startDefuseBomb = fireBtn.defuseBomb; //按住开枪按钮为拆弹操作 16 } 17 else { //在PC上 18 startDefuseBomb = Input.GetMouseButton(0); //按住鼠标左键为拆弹操作 19 } 20 if(startDefuseBomb) { //按住鼠标左键 21 tempTime = Mathf.Lerp(tempTime, 1, speed * Time.deltaTime);//计算按住的时间 22 } 23 else { //松开鼠标时间又慢慢减少 24 tempTime = Mathf.Lerp(tempTime, 0, speed * Time.deltaTime); 25 } 26 if(tempTime>0.99f) { //当进度条大于0.99时 27 tempTime = 1; //将进度条的值设为1 28 gameWin.SendMessage("GameWin", //发送游戏胜利消息 29 SendMessageOptions.RequireReceiver); 30 } 31 foreground.fillAmount = tempTime; //改变进度条的值 32 }} 33 void OnTriggerExit(Collider other) { //如果玩家远离拆弹范围时 34 if (other.gameObject.tag == "Player") { //如果是玩家碰撞 35 crosshairScript.canShowCrosshair = true; //允许显示十字瞄准线 36 weaponManager.selectedWeapon.defusingBomb = false; //拆弹标志位为false 37 tempTime = 0; //将拆弹进度设置为0 38 bombProgressBar.SetActive(false); //隐藏拆弹进度条 39 }}
第9行~第32行实现当玩家进入Trigger,按住鼠标左键或者开枪按钮不放的时候,激活拆弹进度条。在此差值计算用户按住按钮的时间,实现按住时间越长进度条走动越慢的效果。将差值后的变量赋值给进度条的fillAmount属性,使进度条增长。
第33行~第39行当玩家离开Trigger的时候,将拆弹标志位设置为false,并且隐藏拆弹进度条,同时允许玩家在开枪的时候,单击鼠标左键能够显示十字瞄准线效果。
4.5.3 敌人的创建
搭建游戏界面场景的步骤比较繁琐,由于篇幅的限制这里不能很详细地介绍每一个细节,所以,要求读者对Unity的基础知识有一定的了解。接下来对游戏界面的开发步骤进行具体的介绍。
(1)为了方便对所有的敌人进行统一管理,首先新建一个空的GameObject,改名为“Enemys”。选中Enemys,再新建一个空的GameObject,改名为“Enemy”。之后选中“Enemy”,再创 建一个空的GameObject,改名为“Enemy”,并为其添加RigidBody和Audio Source组建。
(2)导入敌人模型。拖动“robot.fbx”到Enemy下面,可以看到在Enemy下面多了一个“robot”对象。之后选中robot,调节Transform Size为“x:100,y:80,z:100”,放大模型。读者可看到头部有立方形物体,将其选中,然后去除Mesh Renderer即可,如图4-31所示。
▲图4-31 导入敌人模型
(3)为敌人添加眼睛。选中“headhandle”,即敌人头部,然后在下面创建子物体,并更名为“eyehandle”,然后为eyehandle添加Mesh Renderer组建和Mesh Filter组建,设置属性如图4-32所示,读者可根据需要将其缩小然后移动到敌人的眼睛部分即可,之后复制一个添加到另一个眼睛。
▲图4-32 为敌人添加眼睛
(4)读者可能注意到敌人枪部分没有贴图。这个时候应该展开robot→mover→gun→ammo对象节点,选中ammo,将Shader属性改为“Mobile/Diffuse”,然后选择“Red 1”材质即可,如图4-33所示。
▲图4-33 添加刚体的参数
(5)为了使敌人的眼睛实现闪烁的效果,编写脚本“EyeBlink.cs”。此脚本通过间歇性地控制Shader脚本中的“_Color”属性来动态地实现敌人眼睛闪烁的效果。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/EnemyScripts目录下的EyeBlink.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class EyeBlink: MonoBehaviour { 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 void Update () { 6 if (Time.time - lastBlinkTime >= 1) { //时间间隔大于1秒 7 lastBlinkTime = Time.time; //记录上次闪烁时间 8 blink = 1- blink; //改变blink值 9 gameObject.renderer.material.SetColor("_Color", 10 new Color(1,0,0,blink)); //设置_Color属性值 11 }}
说明
在Update函数中,每隔1秒便改变一次Blink值,以使Shader脚本中的“_Color”变量动态地改变,达到闪烁的效果。
(6)给枪口添加粒子特效。单击robot→mover→gun→gun_model,然后新建一个空物体“gun_muzzle_Object”,在这个空物体下面再新建一个物体“gun_muzzle_partcal”,调节gun_muzzle_partcal的位置,使其位于枪口的位置。然后添加粒子,设置属性如图4-34所示。
▲图4-34 枪口粒子特效的设置
(7)调整gun_muzzle_Object对象的位置,使其位于枪的中间位置,敌人的子弹将从这里实例化。为gun_muzzle_Object对象添加“Audio Source”,然后编写脚本“BlinkAndFire.cs”,在Update方法中控制枪口颜色的变化,以及对子弹的管理。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/EnemyScripts目录下的BlinkAndFire.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class BlinkAndFire : MonoBehaviour{ 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 void Update () { 6 pSystem.startColor=Color.Lerp(Color.red,Color.red/2, 7 Mathf.PingPong(Time.time, 1)); //不断改变枪口粒子特效的颜色 8 if (firing){ 9 if (Time.time > lastFireTime + 1.52f){ //超过指定时间间隔 10 lastFireTime = Time.time; //记录上一次的发射时间 11 if (CameraAdaption.sound && fireSound){ //如果可以播放声音 12 audio.clip = fireSound; //指定所需播放的声音 13 audio.Play(); //播放警报音效 14 } 15 Instantiate(bulletPrefab,transform.position,transform.rotation);//实例化子弹 16 }}}}
说明
在Update函数中,通过对粒子系统的startColor变量进行不断的差值来改变枪口粒子特效的颜色。当开枪的时候,如果当前允许播放声音,那么播放开枪的声音,然后在当前位置实例化一个子弹出来。
(8)创建子弹prefab。将Missile.fbx拖动到场景中,改变Mesh Renderer中的Materials为“Missile.mat”,为其添加一个“Audio Source”,按照图4-35所示设置属性即可。然后将子弹拖动到Assets/Models/Prefabs文件夹下,使其成为一个prefab。
▲图4-35 敌人子弹的设置
(9)编写脚本“SeekerBullet.cs”并且挂载到Missile对象上,此脚本主要控制子弹的位置,以及与玩家碰到之后所作出的反应。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/BombScripts目录下的SeekerBullet.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class SeekerBullet : MonoBehaviour { 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 void Update () { 6 ......//此方法省略了部分代码语句,查看详细代码请读者自行查询光盘 7 if(targetObject) { //如果获取到了Player对象 8 Vector3 targetPos = targetObject.transform.position;//获取Player对象的位置 9 targetPos = targetPos + 10 transform.right * (Mathf.PingPong(Time.time, 1.0f) -0.5f) * noise;//使位置产生偏移 11 Vector3 targetDir = targetPos - tr.position; //计算两者的连线 12 float targetDist = targetDir.magnitude; //将向量归一化 13 targetDir = targetDir/targetDist; 14 if (Time.time - spawnTime < lifeTime * 0.2 15 && targetDist > 3) {//如果时间未超过生命的1/5,并且敌人与Player的距离还大于3 16 targetDir = targetDir + transform.right * 0.5f * sideBias;//再次产生偏移量 17 } 18 dir = Vector3.Slerp(dir, targetDir, Time.deltaTime * seekPrecision);//计算导弹的朝向 19 tr.rotation = Quaternion.LookRotation(dir); //计算子弹的朝向 20 tr.position = tr.position + (dir * speed) * Time.deltaTime;//计算到子弹位置 21 } 22 Collider[] hits = Physics.OverlapSphere(tr.position, 23 radius, ~ignoreLayers.value); //检测是否碰到东西了 24 foreach (Collider hit in hits) { 25 Health targetHealth = hit.GetComponent("Health") as Health; //获取碰撞物体的Health脚本 26 if (targetHealth) { //如果脚本存在 27 int damage = (int)Random.Range(damageAmount/2,damageAmount);//计算伤害大小 28 targetHealth.OnDamage(damage, -tr.forward, tr.position, 0.5f);//受到伤害 29 } 30 collided = true; //子弹与Player相撞标志位置为true 31 } 32 if(collided) { 33 Instantiate(explosionPrefab, tr.position, tr.rotation);//实例化爆炸特效 34 Destroy(gameObject, 2); //两秒后销毁 35 gameObject.renderer.enabled = false; //禁止渲染 36 }}}
第8行~第17行用以计算敌人子弹的朝向和实时位置,为了模拟更逼真的开枪射击效果,对子弹的射击点进行了一个位置扰动,即有时候打不中有时候能打中玩家,使敌人拥有一个正常人的行为思想。
第24行~第31行对子弹碰到的物体进行遍历,如果碰到玩家的话,那么调用挂载到玩家物体上的“Health.cs”脚本中的OnDamage方法,使玩家产生受伤效果。第32~第35行则实例化子弹爆炸模型,2秒之后销毁子弹对象。
(10)在Enemy下面,添加一个空物体AIAttack,然后编写脚本“SpiderAttackMoveController.cs”挂载到此对象上面,用以控制当玩家靠近敌人的时候,敌人移动的方向。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/EnemyScripts目录下的SpiderAttackMoveController.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class SpiderAttackMoveController : MonoBehaviour { 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 void FixedUpdate () { 6 enemyMove.movementDirection = player.position - transform.position;//计算敌人的移动方向 7 }}
说明
FixedUpdate函数的作用是不断地计算敌人的移动方向,永远朝着Player移动。
(11)同样,在Enemy下面,添加一个空物体AIReturn,然后编写脚本“SpiderReturnMoveController.cs”挂载到此对象上面,用以控制玩家远离敌人后,敌人自动回到原位置。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/EnemyScripts目录下的SpiderReturnMoveController.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class SpiderReturnMoveController: MonoBehaviour { 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 void Update () { 6 float distance = (transform.position - targetPosition).sqrMagnitude;//计算距离的平方 7 if (distance > 16) { //如果没有回到起点 8 enemyMove.movementDirection = targetPosition - transform.position; //计算敌人移动方向 9 } 10 else{ 11 enemyMove.movementDirection = Vector3.zero; //不允许移动 12 spiderAnimation.enemyIdle(); //设置敌人静止不动的动画 13 transform.parent.rotation = rotation; //设置姿势为起始姿势 14 enabled = false; 15 }}}
第6行~第9行计算出敌人当前位置和初始距离的平方,targetPosition保存了敌人初始的位置,如果敌人没有回到起点,且敌人当前的位置与初始位置的距离大于4m,那么就一直计算敌人的移动方向。
第11行~第14行是当敌人处于初始点附近时,调整敌人姿态,并播放敌人闲时的动画。游戏不要求敌人必须回到原点,因此给的是一个距离初始点4m为半径的圆的范围。
(12)继续在Enemy下面创建新的一个Cube,改名为“AIShootSize”,然后去除掉Mesh Renderer前面的对勾,勾选Is Trigger选项,使其充当一个Trigger的作用,适当调节Box Collider的范围,如图4-36所示。
▲图4-36 设置射击范围
(13)编写脚本“ShootAI.cs”,并挂载到“AIShootSize”对象上,用以控制当玩家靠近或者远离敌人一定范围的时候,敌人执行相应的动作,即播放动画。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/EnemyScripts/目录下的ShootAI.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class ShootAI: MonoBehaviour { 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 void OnTriggerEnter(Collider other) { //与该碰撞器相撞时的回调方法 6 if(other.tag == "Player"){ //进入碰撞器范围的是玩家 7 spa.enemyShoot(); //敌人开始射击 8 boxCollider.size = boxSize * 1.5f; //放大射击范围 9 aiMoveOjb.SendMessage("OnShoot"); //发送正在射击消息 10 }} 11 void OnTriggerExit(Collider other) { //玩家离开该范围时的回调方法 12 if (other.tag == "Player"){ 13 spa.enemyRun(); //敌人跑步 14 boxCollider.size = boxSize; //缩小范围 15 aiMoveOjb.SendMessage("OnSpotted1"); //使AIReturn的脚本可使用 16 }}}
第5行~第10行,当Player走到Trigger范围内的时候,敌人则开始向玩家射击,故意放大了Trigger的范围,用以实现一种“易进难出”的效果,同时发送敌人当前正在开枪射击Player的消息。
第11行~第16行,当Player出去Trigger范围的时候,敌人开始执行跑步动作,即追踪Player,使敌人看起来更加智能,此时缩小Trigger的范围,同时发送消息,当条件满足的时候要返回原点不再追踪Player。
(14)选中Enemys对象下面的Enemy,然后新建一个Cube,改名为“AIMoveSize”,去除Mesh Renderer组建使其不渲染,同样勾选“Is Trigger”属性,使其成为一个Trigger,并调节Size大小。
提示
AIMoveSize上面的Trigger范围控制的是敌人移动的范围,而AIShootSize上面的Trigger范围控制的是敌人攻击的范围。
(15)编写脚本“EnemyMove.cs”,此脚本是对AIReturn和AIAttack的一个管理类,主要控制敌人在不同情况下对玩家应该做出什么样的反应。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/EnemyScripts/目录下的EnemyMove.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class EnemyMove: MonoBehaviour { 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 void FixedUpdate(){ 6 facePlayer(); //让敌人面对Player 7 if (!shoot){ //如果没有开枪 8 Vector3 targetVelocity = movementDirection * walkingSpeed;//计算力的大小 9 Vector3 dir = targetVelocity - character.rigidbody.velocity;//计算力的方向 10 character.rigidbody.AddForce(dir * walkingSnappyness, ForceMode.Impulse); //给力 11 if (Time.time > lastTime + 0.5f && 12 movementDirection.sqrMagnitude > 1) { //若时间大于指定间隔 13 if (CameraAdaption.sound) { //如果可播放声音 14 audio.clip = walkSounds; //设置声音播放片段 15 audio.Play(); //播放声音 16 } 17 lastTime = Time.time; //重新计算时间 18 }}} 19 void facePlayer(){ 20 if(shoot){ //如果正在开枪 21 movementDirection = player.position - character.position;//计算移动方向 22 } 23 dir = Vector3.Slerp(dir, movementDirection, Time.deltaTime); //计算朝向 24 dir.y = 0; //设置y分量为0 25 character.rotation = Quaternion.LookRotation(dir); //面向目标点 26 }}
第5行~第18行,在FixedUpdate函数中,首先让敌人面朝Player,当没有开枪的时候,根据走路的速度计算力的大小和方向,然后将此力施加在敌人身上使其移动,如果当前可播放声音,则播放走路声。
第19行~第26行,在facePlayer函数中,调整敌人的朝向dir、面向的目标点以及其他姿态,然后让敌人面向此dir方向。
(16)敌人血量的控制。编写脚本“Health.cs”,并挂载到Enemy对象上,此脚本用于使敌人在受到玩家开枪射击时侯血量减少,并且呈现出一种喷血效果。敌人受到伤害血量会减少,当敌人血量小于0时,敌人爆炸。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/EnemyScripts/目录下的Health.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class Health: MonoBehaviour { 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 public void OnDamage(int amount, Vector3 fromDirection,Vector3 damagePosition,float yOffer) { 6 if (aiMoveObj){ //如果此脚本不为空 7 aiMoveObj.SendMessage("OnAttack"); //发动攻击 8 } 9 if (dead){ //如果死了 10 return; 11 } 12 health -= amount; //血量减少 13 if(transform.tag == "Player") { //如果是玩家 14 bloodBar.CalLastBlood(amount); //计算剩余血量 15 } 16 if(damageEffect) { //如果粒子特效存在 17 damageEffect.transform.rotation = Quaternion.LookRotation(fromDirection,Vector3.up); 18 damagePosition.y += yOffer; 19 damageEffect.transform.position = damagePosition - fromDirection.normalized/2; 20 damageEffect.Emit(); //喷射粒子 21 } 22 if(health <= 0) { //如果血量少于0 23 dead = true; //死亡标志位为false 24 if(transform.tag == "Player"){ 25 gameObject.collider.enabled = false; 26 } 27 if (transform.tag == "Enemy"){ 28 if (explorEffect) { //如果爆炸特效存在 29 explorEffect.transform.position=transform.position;//设置爆炸特效的位置 30 explorEffect.Emit(); //播放粒子特效 31 } 32 robot.SetActive(false); //隐藏敌人 33 Destroy(gameObject.transform.parent.gameObject,1f);//1秒后摧毁 34 } 35 if(deadClip && CameraAdaption.sound) { //如果可播放声音 36 audio.clip = deadClip; 37 audio.Play(); //播放声音 38 }}}}
第12行~第21行用于受到伤害的时候让血量减少,如果粒子特效存在,那么首先设置粒子特效的旋转角度和位置,接着调用Emit方法播放粒子特效,实现一种敌人或者Player受到伤害有血从身上喷出的效果。
第22行~第37行用于当血量减少到0的时候,将死亡标志位置为true。如果爆炸特效存在,那么首先设置特效的位置,接着播放爆炸特效并销毁模型资源。如果可播放声音,还会播放敌人或者Player在死亡时的声音。
提示
“Health.cs”脚本不光用来控制敌人血量的变化,也用来控制玩家血量的变化,通过检测脚本上面是否有“aiMoveObj”来区分当前是敌人还是玩家。
(17)编写敌人动画播放管理脚本。编写“SpiderAnimation.cs”并将其挂载到robot对象上,敌人模型中有三种动画:跑步、射击和敌人非忙碌时的动画。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/EnemyScripts/目录下的SpiderAnimation.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class SpiderAnimation: MonoBehaviour { 4 ......//此处省略了其他函数和变量等的声明,请读者自行查阅光盘 5 void OnEnable () { //设置各个动作为可用状态 6 animation[idle.name].enabled = true; 7 animation[idle.name].layer = 1; 8 animation[idle.name].speed = 2; 9 animation[run.name].enabled = true; 10 animation[run.name].layer = 1; 11 animation[shoot.name].enabled = true; 12 animation[shoot.name].layer = 1; 13 animation[shoot.name].speed = 0.5f; 14 enemyIdle(); //开始的时候为站立动作 15 } 16 public void enemyIdle(){ //敌人闲时的动画 17 gun_muzzle_Object.SetActive(false); //隐藏枪 18 animation.CrossFade(idle.name, 1f); //切换到idle动画 19 animation[idle.name].speed = 2; //设置动画的播放速度 20 }}
第5行~第15行,在OnEnable函数中,设置各个动画包括空闲、跑步、开枪射击的初始化状态信息,如是否可操作,所在层和播放速度等,接着调用enemyIdle函数。
第16行~第20行,在enemyIdle函数中,将敌人当前所拿的枪隐藏掉,然后播放idle动画。
(18)为敌人添加闪烁标志物。通过为敌人添加闪烁标志物,方便俯视摄像机对敌人的渲染,这样在小地图上将会把敌人的位置以点的形式呈现出来。在Enemy对象下面新建一个空物体“Target”,“Add Component”→“Effects”→“Legacy Particles”→“Ellipsoid Particle Emitter”,调节属性如图4-37所示。
▲图4-37 设置Ellipsoid Particle Emitter属性
(19)为Target对象添加“Particle Animator”,单击“Add Component”→“Effects”→“Legacy Particles”→“Particle Animator”,调节Size Grow属性为2,如图4-38所示。
▲图4-38 设置Particle Animator属性
(20)为Target对象添加“Particle Renderer”,“Add Component”→“Effects”→“Legacy Particles”→“Particle Renderer”,调节Size Grow属性为2,如图4-39所示。
▲图4-39 设置Particle Renderer属性
提示
Legacy Particle是Unity自带的一个用于实现特殊效果的粒子系统,读者可以通过它实现一些比较困难的特殊效果,使游戏的场景显示得更加逼真炫丽。
(21)设置Target所在的层,选中Target,在Inspector窗口中,添加一个新的Layer,取名为“Radar”,表示其只会被Culling Mask勾选上Radar层的摄像机渲染。最终效果如图4-40所示。
▲图4-40 敌人最终效果
提示
游戏中不止存在一个摄像机,为了节省资源,通过设置每个摄像机不同的Culling Mask指定其所需渲染的层,从而只使其指定层的物体呈现在游戏视野中,其他物体如敌人、枪支、武器等包括NGUI都设有自己所在的层(默认为Default),读者应多注意。
4.5.4 操作界面搭建
操作界面的搭建和主菜单界面的搭建一样,都是采用NGUI界面搭建的,主要负责显示一些对Player进行操作的按钮,如移动、蹲下、跳跃、瞄准、开枪、装弹和换枪,以及游戏剩余时间和剩余子弹的显示等。最终效果如图4-41所示。其主要的技术已经在本章第4节主菜单界面详细介绍过,此处不在详述。
▲图4-41 操作界面
4.5.5 Player的创建
上一小节介绍了敌人的制作过程,本小节将向读者介绍Player的创建,主要内容包括Player移动、切换视角、以及俯视图的制作。具体的开发步骤如下。
(1)单击“GameObject”→“Create Empty”按钮,选中并将其重命名为“Player”,为了方便管理,和Player人物相关的模型、操控等对象都放在这个父对象下面。
(2)创建Player的身体部分。选中“Player”,创建一个新的空物体,重命名为“Body”,然后在Body的属性窗口中,单击“Add Component”→“Mesh”→“Mesh Filter”,然后将Mesh属性改为“Capsule”,继续添加一个“Mesh Renderer”,将Materials改为“Default-Diffuse Material”,如图4-42所示。
▲图4-42 Player身体部分属性设置
(3)创建Player的头像部分。创建一个空物体“HeadLook”,使其成为“Player”的子物体。单击“Component”→“Create Other”→“Camera”,并将其更名为“Main Camera”,拖动使其成为“HeadLook”的子物体。此Camera是游戏的主摄像机,相当于Player的眼睛。属性设置如图4-43所示。
▲图4-43 摄像机属性设置
(4)创建武器界面摄像机。在“Main Camera”下面继续创建一个“Camera”,重命名为“Weapon Camera”。此Camera只渲染玩家所持的枪模型,删除默认携带的组件“Audio Listener”、“GUI Layer”、“Flare Layer”,并将“Clear Flags”改为“Depth Only”。属性设置如图4-44所示。
▲图4-44 武器摄像机设置
(5)屏幕自适应。游戏在移动手机平台上默认的屏幕分辨率为800×400,为了使游戏在不同大小的屏幕上显示正常,在此需要解决游戏屏幕自适应的问题。编写脚本“CameraAdaption.cs”,挂载到“Main Camera”上,脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/GameUIScripts目录下的CameraAdaption.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class CameraAdaption : MonoBehaviour{ 4 ......//此脚本省略了部分变量的声明,请读者自行查询光盘 5 public static float desiginWidth = 800.0f; //标准屏幕的宽 6 public static float desiginHeight = 480.0f; //标准屏幕的高 7 void Awake(){ 8 ......//此方法省略了部分代码,请读者自行查阅光盘 9 camera.aspect = 800.0f/480.0f; //设置视口的缩放比 10 float lux = (Screen.width – //计算视口偏移量 11 CameraAdaption.desiginWidth * Screen.height/CameraAdaption.desiginHeight)/2.0f; 12 camera.pixelRect = new Rect(lux, 0, Screen.width -2 * lux, Screen.height);//设置显示区域 13 }}
说明
默认游戏运行手机屏幕大小为800×400,当运行在其他屏幕大小的手机上时,首先计算出视口的缩放比,然后再计算出视口的偏移量,再通过设置相机的pixelRect,这样才能在不同分辨率的手机上显示出正确的游戏区域。
(6)创建武器管理对象。创建一个空物体,重命名为“Weapon Manager”,使其成为“Weapon Camera”的子对象,并添加Audio Source组建,此对象用于对Player所持枪械的管理。继续在“Weapon Manager”下面创建空物体“Deagle”,将Assets下面的“Hands+Deagle.fgx”模型拖动到“Deagle”下面。其他几种枪械的创建也一样。图4-45所示为Player部分对象的树形图。
▲图4-45 Player对象树形图
(7)编写脚本控制角色视角。新建脚本“PlayerLook.cs”,此类主要用来控制角色的视角方向,即使角色能够上、下、左、右观看游戏场景。将其挂载到Player和HeadLook上面,脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的PlayerLook.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class PlayerLook : MonoBehaviour { 4 ......//此类省略了其他函数和变量等的声明,请读者自行查阅光盘 5 public RotationAxes axes = RotationAxes.MouseX; //鼠标滑动方向变量 6 void Update () { 7 //此方法省略了其他代码片段,请读者自行查阅光盘 8 if(!(Application.platform == RuntimePlatform.Android //当在PC上运行时 9 || Application.platform == RuntimePlatform.IPhonePlayer)) { 10 inputX = Input.GetAxis("Mouse X"); //水平距离由鼠标获得 11 inputY = Input.GetAxis("Mouse Y"); //垂直距离由鼠标获得 12 } 13 else { 14 if(enableTouch) { //当启用触控时 15 for(int i=0;i<Input.touchCount;i++) { //遍历触摸点 16 Touch touch = Input.GetTouch(i); //得到第i个触摸点 17 if(touch.fingerId == touchIndex) //若同一个触摸点 18 continue; //跳过 19 else { 20 inputX = Mathf.Clamp(touch.deltaPosition.x, -1, 1); //水平距离由手指获得 21 inputY = Mathf.Clamp(touch.deltaPosition.y, -1, 1);//垂直距离由手指获得 22 }}}} 23 if(axes == RotationAxes.MouseX) { //鼠标水平滑动时 24 transform.Rotate(0, inputX * sensitivityY, 0); //根据鼠标的滑动转动角色 25 } 26 else if(axes == RotationAxes.MouseY) { //鼠标竖直滑动时 27 rotationY += inputY * sensitivityY; //得到绕Y轴转动的角度 28 rotationY = Mathf.Clamp (rotationY, minY, maxY);//限制角度范围 29 transform.localEulerAngles = 30 new Vector3(-rotationY, transform.localEulerAngles.y, 0);//为角色设定姿态 31 }}}}
第8行~第22行用于确定当前游戏是运行在手机平台上还是PC上,如果在PC上,那么inputX和inputY由鼠标确定。如果在手机上运行,那么这两个则分别由手指在屏幕上移动的水平距离和垂直距离确定。
第23行~第30行根据用户的不同移动范围,动态改变摄像机的视野范围。当水平滑动时,转动摄像机以左右查看场景。当垂直滑动时,使摄像机绕x轴旋转以上下查看场景。在Player上设置“Axes”为MouseX,在“HeadLook”上,设置“Axes”为MouseY。
(8)为了模拟真实的Player走路效果,需要编写脚本让角色在行走过程中有轻微的抖动效果。新建脚本“WalkSway.cs”,挂载到“Main Camera”和“Weapon Manager”对象上,此脚本通过控制摄像机的localPosition来达到动态模拟Player走路晃动的效果。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的WalkSway.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class WalkSway : MonoBehaviour { 4 ......//此脚本省略了其他变量和函数的声明,请读者自行查阅光盘 5 void FixedUpdate () { 6 //此处省略了此方法部分代码片段,请读者自行查阅光盘 7 transform.localPosition = 8 Vector3.Lerp(transform.localPosition,currentPosition,i);//使用线性插值平滑改变位置 9 }}
说明
在FixedUpdate函数中,使用线性插值来平滑地改变Player的localPosition属性,使其达到一种来回晃动的效果。
(9)为了使Player在行走的过程中,产生断断续续不停走路的声音,为此笔者收集了五种稍微有点不同的脚步声音,通过编写脚本随机播放这几个声音,可以模拟一种非常真实的走路声音效果。新建脚本“PlayerStepSound.cs”,并挂载到Player上,脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的PlayerStepSound.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class PlayerStepSound : MonoBehaviour { 4 public AudioClip[] walkSounds; //脚步声音数组 5 public float walkStepLength = 0.4f; //走动时声音的播放时间 6 public float runStepLength = 0.32f; //跑动时声音的播放时间 7 public float crouchStepLength = 0.5f; //蹲下时声音的播放时间 8 private CharacterController controller; //角色控制器 9 private PlayerController motor; //玩家控制器脚本 10 private float lastStep = -10.0f; //声音持续时间 11 private float stepLength; //当前脚步声音持续时间 12 ......//此处省略了Awake方法的介绍,请读者自行查阅光盘 13 void FixedUpdate () { 14 if(motor.walking && motor.grounded && !motor.crouch) {//当角色走动且没有蹲下 15 PlayStepSound(); //调用播放声音方法 16 stepLength = walkStepLength; //设置声音播放时间 17 } 18 if(motor.running && motor.grounded) { //当角色正在跑步 19 PlayStepSound(); //调用播放声音方法 20 stepLength = runStepLength; //设置声音播放时间 21 } 22 if(motor.walking && motor.crouch && motor.grounded) { //当蹲下时候 23 PlayStepSound(); //调用播放声音方法 24 stepLength = crouchStepLength; //设置声音播放时间 25 }} 26 void PlayStepSound() { 27 if(Time.time > stepLength + lastStep) { //当游戏时间大于间隔时间 28 if(CameraAdaption.sound) { //如果可播放声音 29 audio.clip = walkSounds[Random.Range(0, walkSounds.Length)];//随机选择声音 30 audio.Play(); //播放声音 31 } 32 lastStep = Time.time; //记录上次播放时间 33 }}}
第13行~第25行,FixedUpdate函数主要负责在不同的情况下,设置播放声音的长度不同。一般来说,人物在走路、跑步和蹲下的时候,其所产生的脚步的声音时长是不同的。
第26行~第33行,PlayStepSound函数用于播放声音,当时间间隔大于stepLength,即当前没有声音正在播放时,如果系统允许播放声音,则从给定的walkSounds数组中,随机选择一种声音进行播放,并记录上次的播放时间。
(10)为Player添加第一人称控制器。单击“Player”→“Component”→“Physics”→“Character Controller”。图4-46所示为Player Inspector窗口显示的Character Controller组件。然后单击“Assets”→“Import Package”→“Character Controller”,导入第一人称控制器资源包。
▲图4-46 为Player添加Character Controller
(11)在导入资源包后,需要用到此资源包下面的一个脚本“CharacterMotor.js”。在Project窗口中依次展开路径“Assets/Standard Assets/Character Controllers/Sources/Scripts/”,读者可看到CharacterMotor.js脚本,新建脚本“PlayerController.cs”,挂载到“Player”上。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的PlayerController.cs。
1 using UnityEngine; 2 using System.Collections; 3 [RequireComponent(typeof(CharacterController))] //需有角色控制器组件 4 public class PlayerController : MonoBehaviour { 5 ……//此处省略了部分成员变量的声明,读者可自行查看随书光盘中的源代码 6 public bool running; //处于跑动状态标志位 7 public bool walking; //处于走动状态标志位 8 public bool canRun; //是否能够跑动标志位 9 private GameObject mainCamera = null; //主摄像机对象 10 public bool onLadder = false; //是否处于梯子上 11 private float ladderHopSpeed = 6.0f; //爬梯速度 12 public bool inputRun = false; 13 public bool inputCrouch = false; //是否下蹲标志位 14 public bool crouch; //下蹲标志位 15 private float standardHeight; //标准高度 16 private GameObject lookObj; //看向的物体,保证视角的正确 17 private float centerY; //竖直方向中心点 18 private bool canStand; //能否站起标志位 19 private bool canStandCrouch = true; //能否站起下蹲标志位 20 ......//此处省略了原脚本自带的一些方法,读者可自行查看随书光盘中的源代码 21 ......//此处省略了Awake方法,将在下面进行介绍 22 ......//此处省略了Update方法,将在下面进行介绍 23 ......//此处省略了Crouch方法,将在下面进行介绍 24 ......//此处省略了OnRunninhg方法,将在下面进行介绍 25 ......//此处省略了OffRunning方法,将在下面进行介绍 26 }
CharacterMotor.js是Unity自带的一个角色引擎脚本,其用于实现Player更逼真的移动、滑动、跳跃等效果。游戏所需的“PlayerController.cs”脚本将根据“CharacterMotor.js”进行改编,其中代码片段中的“#region custom code”和“#endregion”之间的代码是自己添加的,用于实现额外功能。
变量上方的“[HideInInspector]”是为了在Unity的Inspector窗口中隐藏public变量,使其不在Unity的属性窗口中显示。类最上方的“[RequireComponent(typeof(CharacterController))]”表示此脚本挂载到的Object对象上要有CharacterController组件才能正常运行,否则此脚本不运行。
(12)上面简要介绍了PlayerController.cs脚本中自己添加的一些变量和修改的方法,接下来将会一一对修改过的函数进行详述,新添加的代码最主要的功能是为Player添加了下蹲和跑动动作。首先介绍Awake函数,其主要进行了一些变量的初始化赋值。函数代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的PlayerController.cs。
1 void Awake () { 2 controller = GetComponent<CharacterController>(); //得到角色控制器组件 3 tr = transform; //获得Transform组件 4 #region custom code //自己添加的代码开始 5 standardHeight = controller.height; //得到角色标准身高 6 lookObj = GameObject.FindWithTag("LookObject"); //得到游戏对象 7 centerY = controller.center.y; //得到角色竖直中心点Y 8 mainCamera = GameObject.FindWithTag("MainCamera"); //拿到主摄像机对象 9 canRun = true; //可以进行跑动 10 canStand = true; //可以进行站立 11 #endregion //自己添加的代码结束 12 }
说明
Awake函数主要负责对一些游戏变量例如角色控制器、Transform组件,以及其他的与Player相关的属性信息等进行初始化操作。
(13)上一步骤中介绍了Awake方法,将下来将介绍Update方法。此方法中主要根据角色当前所处的姿态控制一些动作标志位,如可否走动、可否下蹲等。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的PlayerController.cs。
1 void Update () { 2 ......//此处省略了原脚本自带的一些方法代码,读者可自行查看随书光盘中的源代码 3 #region custom code //自己添加的代码开始 4 if(Input.GetAxis("Vertical") > 0.1f && inputRun 5 && canRun && walking){ //当满足这些条件 6 if(canStand && canStandCrouch){ //允许站立且允许下蹲时 7 OnRunning(); //调用跑动方法 8 }}else{ 9 OffRunning(); //调用禁止跑动方法 10 } 11 if ((movement.velocity.x > 0.01f || movement.velocity.z > 0.01f) //当满足这些条件 12 || (movement.velocity.x < -0.01f || movement.velocity.z < -0.01f)) { 13 walking = true; //走动标志位为真 14 }else{ 15 walking = false; //走动标志位设为假 16 } 17 if(!canControl) //如果角色不可控 18 return; 19 if(movement.canCrouch){ //当允许下蹲时 20 if(!onLadder){ //当角色没有爬梯时 21 Crouch(); //调用下蹲方法 22 }} 23 if(onLadder){ //当在爬梯时 24 grounded = false; //设置落地标志位假 25 crouch = false; //设置下蹲标志为假 26 } 27 if(!crouch && controller.height < standardHeight-0.01f){ //当满足这些条件 28 controller.height = //使角色控制器平滑到标准高度 29 Mathf.Lerp(controller.height, standardHeight, 30 Time.deltaTime/movement.crouchSmooth); 31 Vector3 tempCenter = controller.center; //获取中心点 32 tempCenter.y = Mathf.Lerp(tempCenter.y, centerY,//对中心点Y线性插值 33 Time.deltaTime/movement.crouchSmooth); 34 controller.center = tempCenter; //重新计算中心点 35 Vector3 tempPos = lookObj.transform.localPosition;//获取localPosition 36 tempPos.y = Mathf.Lerp(tempPos.y, //摄像机随角色上下移动 37 standardHeight, Time.deltaTime/movement.crouchSmooth); 38 lookObj.transform.localPosition = tempPos; //重新计算localPosition 39 } 40 #endregion //自己添加的代码结束 41 }
第4行~第26行通过判断inputRun等标志位是否为true,来决定Player是否应该调用OnRunning进行跑步;通过判断movement的速度是否被限制在了0.01内,来判断当前是否为走路状态;当Player在爬梯上的时候,不允许Player蹲下。
第27行~第39行,通过对controller的高度、中心点、localPosition进行线性插值,来使Player在要执行一个动作时,能够平滑地过渡到这个动作。
(14)上一步骤介绍了Update方法,本节将介绍Crouch方法,此方法是控制角色蹲下的核心代码,通过控制角色身高使其处在一个较低的位置来实现蹲下效果。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的PlayerController.cs。
1 void Crouch(){ 2 Vector3 up = transform.TransformDirection(Vector3.up);//得到角色的Y方向分量 3 RaycastHit hit; //射线碰撞引用 4 CharacterController charCtrl = GetComponent<CharacterController>();//得到角色控制器组件 5 Vector3 p1 = transform.position; //保存角色位置 6 if(inputCrouch && !running && canStand){ //当角色只有站立状态时按C键才蹲下 7 crouch = !crouch; //将标志位置反 8 } 9 if (!Physics.SphereCast (p1, charCtrl.radius, transform.up, 10 out hit, standardHeight)) { //球形碰撞检测 11 if(inputJump && crouch){ //当起跳时不允许下蹲 12 crouch = false; //设置标志位 13 } 14 if(running && crouch){ //当跑动时不允许下蹲 15 crouch = false; //设置标志位 16 } 17 if(crouch){ //当下蹲标志位为真时下蹲 18 canStand = true; //设置标志位 19 } 20 canStandCrouch = true; //设置标志位 21 }else{ 22 if(crouch){ //当下蹲标志位为真时 23 canStand = false; //设置标志位 24 } 25 canStandCrouch = false; //设置标志位 26 } 27 if(crouch){ //如果下蹲标志位为真 28 if(controller.height < movement.crouchHeight+0.01f//如果这些满足条件 29 && controller.height > movement.crouchHeight-0.01f) 30 return; //不执行下面代码 31 controller.height = Mathf.Lerp(controller.height, movement.crouchHeight, 32 Time.deltaTime/movement.crouchSmooth); //改变角色控制器高度 33 Vector3 tempCenterY = controller.center; //得到角色控制器中心点 34 tempCenterY.y = Mathf.Lerp(tempCenterY.y, //改变中心点位置 35 movement.crouchHeight/2, Time.deltaTime/movement.crouchSmooth); 36 controller.center = tempCenterY; //重新计算中心点位置 37 Vector3 tempPos = lookObj.transform.localPosition;//得到lookObj对象的位置 38 tempPos.y = Mathf.Lerp(tempPos.y, //线性插值改变位置 39 movement.crouchHeight, Time.deltaTime/movement.crouchSmooth); 40 lookObj.transform.localPosition = tempPos;//重新计算localPosition 41 movement.maxForwardSpeed = movement.crouchSpeed; //计算最大前进速度 42 movement.maxSidewaysSpeed = movement.crouchSpeed; //计算最大侧向速度 43 movement.maxBackwardsSpeed = movement.crouchSpeed; //计算最大后退速度 44 }}
第6行~第8行代码控制当Player当前状态为站立状态且没有跑步的时候,那么按下蹲下按钮,将crouch标志位置反,即如果蹲下则站起,如果站起则蹲下。
第9行~第26行,通过使用SphereCast函数球形碰撞检测,如果Player蹲下的时候没有碰到其他物体,则根据当前为起跳、跑动等不同状态来设置crouch和canStand等标志位。
第27行~第44行,当Player由正常状态变为蹲下状态的时候,对角色控制器的高度、中心点位置、localPosition等进行线性插值,以让角色平滑地过渡到蹲下状态,并将此时角色的移动速度变为蹲下时候的移动速度。
(15)上一步骤介绍了Crouch方法,让角色轻松实现蹲下的效果,接下来再新增加两个方法OnRunning和OffRunning方法,用以分别控制角色在跑动和走路时候一些变量的值。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的PlayerController.cs。
1 void OnRunning (){ //跑步时候的方法 2 running = true; //设置跑动标志位为真 3 movement.maxForwardSpeed = movement.runSpeed; //计算最大前进速度 4 movement.maxSidewaysSpeed = movement.runSpeed; //计算侧向最大速度 5 jumping.extraHeight = jumping.baseHeight + 0.15f; //计算跳起的额外高度 6 } 7 void OffRunning (){ //走路时候的方法 8 running = false; //设置跑动标志位为假 9 if(crouch) { //当前正在下蹲 10 return; //不执行后续代码 11 } 12 movement.maxForwardSpeed = movement.walkSpeed; //计算最大前进速度 13 movement.maxSidewaysSpeed = movement.walkSpeed; //计算侧向最大速度 14 movement.maxBackwardsSpeed = movement.walkSpeed/2; //计算后退最大速度 15 jumping.extraHeight = jumping.baseHeight; //计算跳起的额外高度 16 }
第1行~第6行OnRunning函数为Player跑步时候调用的函数,此时将movement的最大前进速度、侧向最大速度等设置为跑步时候的速度。
第7行~第16行OffRunning函数为Player走路时候的函数,此时将movement相关的最大前进速度、侧向最大速度设置为走路时候的速度。
(16)上面对角色动作的核心控制脚本“PlayerController.cs”进行了详细的介绍,接下来再新建一个脚本“PlayerInput.cs”并将其挂载到“Player”组件上,此脚本主要用于根据用户的输入来改变“PlayerController.cs”脚本的一些变量,从而能够实时地用键盘或者触屏实现对角色的动作控制。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的PlayerInput.cs。
1 using UnityEngine; 2 using System.Collections; 3 [RequireComponent(typeof(PlayerController))] //需有PlayerController脚本组件 4 public class PlayerInput : MonoBehaviour { 5 private PlayerController motor; //定义玩家控制器马达 6 public NGUIJoystick joystick; //声明NGUIJoystick脚本组件引用 7 private Vector3 directionVector = Vector3.zero; //角色移动方向向量 8 private bool jumpBtnClick = false; //NGUI中的起跳按钮单击标志位 9 private bool crouchBtnClick = false; //NGUI中的下蹲按钮单击标志位 10 void Awake () { 11 motor = GetComponent<PlayerController>(); //拿到玩家控制器脚本组件 12 } 13 void OnJumping() { //NGUI起跳按钮中的UIButtonMessage组件中要调用的方法 14 jumpBtnClick = true; //将标志位设为真 15 } 16 void OnCrouch() { //NGUI下蹲按钮中的UIButtonMessage组件中要调用的方法 17 crouchBtnClick = true; //将标志位设为真 18 } 19 ......//此处省略了Update方法,将会在下面介绍 20 }
第5行~第9行PlayerInput声明了PlayerController玩家控制器、NGUIJoystick、角色移动方向向量、起跳标志位和下蹲标志位,用于接受用户输入,来控制PlayerController中的相关变量。
第10行~第18行,Awake函数对motor进行初始化,OnJumping函数将jumpBtnClick标志位设置为true,OnCrouch函数将crouchBtnClick标志位设置为true。
(17)上一步骤简要对“PlayerInput.cs”脚本做了简要概述,接着将介绍Update方法。在Update方法中检测用户是否按下某个键或者某个按钮,从而调用相应的方法完成相应的动作。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/PlayerController目录下的PlayerInput.cs。
1 void Update () { 2 if (Input.GetKeyDown(KeyCode.Escape)){ //如果按下的是返回键 3 Application.LoadLevel("MenuScene"); //加载LevelSelectScene场景 4 } 5 directionVector = new Vector3(Input.GetAxis("Horizontal"), 6 0, Input.GetAxis("Vertical")); //得到角色的移动方向 7 if (Input.GetAxis("Horizontal") == 0 8 && Input.GetAxis("Vertical") == 0) { //当不是用键盘控制时 9 directionVector = new Vector3(joystick.position.x, 10 0, joystick.position.y); //通过虚拟操纵杆确定移动方向 11 } 12 if(directionVector != Vector3.zero) { //有位移 13 float directionLength = directionVector.magnitude;//计算移动方向向量的长度 14 directionVector = directionVector/directionLength; //计算单位向量 15 directionLength = Mathf.Min(1, directionLength); //确保向量长度不超过1 16 directionLength = directionLength * directionLength;//使向量到达边界值更敏感 17 directionVector = directionVector * directionLength;//计算移动方向向量 18 } 19 motor.inputMoveDirection = transform.rotation * directionVector;//计算移动方向 20 if(!(Application.platform == RuntimePlatform.Android ||//不是在Android和IPhone平台上 21 Application.platform == RuntimePlatform.IPhonePlayer)) { 22 motor.inputJump = Input.GetKeyDown(KeyCode.Space); //按空格键起跳 23 motor.inputRun = Input.GetKey(KeyCode.LeftShift); //按下左Shift键加速 24 motor.inputCrouch = Input.GetKeyDown(KeyCode.C); //按下C键蹲下 25 } 26 else { 27 motor.inputJump = jumpBtnClick; //是否起跳 28 jumpBtnClick = false; //将起跳标志位重新置为false 29 motor.inputCrouch = crouchBtnClick; //是否蹲下 30 crouchBtnClick = false; //将下蹲标志位重新置为false 31 }}
第2行~第18行,当玩家按下返回键的时候,返回到主菜单界面;如果游戏运行在PC上,那么Player通过Input.GetAxis函数来通过键盘进行移动,否则通过摇杆来进行移动;然后第12~第18行对得到的directionVector进行规格化操作。
第19行~第30行,当游戏运行在PC上的时候,按下键盘上的空格键起跳,按下左Shift键加速前进,按下C键蹲下;当运行在手机上的时候,则通过屏幕上的按钮来进行操控。
(18)在上述步骤6中,创建了武器管理对象“Weapon Manager”,用以对五种武器进行管理,现在再新建脚本“WeaponAnimation.cs”并将其挂载到“Hands+Deagle对象”上,根据当前角色所在的状态如开枪还是装弹等,调用不同的动画来播放。由于代码较长,本书将在下一节介绍Reloading方法的编写。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的WeaponAnimation.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class WeaponAnimation : MonoBehaviour { 4 public float fireAnimationSpeed = 1; //开枪速度 5 public float takeInOutSpeed = 1; //枪的装入、掏出速度 6 public float reloadMiddleRepeat = 3; //装载弹药中间动画重复次数 7 public bool isSniperRifle = false; //是否为狙击步枪标志位 8 void Awake () { 9 animation.Play("Idle"); //播放空闲动画 10 animation.wrapMode = WrapMode.Once; //设置动画的循环模式为一次 11 } 12 void Fire(){ 13 animation.Rewind("Fire"); //倒回到动画开始时 14 animation["Fire"].speed = fireAnimationSpeed; //控制动画播放速度 15 animation.Play("Fire"); //播放射击动画 16 } 17 void TakeIn(){ 18 animation.Rewind("TakeIn"); //倒回到动画开始时 19 animation["TakeIn"].speed = takeInOutSpeed; //控制动画播放速度 20 animation["TakeIn"].time = 0; //从动画开始的地方开始播放 21 animation.Play("TakeIn"); //播放抢装入动画 22 } 23 void TakeOut(){ 24 animation.Rewind("TakeOut"); //倒回到动画开始时 25 animation["TakeOut"].speed = takeInOutSpeed; //控制动画播放速度 26 animation["TakeOut"].time = 0; //从动画开始的地方开始播放 27 animation.Play("TakeOut"); //播放掏枪动画 28 } 29 ......//此处省略了Reloading方法,将会在下面介绍 30 }
第4行~第11行定义了不同武器动画对应的不同速度、次数等,包括开枪速度、装枪掏枪速度、装弹中间重复次数等信息。在Awake函数中首先播放Player的空闲动画,执行一次循环。
第12地~第28行,Fire函数、TakeIn函数和TakeOut函数这三个函数分别控制Player的开枪、装枪和掏枪动作,其在函数内部都首先设置动画从开头进行播放,然后设置动画播放的速度,接着调用Play函数进行播放。
(19)上一步骤对“WeaponAnimation.cs”中的空闲、开枪、装枪和掏枪动画进行了介绍,接下来开始介绍Reloading方法,此方法主要负责装弹动画的播放。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的WeaponAnimation.cs。
1 void Reloading(float reloadTime) { 2 if(!isSniperRifle) { //当不是狙击步枪时 3 animation.Stop("Reload"); //停止重新装载(弹药)动画 4 animation["Reload"].speed = //控制动画播放速度 5 animation["Reload"].clip.length/reloadTime; 6 animation.Rewind("Reload"); //控制动画播放速度 7 animation.Play("Reload"); //播放重新装载(弹药)动画 8 } 9 else { 10 AnimationState newReload1 = //将装弹的第一段动画放入淡入淡出队列 11 animation.CrossFadeQueued("Reload_1_3"); 12 newReload1.speed = 13 animation["Reload_1_3"].clip.length/reloadTime; //控制动画播放速度 14 for(int i = 0; i < reloadMiddleRepeat; i++){ 15 AnimationState newReload2 = //将装弹的第二段动画放入淡入淡出队列 16 animation.CrossFadeQueued("Reload_2_3"); 17 newReload2.speed = //控制动画播放速度 18 animation["Reload_2_3"].clip.length/reloadTime; 19 } 20 AnimationState newReload3 = //将装弹的第三段动画放入淡入淡出队列 21 animation.CrossFadeQueued("Reload_3_3"); 22 newReload3.speed = //控制动画播放速度 23 animation["Reload_3_3"].clip.length/reloadTime; 24 }}
第2行~第8行,当不是狙击步枪的时候,停止播放装弹动画,重新设置装弹动画的速度,并使其倒回到开头部分,然后重新播放装弹。
第9行~第23行,当是其他枪的时候,首先将装弹动画的第一段动画“Reload_1_3”送入动画播放队列,然后重复将第二段动画“Reload_2_3”送入队列reloadMiddleRepeat次,最后再将最后一段动画“Reload_3_3”送入队列,使其按照队列顺序进行播放。
(20)制作Bullet Hole Prefab。新建一个空物体,将其命名为“Bullet Hole”,此对象将会作为Player开枪子弹打在地上所产生的弹孔(一块褐色的斑点)。单击“Add Component”→“Mesh”→“Mesh Renderer”,展开“Materials”节点,将“Element 0”选择为“Bullet Hole”材质,接着再为其添加“Mesh Filter”,并将“Mesh”选项修改为“Plane”。
(21)重新选择上面创建的“Bullet Hole”,为其添加“Audio Source”组件,将“Audio Clip”选项改为声音文件“ImpactSound”,然后调节Transform大小,将Scale改为“X:0.02,Y:1,Z:0.02”,缩小图片大小,如图4-47所示。
(22)新建脚本“WaitForDestroy.cs”并将其挂载到Bullet Hole对象上。“WaitForDestroy.cs”脚本是为了实现子弹打在地上的洞在若干秒之后图片的颜色逐渐变淡并且慢慢销毁的效果。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的WaitForDestroy.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class WaitForDestroy : MonoBehaviour { 4 public float lifeTime = 0.3f; //定义对象生存时间 5 public bool isFade = false; //是否逐渐消失,然后销毁 6 public float duration = 0.01f; //对颜色进行插值时的时间间隔 7 void Awake () { 8 if(!isFade) { 9 Destroy(gameObject, lifeTime); //lifeTime秒后摧毁对象 10 } 11 else { 12 StartCoroutine(FadeAndDestroy()); //颜色变淡销毁 13 }} 14 IEnumerator FadeAndDestroy () { 15 while(true) { 16 yield return new WaitForSeconds(lifeTime); //等待lifeTime秒 17 Color c = //得到材质主颜色 18 gameObject.renderer.material.GetColor("_Color"); 19 c.a = Mathf.Lerp(c.a, 0.5f, duration); //对颜色的alpha值进行插值 20 gameObject.renderer.material.color=c;//改变材质颜色(alpha值不断减小) 21 if(c.a < 0.52f) { //当alpha值小于0.5时, 22 Destroy(gameObject); //销毁对象 23 }}}}
第7行~第13行,在Awake函数中,如果不允许逐渐消失,则lifeTime后摧毁弹孔对象,否则启用线程来调用FadeAndDestroy函数逐渐使弹孔颜色变浅。
第14行~第23行,在FadeAndDestroy函数中,每隔lifeTime秒,便对材质颜色的alpla值进行差值,使其不断减小,当alpha值小于0.52的时候,就销毁此对象。
(23)为了使创建的“Bullet Hole”对象打在地上能够模拟出火花的效果,需要再新建一个空物体“MachineGun sparks”,在5.3节中的第18步、第19步、第20步骤中,通过使用Legacy Particle模拟出特殊的粒子效果,此次仍然可通过结合“Ellipsoid Particle Emitter”、“Particle Animator”、“Particle Renderer”创造出所需要的效果。并也为其挂上“WaitForDestroy.cs”脚本,如图4-48所示。
▲图4-47 Bullet Hole属性设置
▲图4-48 MachineGun sparks粒子属性设置
提示
将“MachineGun sparks”对象拖曳到“Bullet Hole”对象下,使其成为“Bullet Hole”子物体。然后将整个“Bullet Hole”对象拖到“Assets/Prefabs”文件夹下面,使其成为一个Prefab,方便资源管理和使用。
(24)上几步将弹孔对象创建好了,接下来将创建枪械射击出来的子弹对象“Bullet”。单击“GameObject”→“Create Empty”,选中并将其重命名为“Bullet”。新建脚本“Bullet.cs”并将其挂载到“Bullet”上面,然后将整个对象拖曳到“Assets/Prefabs”文件夹下面。“Bullet.cs”脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的Bullet.cs。
1 using UnityEngine; 2 using System.Collections; 3 using System.Collections.Generic; 4 public class Bullet : MonoBehaviour { 5 public LayerMask mask = -1; //忽略层变量 6 public int speed = 500; //子弹速率 7 public float life = 3; //子弹生命值 8 public int impactForce = 10; //子弹打上物体时施加力的大小 9 public bool impactHoles = true; //是否产生弹孔标志位 10 public List<GameObject> impactObjects; //弹孔效果对象 11 private int damageAmount =20; //伤害范围 12 private Vector3 velocity; //子弹速度 13 private Vector3 newPos; //新的位置 14 private Vector3 oldPos; //上次位置 15 private bool hasHit = false; //是否已经产生碰撞标志位 16 void Start (){ 17 newPos = transform.position; //子弹新位置初始化 18 oldPos = newPos; //子弹旧位置初始化 19 velocity = speed * transform.forward; //计算子弹速度 20 Destroy( gameObject, life ); //life秒后摧毁子弹对象 21 } 22 ......//此处省略了Update方法,将会在下面介绍 23 }
说明
在Bullet中,脚本声明了子弹的忽略层变量、速率、生命值、打在物体上力的大小、弹孔对象、伤害范围、新旧位置等信息,然后在Start函数中,对子弹的新旧位置和速度进行赋值初始化,并在life秒后销毁子弹对象。
(25)上一步骤介绍了“Bullet.cs”类相关变量在Start方法中的初始化,接下来介绍“Update”方法,当子弹射出去的时候,如果碰到了敌人就使敌人的血量减少;如果碰到了地面,就实例化一个Bullet Hole对象,模拟弹孔。脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的Bullet.cs。
1 void Update () { 2 if(hasHit) //已经产生碰撞 3 return; //不执行下面代码 4 newPos += velocity * Time.deltaTime; //子弹发射后不断前进 5 Vector3 direction = newPos - oldPos; //计算子弹方向 6 float distance = direction.magnitude; //计算子弹移动距离 7 if(distance > 0) { //当子弹移动距离大于零时 8 RaycastHit hit; //声明碰撞信息变量 9 if (Physics.Raycast(oldPos, direction, out hit, distance, mask)) {//使用射线检测 10 newPos = hit.point; //将碰撞点赋值给newPos变量 11 hasHit = true; //设置已经碰撞标志位为真 12 Quaternion rotation = Quaternion//计算up向量到碰撞点法向量所要进行的旋转 13 FromToRotation(Vector3.up,hit.normal); 14 Health targetHealth = hit.collider.GetComponent("Health") 15 as Health; //获取碰撞物体的Health脚本 16 if(targetHealth){ //如果脚本存在 17 int damage = (int)Random.Range(damageAmount/2, 18 damageAmount); //计算减少的血量 19 targetHealth.OnDamage(damage, -transform.forward, hit.point, 0); //进行伤害 20 } 21 if(hit.rigidbody){ //如果被碰撞物体上有刚体 22 hit.rigidbody.AddForce( transform.forward * impactForce, 23 ForceMode.Impulse ); //对刚体施加一个力 24 } 25 if(impactHoles){ //如果弹孔对象存在 26 for(int i = 0; i<impactObjects.Count; i++){//遍历弹孔列表 27 if(hit.transform.tag==impactObjects[i].name){//当两个标签相同 28 GameObject hole = //在碰撞点上实例化一个弹孔 29 (GameObject)Instantiate(impactObjects[i], hit.point, rotation); 30 hole.transform.parent = hit.transform;//设置弹孔位置 31 }}} 32 Destroy(gameObject, 1); //一秒后摧毁此对象 33 }} 34 oldPos = transform.position; //重新计算旧位置 35 transform.position = newPos; //实现子弹移动的效果 36 }
在Update函数中,不停地改变子弹的位置,计算子弹的方向和距离,然后重新给子弹的位置进行赋值,当距离大于0的时候,使用射线检测是否碰到物体。
第9行~第33行检测子弹是否碰撞到物体,如果碰撞到的物体上面有“Health”脚本,则证明碰到的是敌人,这个时候调用挂载到敌人身上的“Health”脚本中的OnDamage方法来对敌人造成伤害,如果碰到地面,则实例化一个弹孔对象。
(26)在上述步骤6中,创建了“Weapon Manager”用以管理五种枪械。本游戏将这五种枪械分为两类——“机械枪”和“霰弹枪”,机械枪可以一直按住按钮连续不停发射子弹,而霰弹枪指的是只能按一下发一颗子弹的枪。新建脚本“Weapon.cs”并绑定到“Deagle”以及其他四种枪械上,此脚本用于对开枪时的各种动作参数等进行统一管理。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的Weapon.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class Weapon : MonoBehaviour { 4 ......//此处省略了部分变量的声明,请读者自行查阅光盘 5 public enum GunType {MACHINE_GUN, SHOTGUN} //武器类型枚举 6 void Start () { 7 ......//此函数省略了部分代码片段,请读者自行查阅光盘 8 if(gunType == GunType.MACHINE_GUN){ //当武器为机械枪 9 MachineGunAwake(); //初始化武器的状态 10 } 11 if(gunType == GunType.SHOTGUN){ //当武器为霰弹枪 12 ShotGunAwake(); //初始化武器状态 13 }} 14 void Update () { 15 ......//此函数省略了部分代码片段,请读者自行查阅光盘 16 Aiming(); //调用瞄准方法 17 if(gunType == GunType.MACHINE_GUN){ //当武器为机械枪 18 MachineGunFixedUpdate(); //调用机械枪方法 19 } 20 if(gunType == GunType.SHOTGUN){ //当武器为霰弹枪 21 ShotGunFixedUpdate(); //调用霰弹枪方法 22 }} 23 void LateUpdate(){ 24 ......//此函数省略了部分代码片段,请读者自行查阅光盘 25 if(gunType == GunType.MACHINE_GUN){ //当武器为机械枪 26 if(fireBtnClickDown && canFire //当满足这些条件时 27 && !isReload && singleFire){ 28 MachineGunFire(); //调用机械枪射击方法 29 }}else{ 30 MachineGunStopFire (); //调用停止开枪方法 31 }} 32 if(gunType == GunType.SHOTGUN){ //当武器为霰弹枪 33 if(fireBtnClickDown && canFire //当满足这些条件时 34 && !isReload && singleFire){ 35 ShotGunFire (); //调用霰弹枪开枪方法 36 }}} 37 if(reloadBtnClick && !isReload && machineGun.clips > 0){//当点下R键时 38 if(gunType == GunType.MACHINE_GUN //当机械强子弹未装满 39 && machineGun.bulletsLeft != machineGun.bulletsPerClip){ 40 StartCoroutine(MachineGunReload()); //机械强装弹 41 } 42 if(gunType == GunType.SHOTGUN && shotGun.bulletsLeft != shotGun.bulletsPerClip){ 43 StartCoroutine(ShotGunReload()); //霰弹枪装弹 44 }}} 45 ......//此处省略了机械强和霰弹枪以及瞄准等相关方法,将会在后面介绍 46 }
第6行~第13行,在Start函数中,当武器为机械枪时,则执行MachineGunAwake初始化函数,对机械枪进行初始化当为霰弹枪时,则执行ShotGunAwake初始化函数,对霰弹枪进行初始化。
第14行~第22行,在Update函数中,当武器为机械枪时,则执行MachineGunFixedUpdate函数。当为无气味霰弹枪时,则执行ShotGunFixedUpdate函数。
第23行~第44行,在LateUpdate函数中,当武器为机械枪时,则执行MachineGunFire和MachineGunReload函数进行机械枪的开枪和装弹。当为霰弹枪时,则执行ShotGunFire和ShotGunReload方法进行霰弹枪的开枪和装弹。
(27)上一步骤中简要描述了“Weapon.cs”脚本的整个代码执行框架,接下来介绍和“机械枪”相关的几个方法,主要包括:开枪、停止开枪、重新装弹等。霰弹枪和机械强的方法类似,在此将不再介绍。其相关函数脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的Weapon.cs。
1 void MachineGunFixedUpdate(){ 2 ......//此函数省略了部分代码片段,请读者自行查阅光盘 3 if(fire && !isReload){ //当开枪标志位为真,且不处于装弹过程中时 4 MachineGunFire(); //调用开枪方法 5 }else{ 6 MachineGunStopFire(); //停止开枪 7 }} 8 void MachineGunFire (){ 9 if (machineGun.bulletsLeft == 0) //如果子弹用光 10 return; //不允许开枪 11 if (Time.time - machineGun.fireRate > nextFireTime){ //两次开枪大于时间间隔 12 nextFireTime = Time.time - Time.deltaTime; //计算nextFireTime变量 13 } 14 while( nextFireTime < Time.time && machineGun.bulletsLeft != 0){//不断开枪 15 StartCoroutine(MachineGunOneShot()); //开一下枪 16 nextFireTime += machineGun.fireRate; //计算下一次开枪时间 17 }} 18 void ShotGunStopFire (){ 19 motor.canRun = true; //停止开枪时,允许跑动 20 } 21 IEnumerator MachineGunReload () { 22 ......//此函数省略了部分代码片段,请读者自行查阅光盘 23 BroadcastMessage ("Reloading", //广播重新装弹的消息 24 machineGun.reloadTime, SendMessageOptions.DontRequireReceiver); 25 } 26 IEnumerator MachineGunOneShot () { 27 ......//此函数省略了部分代码片段,请读者自行查阅光盘 28 if(!aimed){ //没有瞄准时 29 Instantiate (machineGun.bullet, 30 firePoint.position, firePoint.rotation); //实例化一颗子弹 31 }else{ 32 Vector3 pos = Camera.main.ScreenToWorldPoint( //计算子弹生成点 33 new Vector3(Screen.width/2, Screen.height/2, Camera.main.nearClipPlane)); 34 Instantiate (machineGun.bullet, pos, firePoint.rotation);//实例化子弹 35 } 36 machineGun.bulletsLeft--; //子弹数量减一 37 StartCoroutine(MachineGunMuzzleFlash()); //枪口冒出火光 38 }
第1行~第20行,MachineGunFixedUpdate函数负责控制Player操控机械枪的逻辑,当开枪的时候,调用机械枪开枪的方法,否则调用其停止开枪的方法。MachineGunFire函数是用来负责开枪的,在此函数中启用协程调用MachineGunOneShot函数进行一次射击。ShotGunStopFire函数将Player的跑动标志位置为true。
第21行~第38行,MachineGunReload主要负责机械枪的装弹,通过发送消息“Reloading”来通知使其播放装弹动画;MachineGunOneShot用来进行一次射击开枪,瞄准和未瞄准的时候子弹实例化的位置是不同的,开枪的时候子弹数量减少,枪口也会冒出火光。
(28)上一步骤介绍了机械枪相关的方法,接下来介绍用于瞄准的核心函数,其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的Weapon.cs。
1 void Aiming(){ 2 if(aimed && !motor.running){ //当瞄准且角色没有跑动时 3 currentPosition = aim.aimPosition; //获取瞄准点 4 currentFov = aim.toFov; //获取当前视角 5 errorAngle = machineGun.aimErrorAngle; //获取视角容错角度 6 walkSway.bobbingAmount = aim.aimBobbingAmount; //计算bobbingAmount 7 } 8 else { 9 currentPosition = defaultPosition; //得到当前位置 10 currentFov = defaultFov; //得到当前视角 11 errorAngle = machineGun.noAimErrorAngle; //得到容错角度 12 walkSway.bobbingAmount = defaultBobbingAmount; //计算bobbingAmount 13 } 14 transform.localPosition = //对武器位置线性插值,让武器移动到视野中间 15 Vector3.Lerp(transform.localPosition,currentPosition,Time.deltaTime/aim.smoothTime); 16 Camera.main.fieldOfView = //对摄像机视角进行线性插值,让瞄准时的视野更近 17 Mathf.Lerp(Camera.main.fieldOfView, currentFov, Time.deltaTime/aim.smoothTime); 18 }
说明
在Aiming函数中,在角色处于不同条件(如当前是走路还是跑步)的时候,计算其当前位置、当前视角、当前视角容错角度等,然后对武器位置进行线性插值,让武器移动到视野的中间,对摄像机的fieldOfView进行线性插值,让瞄准时的视野更近。
(29)“SniperRifle”和“STW_25”枪械在单击鼠标右键的时候可以进行瞄准,即整个视野只有一个圆形内置一颗准星,如图4-49所示效果。为此编写脚本“SniperScope.cs”将其挂载到“SniperRifle”和“STW_25”对象上面。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的SniperScope.cs。
▲图4-49 右键单击瞄准敌人
1 using UnityEngine; 2 using System.Collections; 3 public class SniperScope : MonoBehaviour { 4 public GameObject NGUIScope; //NGUI制作的瞄准镜效果对象 5 public GameObject[] objectsToDeactive; //需要隐藏的游戏对象 6 private Weapon weapon; //武器脚本组件引用 7 void Awake () { 8 weapon = gameObject.GetComponent<Weapon>(); //拿到Weapon脚本组件 9 } 10 void OnGUI () { 11 if(weapon.aimed) { //当瞄准标志位为真时 12 NGUIScope.SetActive(true); //启用瞄准镜效果 13 for(int i=0; i<objectsToDeactive.Length;i++) { 14 objectsToDeactive[i].SetActiveRecursively(false);//将对应的游戏对象隐藏 15 }} 16 else { 17 NGUIScope.SetActive(false); //禁用瞄准镜效果对象 18 for(int j=0;j<objectsToDeactive.Length;j++) { //遍历数组 19 objectsToDeactive[j].SetActiveRecursively(true);//将对应的 游戏对象显示出来 20 }}}}
第4行~第9行声明了NGUIScope、武器脚本等对象,在Awake函数中对武器脚本进行初始化。
第10行~第20行在OnGUI函数中,当玩家触摸瞄准按钮的时候,将NGUIScope对象进行显示,然后将需要隐藏的对象隐藏掉。当未进行瞄准的时候,再将对应的游戏对象显示出来。
(30)为了使Player的视野中出现一个白色的“十”字儿准星,当开枪的时候,“十”字儿的轮廓会稍微扩大一些。为此需要编写脚本“WeaponCrosshair.cs”,将其挂载到“Weapon Manager”对象上面,原理就是调用OnGUI函数在屏幕上绘制白色的横线竖线即可。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的WeaponCrosshair.cs。
1 using UnityEngine; 2 using System.Collections; 3 public class WeaponCrosshair : MonoBehaviour { 4 public Texture2D crosshairTexture; //十字瞄准线纹理图 5 public float length = 15; //每条十字线的长度 6 public float width = 1; //每条十字线的宽度 7 public bool dynamicCrosshair = true; //开枪时十字线是否播放动画 8 public float crosshairResponce = 50; //每条直线的移动范围 9 public float defaultDistance = 20; //一条直线上的两条十字线的间隔距离 10 public float smooth = 0.3f; //平滑移动参数 11 private bool crosshair = true; //是否显示十字瞄准线标志位 12 private Texture textu; //白线纹理图 13 private GUIStyle lineStyle; //十字线样式 14 private float distance; //白线移动总距离 15 private float currentDistance; //白线移动的当前距离 16 private PlayerController motor; //PlayerController脚本组件引用 17 private WeaponManager weaponManager; //WeaponManager脚本组件引用 18 private Weapon weapon; //Weapon脚本组件引用 19 public bool canShowCrosshair; //允许显示十字瞄准线标志位 20 void Awake () { 21 lineStyle = new GUIStyle(); //新建一个样式 22 lineStyle.normal.background = crosshairTexture; //设置样式背景图 23 motor = GameObject.FindWithTag("Player") 24 GetComponent<PlayerController>(); //得到PlayerController脚本组件 25 weaponManager = GameObject.FindWithTag("WeaponManager") 26 GetComponent<WeaponManager>(); //得到WeaponManager脚本组件 27 } 28 void OnGUI () { 29 if(!(distance > (Screen.height/2)) && canShowCrosshair && crosshair){//当满足条件时 30 GUI.Box(new Rect((Screen.width - distance)/2- length,(Screen. height - width)/2, 31 length, width), textu, lineStyle); //绘制左边水平白线 32 GUI.Box(new Rect((Screen.width+distance)/2,(Screen.height-width)/2, 33 length, width), textu, lineStyle); //绘制右边水平白线 34 GUI.Box(new Rect((Screen.width - width)/2,(Screen.height-distance)/2 - length, 35 width, length), textu, lineStyle); //绘制上边竖直白线 36 GUI.Box(new Rect((Screen.width - width)/2, (Screen.height + distance)/2, 37 width, length), textu, lineStyle); //绘制下边竖直白线 38 } 39 ......//此处省略了Update方法,将会在下面介绍 40 }}
第20行~第27行在Awake函数中进行一些初始化工作,首先新建一个样式,然后设置样式的背景图为白色图片,接着通过调用FindWithTag方法对PlayerController组件和WeaponManager组件分别进行初始化。
第28行~第38行在OnGUI函数中,当需要绘制“十”字的时候,使用GUI.Box函数来绘制左右上下的白色线条,distance将会在接下来要介绍的Update函数中根据玩家的操控实时地进行计算更新。
(31)上一步骤介绍了“WeaponCrosshair.cs”脚本的OnGUI函数,接下来介绍Update函数,此函数主要根据不同情况下对distance的大小进行控制,来显示在屏幕上的“十”字儿的大小进行动态地改变。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的WeaponCrosshair.cs。
1 void Update(){ 2 if(weaponManager){ //当脚本组件存在时 3 if(weaponManager.selectedWeapon) //当已经选中武器时 4 weapon = weaponManager.selectedWeapon 5 GetComponent<Weapon>(); //得到Weapon脚本组件 6 } 7 if(Time.timeScale < 0.01f) //当时间缩放参数小于0.01时 8 return; //返回 9 if(dynamicCrosshair){ //当是动态瞄准线时 10 bool fireInput = Input.GetMouseButtonDown(0); //点下鼠标左键 11 if(weapon && (fireInput || weapon.fire)){ //满足条件时 12 if(weapon.singleFire){ //如果是单次射击 13 if(fireInput && weapon.canFire && !weapon.isReload 14 && !weapon.noBullets){ //如果满足条件 15 if(distance < crosshairResponce*4){ //当移动距离小于4倍的移动范围时 16 distance = distance + crosshairResponce;//计算移动距离 17 }}else{ 18 distance = Mathf.Lerp(distance, defaultDistance, 19 Time.deltaTime/smooth);//对距离线性插值,平滑移动白线 20 }}else{ 21 if(weapon.fire && !weapon.noBullets){ //如果在开枪且有弹药 22 currentDistance = crosshairResponce*2;//计算当前白线移动距离 23 }else{ 24 currentDistance=defaultDistance;//重新计算currentDistance 25 } 26 distance = Mathf.Lerp(distance, currentDistance, 27 Time.deltaTime/smooth); //线性插值平滑改变距离 28 }}else{ 29 currentDistance = defaultDistance; //重新计算currentDistance 30 distance = Mathf.Lerp(distance, currentDistance, 31 Time.deltaTime/smooth); //线性插值平滑改变距离 32 }}else{ 33 distance = defaultDistance; //重新计算distance 34 } 35 if(weapon) //如果变量weapon不为空时 36 if(weapon.aimed){ //如果武器处于瞄准状态 37 crosshair = false; //不显示瞄准线 38 }else{ 39 crosshair = true; //显示瞄准线 40 }}
第2行~第8行,当武器管理类脚本存在,并且此脚本中有已经选上的武器时,获得Weapon脚本组件。当时间缩放参数Time.timeScale小于0.01的时候,不执行下面的代码。
第9行~第34行,当允许动态显示瞄准线的时候,根据枪是单次射击还是多次射击对distance进行差值计算,使其白色图片平滑地就像弹簧似的从一个位置移动到另外一个位置,再从另外一个位置移动回来。
第35行~第39行,当Player当前正在瞄准的时候,不显示瞄准线,否则显示瞄准线。
(32)为了方便对五种武器进行统一的管理,方便游戏开发,为此编写脚本“WeaponManager.cs”类并将其挂载到“Weapon Manager”对象上。该类负责接受用户输入执行换枪操作,接受NGUI按钮发送的消息以调用武器的开枪、装弹等方法。在此通过对这五种武器的管理集合在一个脚本中,大大减少了代码量,提高了开发效率。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的WeaponManager.cs。
1 using UnityEngine; 2 using System.Collections; 3 using System.Collections.Generic; 4 public class WeaponManager : MonoBehaviour { 5 ......//此处省略了变量的声明,请读者自行查阅光盘 6 void Awake () { 7 .....//此函数省略了部分代码片段,请读者自行查阅光盘 8 selectedWeapon = weaponEquipment[0]; //默认武器 9 TakeFirstWeapon(weaponEquipment[0].gameObject); //设置第一把武器 10 } 11 void Update () { 12 .....//此函数省略了部分代码片段,请读者自行查阅光盘 13 isNextWeapon = (Input.GetKeyDown("2") || switchWeaponBtnClick) 14 ? true : false; //当单击键盘数字键2或单击换枪按钮时,都会换枪 15 switchWeaponBtnClick = false; //将标志位置回默认值 16 if(isNextWeapon && canSwitch) { //当单击2数字键且允许换枪时 17 StartCoroutine(SwitchWeapons(weaponEquipment[index].gameObject, 18 weaponEquipment[(index+1)%2].gameObject)); //调用方法交换武器 19 index = (index+1)%2; //index取值只能是0或者1 20 }} 21 void TakeFirstWeaponSoundPlay() { //播放掏枪声音方法 22 if(CameraAdaption.sound){ 23 audio.clip = weaponChangeAudio; //设置声音片段 24 audio.Play(); //播放声音 25 }} 26 void TakeFirstWeapon(GameObject weapon){ 27 weapon.SetActiveRecursively(true); //将武器对象及其所有子对象都显示 28 weapon.SendMessage("SelectWeapon"); //发送消息 29 canSwitch = true; //设置能否换枪标志位为真 30 } 31 IEnumerator SwitchWeapons(GameObject currentWeapon, GameObject nextWeapon) { 32 canSwitch = false; //设置能否换枪标志位为假 33 if(currentWeapon.active == true){ //若当前武器处于激活状态 34 currentWeapon.SendMessage("DeselectWeapon"); //发送消息 35 } 36 yield return new WaitForSeconds(switchTime); //等待switchTime秒 37 if(CameraAdaption.sound){ //如果可以播放声音 38 audio.clip = weaponChangeAudio; //给音频源赋值 39 audio.Play(); //播放音效 40 } 41 currentWeapon.SetActiveRecursively(false);//隐藏当前武器对象及其所有子对象 42 nextWeapon.SetActiveRecursively(true);//显示下一个武器对象及其所有子对象 43 nextWeapon.SendMessage("SelectWeapon"); //发送消息 44 canSwitch = true; //设置能否换枪标志位为真 45 } 46 ......//此处省略了OnFire方法,将会在下面介绍 47 ......//此处省略了OnAim方法,将会在下面介绍 48 .....//此处省略了OnReload方法,将会在下面介绍 49 .....//此处省略了OnSwitchWeapon方法,将会在下面介绍 50 }
第6行~第20行在Awake函数中首先设置默认选择第一把枪,在Update函数中接受玩家的输入,当玩家单击键盘上的数字2键或者换枪按钮的时候,进行换枪,如果可以播放声音,则播放声音。其中,index的取值只能为0或者1,所以只能切换两把枪。
第21行~第30行TakeFirstWeaponSoundPlay方法负责播放掏枪声音,TakeFirstWeapon方法负责掏出第一把枪。
第31行~第45是SwitchWeapons方法,为了不阻塞主线程,Update方法另外开启线程来调用此方法进行枪支的更换,如果当前可以播放声音,则播放声音,并将原来选择的枪支隐藏掉,将下一个枪支显示出来。
(33)上一步骤简要介绍了“WeaponManager.cs”脚本的Awake和Update函数需要做的工作,接下来介绍OnFire、OnAim、OnReload、OnSwitchWeapon函数。当按下NGUI按钮,如果是开枪、瞄准、装弹、换枪等动作,其发送的消息将会调用这些函数。其脚本代码如下。
代码位置:见随书光盘中源代码/第04章/FPSGame/Assets/Scripts/WeaponSystem目录下的WeaponManager.cs。
1 void OnFire(bool isFire) { 2 if(selectedWeapon.singleFire && isFire){ //当单次射击且开枪按钮被单击时 3 selectedWeapon.fireBtnClickDown = true; //将开枪按钮标志位设为真 4 } 5 else if(!selectedWeapon.singleFire){ //当武器不是单次单击(即半自动武器)时 6 selectedWeapon.fireBtnClick = isFire; //将开枪按钮单击标志位设为isFire 7 }} 8 void OnAim(bool isAim) { 9 selectedWeapon.aimBtnClick = isAim; //更改瞄准标志位 10 } 11 void OnReload(bool isReload) { 12 selectedWeapon.reloadBtnClick = isReload; //更改装弹标志位 13 } 14 void OnSwitchWeapon(bool isSwitchWeapon) { 15 switchWeaponBtnClick = isSwitchWeapon; //更改换枪标志位 16 }
说明
OnFire函数控制开枪,用fireBtnClick标志位来记录开枪;则OnAim函数控制瞄准,用aimBtnClick标志位来记录瞄准;OnReload函数控制重新装载子弹,用reloadBtnClick标志位来记录装弹;OnSwitchWeapon函数控制更换枪支,用switchWeaponBtnClick标志位记录更换。
(34)上一步骤介绍了角色、枪械的界面搭建和脚本编写,基本完成了本游戏的制作。为了能够在屏幕上实时显示玩家在地图上当前所处的位置,为此需要制作小地图,使用摄像机跟踪Player的位置。接下来就介绍小地图如何制作。选中“Player”,单击“GameObject”→“Create Other”→“Camera”,重命名为“RadarCamera”,然后修改其属性,如图4-50所示。
▲图4-50 RadarCamera属性设置
提示
制作小地图的关键是RadarCamera需要置于整个地图的上方,采用正交投影(即:将摄像机的Projection属性设置为“Orthographic”)垂直照射地面,然后使其作为Player的子物体,这样使得玩家能够实时获得Player的视野。
(35)添加PlayerPoint。新建空物体并将其重命名为“PlayerPoint”,拖动使其成为“RadarCamera”子物体。设置Layer层为“Radar”,并添加“Mesh Filter”组件和“Mesh Renderer”组件,“Mesh Renderer”组件的材质选择“radar_point”,如图4-51所示。
▲图4-51 PlayerPoint属性设置
提示
笔者所制作的图片上面有一块扇形的区域,这样只要将图片作为摄像机子物体便能够做到实时随着摄像机转动,当Player左右观看的时候,扇形区域也会转动,这样能够给玩家传递当前Player行进方向的信息。
(36)添加“radar_occluder”。为了消除摄像机四周的黑色区域,需要采用一个透明的Mesh Filter将其过滤掉即可。将“radar_occluder.fbx”拖入Hierarchy窗口,使其成为“RadarCamera”子物体,然后将“Mesh Renderer”中的材质选择为“radar_occluder”即可。其属性设置如图4-52所示。
▲图4-52 radar_occluder属性设置