游戏项目展示:《燃烧的地平线》——Unity3D游戏开发课期末作品展示
源代码:Burning-Horizon-Source-Code
要讲解这个项目,先要说明我做了些什么。这是该项目大概的几个模块。
Player模块主要负责处理玩家的输入并实现玩家坦克的移动,瞄准,开火,玩家坦克的各项状态,玩家坦克的音效播放以及摄像机控制。
Enemy模块主要负责处理敌人的索敌,瞄准,开火,移动,敌人坦克的各项状态以及敌人坦克的音效播放。
炮弹模块负责处理炮弹的碰撞检测,以及火力测试,炮弹添加了RigidBody以及Trail Renderer
损毁模块主要负责坦克的销毁功能。
Scene模块主要负责场景里的UI管理,关卡管理,游戏的暂停和退出。
接下来我会分开讲一讲各个模块的具体实现
"Player模块主要负责处理玩家的输入并实现玩家坦克的移动,瞄准,开火,玩家坦克的各项状态,玩家坦克的音效播放以及摄像机控制。"
其中我最先实现的就是坦克的移动控制。在我的设想中,不求有多么精细的履带及悬挂效果,只需要玩家操控的坦克是个刚体,并且移动时履带和轮子看得到效果就可以。
以下是TankController脚本,该脚本实现了以上效果。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TankController : MonoBehaviour
{
public Rigidbody rb;
//坦克左边的所有轮子
public GameObject[] LeftWheels;
//坦克右边的所有轮子
public GameObject[] RightWheels;
//坦克左边的履带
public GameObject LeftTrack;
//坦克右边的履带
public GameObject RightTrack;
public float wheelSpeed = 2f;
public float trackSpeed = 2f;
public float rotateSpeed = 10f;
public float moveSpeed = 2f;
public AudioSource movementAudioPlayer;
public AudioClip move;
public AudioClip idle;
private void Update()
{
// 获取输入
float horizontal = Input.GetAxis("Horizontal");
float vertical = Input.GetAxis("Vertical");
// 音效播放
if (horizontal == 0 && vertical == 0)
{
movementAudioPlayer.clip = idle;
if (!movementAudioPlayer.isPlaying)
{
movementAudioPlayer.volume = 0.2f;
movementAudioPlayer.Play();
}
}
else
{
movementAudioPlayer.clip = move;
if(!movementAudioPlayer.isPlaying)
{
movementAudioPlayer.volume = 0.6f;
movementAudioPlayer.Play();
}
}
// 限制倒车的速度
vertical = Mathf.Clamp(vertical, -0.3f, 1f);
// 这些都是为了让履带和轮子看上去在动
//坦克左右两边车轮转动
foreach (var wheel in LeftWheels)
{
wheel.transform.Rotate(new Vector3(wheelSpeed * vertical, 0f, 0f));
wheel.transform.Rotate(new Vector3(wheelSpeed * 0.6f * horizontal, 0f, 0f));
}
foreach (var wheel in RightWheels)
{
wheel.transform.Rotate(new Vector3(wheelSpeed * vertical, 0f, 0f));
wheel.transform.Rotate(new Vector3(wheelSpeed * 0.6f * - horizontal, 0f, 0f));
}
//履带滚动效果
// 前后
LeftTrack.transform.GetComponent<MeshRenderer>().material.mainTextureOffset += new Vector2(0, -trackSpeed * vertical * Time.deltaTime);
RightTrack.transform.GetComponent<MeshRenderer>().material.mainTextureOffset += new Vector2(0, -trackSpeed * vertical * Time.deltaTime);
// 左右
LeftTrack.transform.GetComponent<MeshRenderer>().material.mainTextureOffset += new Vector2(0, 0.6f * -trackSpeed * horizontal * Time.deltaTime);
RightTrack.transform.GetComponent<MeshRenderer>().material.mainTextureOffset += new Vector2(0, 0.6f * trackSpeed * horizontal * Time.deltaTime);
// 坦克本体的移动
rb.MovePosition(rb.position + transform.forward * moveSpeed * vertical * Time.deltaTime);
// 坦克本体的旋转
Quaternion turnRotation = Quaternion.Euler(0f, horizontal * rotateSpeed * Time.deltaTime, 0f);
rb.MoveRotation(rb.rotation * turnRotation);
}
}
原谅我并未做任何封装,一是因为项目本身不大,二是因为编写时从未进行过策划。
这个脚本的设计思路是,分别拿到坦克左右两边的所有车轮以及两条履带的材质,材质一定要是两个不同的材质,履带是不动的,动起来的是履带上附带的材质,这样也可以实现视觉上的履带移动效果。车轮则会实际旋转起来。具体运动方向根据玩家的输入来控制坦克的车轮旋转和履带材质的位移以符合坦克的移动方式,是前进后退还是左转右转,或者更复杂的前进后退的同时在旋转。
为什么要这样做?因为坦克和汽车的移动是不同的,坦克靠两条履带的运动的来前进,靠两边的速度差来旋转。所以我们的效果要符合。
视觉上是这样的:《燃烧的地平线》履带和车轮的运动效果
坦克的瞄准算是这个项目的难点了,参考了一些大佬的代码和项目。最终效果勉强及格。TankAimming脚本代码如下。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class TankAimming : MonoBehaviour
{
// 旋转速度
public float rotateSpeed;
// 炮塔的Transform
public Transform turret;
// 炮管的Transform
public Transform gun;
// 火炮瞄准UI图片
public Image GunAimImage;
// 炮口的Transform
public Transform gunPoint;
// 炮管的仰角
[Range(0.0f, 90.0f)]
public float elevation = 25f;
// 炮管的俯角
[Range(0.0f, 90.0f)]
public float depression = 10f;
// 当前正在使用的摄像机
public Camera currentCamera;
// 炮塔锁死功能
private bool isLocked = false;
private void Start()
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
void Update()
{
// 用于存储瞄准的方向
Vector3 aimPosition/* = Camera.main.transform.TransformPoint(Vector3.forward * 10000.0f)*/;
// 用于确定射线的落点
RaycastHit camHit;
// 射线的最大距离
float maxDistance = 10000f;
// 用于Debug.DrawRay
float camDistance = 0f;
// 从当前使用的摄像机位置向前发射射线
if (Physics.Raycast(currentCamera.transform.position,
currentCamera.transform.forward,
out camHit, maxDistance, LayerMask.GetMask("Default", "Ground", "Enemy")))
{
aimPosition = camHit.point;
camDistance = camHit.distance;
}
else
{
aimPosition = currentCamera.transform.TransformDirection(Vector3.forward) * maxDistance;
camDistance = maxDistance;
}
// 右键锁死坦克炮塔
if (Input.GetMouseButton(1))
{
isLocked = true;
}
else
{
isLocked = false;
}
// 如果炮塔没有锁死
if (!isLocked)
{
// 炮塔的实际旋转
Vector3 turretPos = transform.InverseTransformPoint(aimPosition);
turretPos.y = 0f; //过滤掉y轴的信息,防止炮塔出现绕x,z轴旋转的问题
Quaternion aimRotTurret = Quaternion.RotateTowards(turret.localRotation,
Quaternion.LookRotation(turretPos), Time.deltaTime * rotateSpeed);
turret.localRotation = aimRotTurret;
// 炮管的实际旋转
Vector3 localTargetPos = turret.InverseTransformPoint(aimPosition);
localTargetPos.x = 0f; //过滤掉x轴的信息,防止炮塔出现绕y,z轴旋转的问题
Vector3 clampedLocalVec2Target = localTargetPos;
// 根据俯仰角限制炮管的旋转角度
if (localTargetPos.y >= 0.0f)
clampedLocalVec2Target = Vector3.RotateTowards(Vector3.forward, localTargetPos, Mathf.Deg2Rad * elevation, float.MaxValue);
else
clampedLocalVec2Target = Vector3.RotateTowards(Vector3.forward, localTargetPos, Mathf.Deg2Rad * depression, float.MaxValue);
Quaternion aimRotGun = Quaternion.RotateTowards(gun.localRotation,
Quaternion.LookRotation(clampedLocalVec2Target), Time.deltaTime * rotateSpeed);
gun.localRotation = aimRotGun;
}
// 炮管的瞄准UI
RaycastHit gunHit;
Vector3 UIPos;
float gunDistance = 100f;
if (Physics.Raycast(gunPoint.position,
gunPoint.TransformDirection(Vector3.forward),
out gunHit, maxDistance,
LayerMask.GetMask("Default", "Ground", "Enemy")))
{
gunDistance = gunHit.distance;
UIPos = gunHit.point;
}
else
{
gunDistance = 100f;
UIPos = gunPoint.position + gunPoint.forward * gunDistance;
}
GunAimImage.rectTransform.position = currentCamera.WorldToScreenPoint(UIPos);
Debug.DrawRay(gunPoint.position, gunPoint.forward * gunDistance, Color.red);
Debug.DrawRay(currentCamera.transform.position, currentCamera.transform.TransformDirection(Vector3.forward) * camDistance, Color.blue);
}
}
这个脚本的原理,简单来说,从摄像机发射一条射线,射线打到场景中的某个点(或者什么也没达到就默认向前10000个单位)之后我们拿到那个点作为我们将要旋转到的目标点。再将炮塔和炮管分别旋转到他们该旋转到的位置。
这个脚本让我踩了很多坑,感兴趣可以参考一下我以前写的博客和这个视频:
二战美军谢尔曼的真实战斗力(bushi)
这个脚本最核心的就是Quaternion.RotateTowards这个API的使用,在这个情况下要使用localRotation,aimPosition也要转换到本地坐标系。
在这个脚本中我也尝试做了一个炮管指向的UI,类似坦克世界的瞄准环。实现原理就是从炮管发射一条射线,将射线的命中点作为UI的位置,然后将其转换到屏幕坐标系。
效果:瞄准系统
坦克开火,其实很简单,在炮口位置生成炮弹,粒子特效,播放音效即可,但是我们还需要实现炮弹装填,炮弹装填其实说白了就是一个bool状态,如果是否以装填是true,就可以开火。下面是FireShell脚本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class FireShell : MonoBehaviour
{
public GameObject shell;
public Transform gunPoint;
public float reactionForce = 10f;
public float LoadTime = 5f; //装填时间
public GameObject fireFX;
public AudioSource fireAudioPlayer;
private bool isLoaded; //用于标记是否装填完成
private bool startLoading; //用于标记是否要进行装填
private void Loaded()
{
isLoaded = true;
}
private void Start()
{
isLoaded = true;
startLoading = false;
}
// Update is called once per frame
void Update()
{
if(isLoaded)
{
if (Input.GetMouseButtonDown(0))
{
Instantiate(shell, gunPoint.position, gunPoint.rotation);
this.GetComponent<Rigidbody>().AddForceAtPosition(-gunPoint.forward * reactionForce, gunPoint.position, ForceMode.Impulse);
GameObject fireFXTemp = Instantiate(fireFX, gunPoint.position, gunPoint.rotation);
GameObject.Destroy(fireFXTemp, 3f);
startLoading = true;
isLoaded = false;
fireAudioPlayer.Play();
}
}
if(startLoading)
{
Invoke("Loaded", LoadTime);
startLoading = false;
}
}
}
为了实现"装填要花一点时间才能完成”这个目的,在这里我使用Invoke在LoadTime过后再执行Loaded,Loaded将isLoaded设置为true,startLoading用于确保Invoke只会执行一次。当isLoaded为false时,if里面的代码无法执行,也就无法射击,以此实现了装填效果。
值得一提的是,项目并没有使用对象池来管理炮弹对象,考虑到开发成本以及收益。
接下来是玩家坦克的各项状态Player脚本,坦克的各项状态在我的项目中并不多,我只考虑了装甲值(用于火力测试),是否被击毁,是否处于瘫痪状态。其中瘫痪效果就在本脚本中实现。Player脚本的代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour
{
public float armor = 3f;
public PlayerDestoryed playerDestoryed;
// Bailed Out特效
public GameObject bailedOutFireFx;
public Transform bialedOutFireTrans;
public float bailedOutTime = 5f;
[HideInInspector]
public bool isBailedOut;
[HideInInspector]
public bool startBailedOut; //专门用来启动Invoke
public Transform turret;
private void BailedOutOver()
{
isBailedOut = false;
}
private void Start()
{
playerDestoryed = GameObject.FindGameObjectWithTag("GameManager").GetComponent<PlayerDestoryed>();
isBailedOut = false;
startBailedOut = false;
}
private void Update()
{
if (startBailedOut)
{
GameObject fireTemp = Instantiate(bailedOutFireFx, bialedOutFireTrans.position, Quaternion.identity);
GameObject.Destroy(fireTemp, bailedOutTime);
Invoke("BailedOutOver", bailedOutTime);
startBailedOut = false;
}
if(isBailedOut)
{
this.GetComponent<TankController>().enabled = false;
this.GetComponent<TankAimming>().enabled = false;
this.GetComponent<FireShell>().enabled = false;
}
else
{
this.GetComponent<TankController>().enabled = true;
this.GetComponent<TankAimming>().enabled = true;
this.GetComponent<FireShell>().enabled = true;
}
}
}
所谓坦克瘫痪,就是坦克由于受到攻击而暂时无法行动,可能是乘员昏厥,成员跳车了,或者坦克某个部件暂时损坏,在我的游戏中都被归为一种情况,既坦克暂时无法行动。isBailedOut用于表示这个状态,当isBailedOut为true时,坦克进入瘫痪状态。在瘫痪状态时,与操作相关的三个脚本组件被设置为disabled,以此来实现无法控制的效果。同时会在坦克尾部生成火焰粒子效果来实现视觉效果。
效果展示:坦克瘫痪效果展示
最后我想直接跳过音效讲解我的摄像机控制,这个项目使用了两种摄像机,一个第三人称摄像机,一个第一人称摄像机(但是游戏依旧被我归类为第三人称射击游戏)。这两种摄像机各有用处,其中一人称摄像机设计为类似于炮手锚具那样的效果,可以像狙击枪瞄具一样放大图像,可以用鼠标滚轮来调整放大倍率,第三人称摄像机也能通过滚轮来调整远近。这套系统的关键在于两个不同的摄像机如何配合。在项目中,两种摄像机设计为通过左shift键或滚轮划到底来切换。先展示一下两种摄像机的脚本
第一人称摄像机脚本FPCamera:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class FPCamera : MonoBehaviour
{
public Camera camera;
public float mouseSentivity;
public Vector2 maxMinAngle;
public float mouseScrollSpeed = 5f;
// 当前是否是第三人称
public bool isThirdPerson;
// 第三人称摄像机的鼠标灵敏度
public float TPCameraMouseSensitivity;
// 摄像机转换
public CameraShift shifter;
// 旋转运动平滑时间
public float rotationSmoothTime = 0.12f;
private Vector3 m_mouseInputValue;
private float mouseScrollWheel;
// 当前的旋转
Vector3 currentRotation;
// 旋转运动平滑速度
Vector3 rotationSmoothVelocity;
// 实际鼠标灵敏度
[HideInInspector]
public float actualMouseSensitivity;
[HideInInspector]
public float fov = 40f;
private void Start()
{
m_mouseInputValue = new Vector3();
camera = this.GetComponent<Camera>();
fov = 40f;
}
void Update()
{
// 判断当前是否正在使用第三人称摄像机
if(isThirdPerson)
{
actualMouseSensitivity = TPCameraMouseSensitivity;
}
else
{
actualMouseSensitivity = mouseSentivity;
// 只有在第一人称时会设置摄像机fov
mouseScrollWheel = -Input.GetAxis("Mouse ScrollWheel");
actualMouseSensitivity = 0.5f + (fov / 10);
fov += mouseScrollWheel * mouseScrollSpeed;
fov = Mathf.Clamp(fov, 5f, 45f); //实际到45f时会切换为第三人称视角
camera.fieldOfView = Mathf.Clamp(fov, 5f, 40f);
}
m_mouseInputValue.y += Input.GetAxis("Mouse X") * actualMouseSensitivity;
m_mouseInputValue.x -= Input.GetAxis("Mouse Y") * actualMouseSensitivity;
// 限制垂直旋转的角度(以符合现实情况)
m_mouseInputValue.x = Mathf.Clamp(m_mouseInputValue.x, maxMinAngle.x, maxMinAngle.y);
currentRotation = Vector3.SmoothDamp(currentRotation, new Vector3(m_mouseInputValue.x, m_mouseInputValue.y, 0f), ref rotationSmoothVelocity, rotationSmoothTime);
}
private void LateUpdate()
{
Vector3 targetRotation = currentRotation;
transform.eulerAngles = targetRotation;
}
}
狙击枪瞄具效果并不神秘,调整摄像机的FOV值就可以了。一个细节的处理是在调整FOV值的同时脚本也在调整鼠标输入的灵敏度。第一人称摄像机的实现并不稀奇,网上一抓一大把,这里就不讲了。注意限制绕x轴旋转的角度。
第三人称摄像机ThirdPersonCamera:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ThirdPersonCamera : MonoBehaviour
{
// 鼠标灵敏度
public float mouseSensitivity = 10f;
// 滚轮灵敏度
public float mouseScrollSensitivity = 5f;
// 第三人称目标
public Transform target;
// 摄像机离目标的位置
public float distanceFromTarget = 2f;
// y方向的限制
public Vector2 pitchMinMax = new Vector2(-40f, 85f);
// 旋转运动平滑时间
public float rotationSmoothTime = 0.12f;
// 是否锁定鼠标
public bool lockCursor;
// 当前是否是第一人称
public bool isFirstPerson;
// 第一人称摄像机的鼠标灵敏度
public float FPCameraMouseSensitivity;
// 摄像机转换
public CameraShift shifter;
// x方向
private float yaw;
// y方向
private float pitch;
// 旋转运动平滑速度
Vector3 rotationSmoothVelocity;
// 当前的旋转
Vector3 currentRotation;
// 实际鼠标灵敏度
[HideInInspector]
public float actualMouseSensitivity = 10f;
// 滚轮拉近拉远摄像机
[HideInInspector]
public float distance;
void Start()
{
if(lockCursor)
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
distance = distanceFromTarget;
}
private void Update()
{
// 当前是否是第一人称
if(isFirstPerson)
{
actualMouseSensitivity = FPCameraMouseSensitivity;
}
else
{
actualMouseSensitivity = mouseSensitivity;
// 只有在第三人称时才考虑摄像机拉近拉远
distance -= Input.GetAxis("Mouse ScrollWheel") * mouseScrollSensitivity;
distance = Mathf.Clamp(distance, 2.5f, 16f); // 实际到2.5f时会切换到第一人称视角
distanceFromTarget = Mathf.Clamp(distance, 3f, 16f);
}
yaw += Input.GetAxis("Mouse X") * actualMouseSensitivity;
pitch -= Input.GetAxis("Mouse Y") * actualMouseSensitivity;
pitch = Mathf.Clamp(pitch, pitchMinMax.x, pitchMinMax.y);
currentRotation = Vector3.SmoothDamp(currentRotation, new Vector3(pitch, yaw, 0f), ref rotationSmoothVelocity, rotationSmoothTime);
}
// 使用LateUpdate在target.position设置好以后设置摄像机的位置
void LateUpdate()
{
Vector3 targetRotation = currentRotation;
transform.eulerAngles = targetRotation;
transform.position = target.position - transform.forward * distanceFromTarget;
}
}
“平平无奇网上一抓一大把“,但是这个脚本学的油管大佬Sebastian Lague的写法。注意将摄像机的位置设置放在LateUpdate中进行。
你可能注意到了在两个脚本中都会判断当前是第几人称,这是因为切换摄像机没有完全disable掉对应的游戏对象,而是将对应游戏对象的Camera和AudioListener组件disable掉,脚本保持运行,这样能确保两台摄像机不是独立的,如果是独立的,第三人称摄像机看的一个方向,切换到第一人称又看向另一个方向,这样的体验很糟糕,两个脚本无论当前是哪个摄像机实际在工作都会保持运行,以确保一直能获取同样的输入,来实现方向同步,方法很朋克,也有bug没解决。迫于时间我采取了我认为的简单方法。我觉得大可以在切换时将一个摄像机的rotation交给了另一个摄像机,或许会简单一点?你可以试试。
CameraShift脚本如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CameraShift : MonoBehaviour
{
public Transform TPCamera;
public Transform FPCamera;
public TankAimming tankAimming;
private bool isThirdPerson = true;
private void Start()
{
tankAimming.currentCamera = TPCamera.GetComponent<Camera>();
}
void Update()
{
// 左shift键可以切换摄像机
if(Input.GetKeyDown(KeyCode.LeftShift))
{
isThirdPerson = !isThirdPerson;
}
//滚轮拉到一定程度也可以
if (isThirdPerson && TPCamera.GetComponent<ThirdPersonCamera>().distance <= 2.5f)
{
isThirdPerson = false;
TPCamera.GetComponent<ThirdPersonCamera>().distance = 3f;
}
if (!isThirdPerson && FPCamera.GetComponent<FPCamera>().fov >= 45f)
{
isThirdPerson = true;
FPCamera.GetComponent<FPCamera>().fov = 40f;
}
if (isThirdPerson) //当前使用第三人称摄像机
{
// 设置第一人称摄像机
FPCamera.GetComponent<FPCamera>().isThirdPerson = true;
FPCamera.GetComponent<FPCamera>().TPCameraMouseSensitivity = TPCamera.GetComponent<ThirdPersonCamera>().actualMouseSensitivity;
FPCamera.GetComponent<Camera>().enabled = false;
FPCamera.GetComponent<AudioListener>().enabled = false;
// 设置第三人称摄像机
TPCamera.GetComponent<ThirdPersonCamera>().isFirstPerson = false;
TPCamera.GetComponent<Camera>().enabled = true;
TPCamera.GetComponent<AudioListener>().enabled = true;
tankAimming.currentCamera = TPCamera.GetComponent<Camera>();
}
else //当前使用第一人称摄像机
{
// 设置第一人称摄像机
FPCamera.GetComponent<FPCamera>().isThirdPerson = false;
FPCamera.GetComponent<Camera>().enabled = true;
FPCamera.GetComponent<AudioListener>().enabled = true;
// 设置第三人称摄像机
TPCamera.GetComponent<ThirdPersonCamera>().isFirstPerson = true;
TPCamera.GetComponent<ThirdPersonCamera>().FPCameraMouseSensitivity = FPCamera.GetComponent<FPCamera>().actualMouseSensitivity;
TPCamera.GetComponent<Camera>().enabled = false;
TPCamera.GetComponent<AudioListener>().enabled = false;
tankAimming.currentCamera = FPCamera.GetComponentInChildren<Camera>();
}
}
}
可以看到我确实只disable掉了Camera和AudioListener组件。对滚轮操作的处理比较直接。
“Enemy模块主要负责处理敌人的索敌,瞄准,开火,移动,敌人坦克的各项状态以及敌人坦克的音效播放。”
敌人的索敌,瞄准,开火,在我的项目中敌人会一直向玩家所在方向发射射线,如果射线打到的是玩家,也就表明敌人“发现”玩家了,只有在发现玩家的情况下敌人才会将炮塔转向玩家,于此同时会在炮口位置也发射射线,如果射线打到的是玩家,就说明敌人瞄准玩家了,只有在瞄准玩家并且炮弹装填完毕的情况下敌人才会向玩家开火。至于为啥敌人的侦测能力那么强,你可以理解为德军车长都是不怕死的超人,能做到随时探头并且360度无死角侦察。
因为写的比较早,所以和敌人的状态一起写在了Enemy脚本中了,索敌,瞄准,开火的逻辑代码片段如下:
// 是否能直接看到玩家(射线检测)
RaycastHit hit;
// 能否看见玩家
if(Physics.Raycast(watchTower.position, aimmingPosition.position - watchTower.position, out hit, detectDistance))
{
if(hit.transform.tag == "Player")
{
// 能看到玩家
canSeePlayer = true;
// 是否已经瞄准
if (Physics.Raycast(gunPoint.position, gunPoint.forward, out hit, detectDistance))
{
if(hit.transform.tag == "Player")
{
// 已经瞄准玩家
aimedAtPlayer = true;
}
else
{
aimedAtPlayer = false;
}
}
else
{
aimedAtPlayer = false;
}
}
else
{
canSeePlayer = false;
}
}
else
{
canSeePlayer = false;
}
// 没有在BailedOut状态下敌人才能行动
if(!isBailedOut)
{
if(canSeePlayer || isInfoShared)
{
// 看得到玩家或有队友的信息共享时才会尝试瞄准玩家
Vector3 aimPosition = aimmingPosition.position;
Vector3 turretPos = transform.InverseTransformPoint(aimPosition);
turretPos.y = 0f; //过滤掉y轴的信息,防止炮塔出现绕x,z轴旋转的问题
Vector3 LocalVec2Target = turretPos;
Quaternion aimRotTurret = Quaternion.RotateTowards(turret.localRotation,
Quaternion.LookRotation(turretPos), Time.deltaTime * rotateSpeed);
turret.localRotation = aimRotTurret;
Vector3 localTargetPos = turret.InverseTransformPoint(aimPosition);
localTargetPos.x = 0f;
Vector3 clampedLocalVec2Target = localTargetPos;
if (localTargetPos.y >= 0.0f)
clampedLocalVec2Target = Vector3.RotateTowards(Vector3.forward, localTargetPos, Mathf.Deg2Rad * elevation, float.MaxValue);
else
clampedLocalVec2Target = Vector3.RotateTowards(Vector3.forward, localTargetPos, Mathf.Deg2Rad * depression, float.MaxValue);
Quaternion rotationGoal = Quaternion.LookRotation(clampedLocalVec2Target);
Quaternion aimRotGun = Quaternion.RotateTowards(gun.localRotation, rotationGoal, Time.deltaTime * rotateSpeed);
gun.localRotation = aimRotGun;
// 瞄准玩家并且装填完毕就会向玩家开火
if (isLoaded && aimedAtPlayer)
{
Instantiate(shell, gunPoint.position, gunPoint.rotation);
this.GetComponent<Rigidbody>().AddForceAtPosition(-gunPoint.forward * reactionForce, gunPoint.position, ForceMode.Impulse);
GameObject fireFXTemp = Instantiate(fireFX, gunPoint.position, gunPoint.rotation);
GameObject.Destroy(fireFXTemp, 3f);
fireAudioPlayer.Play();
startLoading = true;
isLoaded = false;
}
}
// 装填弹药
if (startLoading)
{
Invoke("Loaded", LoadTime);
startLoading = false;
}
}
else
{
this.GetComponent<EnemyController>().enabled = false;
}
敌人的瞄准代码就是玩家的瞄准代码,将玩家炮塔位置作为aimPosition即可。
如果有什么要优化的,我会在敌人瞄准玩家持续几秒之后再开火,而不是“甩狙”。
效果展示:敌人的索敌,瞄准,开火
值得一提的是,当敌人的坦克瘫痪时,这些都不会进行。连带着不会进行的还有EnemyController脚本,该脚本负责敌人的移动。
敌人的移动,之前想着用Unity自带的NavMeshAgent,但那个效果不真实,所以最后决定自己做。原理是:我给它一个巡逻点,他会先转向巡逻点,再旋转的“差不多了”以后就会向前移动,直到位置“差不多”达到巡逻点的位置,移动就结束了。可以设计多个巡逻点来实现多点巡逻,无法自动避障,没有设计倒车因为没有需要用到倒车的场合。
TankController脚本如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class EnemyController : MonoBehaviour
{
//坦克左边的所有轮子
public GameObject[] LeftWheels;
//坦克右边的所有轮子
public GameObject[] RightWheels;
//坦克左边的履带
public GameObject LeftTrack;
//坦克右边的履带
public GameObject RightTrack;
// 坦克刚体
public Rigidbody rb;
// 旋转速度
public float rotateSpeed = 0.3f;
// 移动速度
public float moveSpeed = 3f;
// 巡逻控制
public float precision = 0.999f;
[HideInInspector]
public bool rotateComplete = false;
[HideInInspector]
public bool moveComplete = false;
// 巡逻点
public bool patrolIsActive = false; //巡逻点是否激活
public Transform patrolPoint; //巡逻点,使用外部的GameObject.Transform
private Transform patrolPointActived; //如果激活了巡逻点,这个对象将被赋值
// 巡逻模式
public bool activePatrolByPlayerDetected = false; //启用这个类型,敌人会在看到玩家时前往巡逻点
public bool stopPatrolByPlayerDetected = false; //启用这个类型,敌人会在看到玩家时停止巡逻
//public bool patrolLoop = false; //启用这个类型,敌人会在多个巡逻点循环往返
//public NavMeshAgent agent;
// Start is called before the first frame update
void Start()
{
rb = this.GetComponent<Rigidbody>();
rotateComplete = false;
moveComplete = false;
}
// Update is called once per frame
void Update()
{
// 已经抛弃了Navmesh Agent方案
//agent.SetDestination(patrolPoint.position);
//根据是否检测到玩家来激活巡逻点
if (activePatrolByPlayerDetected) //检测到玩家时开始巡逻
{
if(this.GetComponent<Enemy>().canSeePlayer || this.GetComponent<Enemy>().isInfoShared)
{
patrolIsActive = true;
}
}
if(stopPatrolByPlayerDetected) // 检测到玩家时停止巡逻
{
if(this.GetComponent<Enemy>().canSeePlayer)
{
patrolIsActive = false;
}
else
{
patrolIsActive = true;
}
}
// 路径是否激活
if(patrolIsActive)
{
patrolPointActived = patrolPoint;
}
else
{
patrolPointActived = null;
}
// 执行巡逻
if (patrolPointActived != null) //如果有巡逻点的话
{
Vector3 path = patrolPointActived.position - this.transform.position;
Vector3 direction = new Vector3(path.x, 0f, path.z).normalized;
// 首先转向巡逻点的方向
if (transform.InverseTransformDirection(direction).z <= precision)
{
rotateComplete = false;
float leftOrRight = Mathf.Sign(Vector3.Cross(transform.forward, direction).y); //左转还是右转?
// 实际旋转
Quaternion turnRotation = Quaternion.Euler(0f, leftOrRight * rotateSpeed * Time.deltaTime, 0f);
rb.MoveRotation(rb.rotation * turnRotation);
//坦克左右两边车轮转动
foreach (var wheel in LeftWheels)
{
wheel.transform.Rotate(new Vector3(leftOrRight * rotateSpeed * 0.06f, 0f, 0f));
}
foreach (var wheel in RightWheels)
{
wheel.transform.Rotate(new Vector3(-leftOrRight * rotateSpeed * 0.06f, 0f, 0f));
}
//履带滚动效果
// 左右
LeftTrack.transform.GetComponent<MeshRenderer>().material.mainTextureOffset += new Vector2(0, -leftOrRight * 0.06f * rotateSpeed * Time.deltaTime);
RightTrack.transform.GetComponent<MeshRenderer>().material.mainTextureOffset += new Vector2(0, leftOrRight * 0.06f * rotateSpeed * Time.deltaTime);
}
else
{
rotateComplete = true;
}
// 旋转完成后再移动坦克
if (rotateComplete && path.sqrMagnitude >= 2f)
{
moveComplete = false;
// 实际移动
rb.MovePosition(rb.position + transform.forward * moveSpeed * Time.deltaTime);
// 车轮向前
foreach (var wheel in LeftWheels)
{
wheel.transform.Rotate(new Vector3(rotateSpeed * 0.06f, 0f, 0f));
}
foreach (var wheel in RightWheels)
{
wheel.transform.Rotate(new Vector3(rotateSpeed * 0.06f, 0f, 0f));
}
// 履带向前
LeftTrack.transform.GetComponent<MeshRenderer>().material.mainTextureOffset += new Vector2(0, 0.1f * -rotateSpeed * Time.deltaTime);
RightTrack.transform.GetComponent<MeshRenderer>().material.mainTextureOffset += new Vector2(0, 0.1f * -rotateSpeed * Time.deltaTime);
}
else
{
moveComplete = true;
}
}
}
}
是否旋转完成通过patrolPointActived.position - this.transform.position来得到坦克到巡逻点的向量,在不关注Y值的情况下对向量标准化,当这个标准化向量的z值为1时说明坦克此时正对着巡逻点。但是由于旋转往往不能正好旋转到正对着,所以用了一个precision来定一个大概的精度,可以在当z值小于precision时将旋转直接设置为正对巡逻点。向前移动因为类似问题也使用了类似技巧。使用了两个bool对象rotateComplete和moveComplete来标记运动状态,两个都为true则运动完成。patrolIsActive用于标记坦克有没有激活巡逻功能,还有注意判断坦克到底有没有巡逻点的引用。
我还设计了两种行为模式,一种会在看见玩家时才发起巡逻,另一种会在发现玩家时停止巡逻。
除了判定巡逻有没有结束,脚本还要判断敌人是怎么移动的,也就是要向左转还是向右转。通过叉乘方法以及数学方法来判断,具体也就是通过Mathf.Sign(Vector3.Cross(transform.forward, direction).y)的返回值正负来判断方向。得到的方向会用来应用到坦克的实际旋转以及车轮和履带的运动效果。
巡逻点并不仅仅是一个点,他也负责保存下一个巡逻点的信息,这样巡逻点和巡逻点可以像链表一样串起来,以此来实现多点巡逻。
PatrolPoint脚本
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PatrolPoint : MonoBehaviour
{
public EnemyController enemyController = null;
public Transform nextPatrolPoint = null;
// Update is called once per frame
void Update()
{
// 如果。。。
if (enemyController != null && //当该巡逻点会被某个敌人使用
enemyController.patrolIsActive && //敌人激活了巡逻
enemyController.patrolPoint == this.transform && //敌人目前正在行驶向该巡逻点
enemyController.rotateComplete && //敌人已经完成转向
enemyController.moveComplete //敌人已经完成向前移动
)
{
if (nextPatrolPoint != null)
{
// 将下一个巡逻点设置给敌人
enemyController.patrolPoint = nextPatrolPoint;
enemyController.moveComplete = false;
enemyController.rotateComplete = false;
}
else
{
// 如果没有下一个巡逻点,就保持这设置为该巡逻点
enemyController.patrolPoint = this.transform;
}
}
}
}
这个脚本随便写的,我觉得在EnemyController脚本中进行是否巡逻完毕的判断要好一点。
敌人巡逻效果展示:敌人的巡逻
敌人坦克的状态和玩家坦克的状态差不多,以下是Enemy脚本中的参数
public EnemyDestoryed tankDestoryed;
// 炮塔旋转
public float rotateSpeed; //炮塔旋转速度
public Transform turret;
public Transform gun;
public Transform watchTower;
[Range(0.0f, 90.0f)]
public float elevation = 25f;
[Range(0.0f, 90.0f)]
public float depression = 10f;
[HideInInspector]
public bool canSeePlayer; // 能否看见玩家?
[HideInInspector]
public bool isInfoShared; // 是否有信息共享
// Bailed Out特效
public GameObject bailedOutFireFx;
public Transform bialedOutFireTrans;
public float bailedOutTime = 10f;
[HideInInspector]
public bool isBailedOut = false;
[HideInInspector]
public bool startBailedOut = false; //专门用来启动Invoke
// 敌人的装甲
[HideInInspector]
public float armor = 3f;
// 敌人的瞄准
public float detectDistance = 200f;
public Transform aimmingPosition; // 瞄准玩家的点(炮塔)
// 敌人开火
public GameObject shell;
public Transform gunPoint;
public float reactionForce = 10f;
public float LoadTime = 20f; //装填时间
public GameObject fireFX;
public AudioSource fireAudioPlayer;
private bool isLoaded; //用于标记是否装填完成
private bool startLoading; //用于标记是否要进行装填
private bool aimedAtPlayer; //是否已瞄准?
本来想用多做点的,奈何时间和水平均不允许。
关于敌人,我还设计了一个敌人共享信息系统,代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 当一个敌人发现玩家时,该系统内的所有敌人相当于都发现了玩家
public class EnemyInformationSharing : MonoBehaviour
{
public Enemy[] enemies;
private bool someCanSee;
// Start is called before the first frame update
void Start()
{
someCanSee = false;
}
// Update is called once per frame
void Update()
{
if(enemies != null)
{
foreach (var enemy in enemies)
{
if (enemy.canSeePlayer)
{
someCanSee = true;
break;
}
}
if (someCanSee)
{
foreach (var enemy in enemies)
{
enemy.isInfoShared = true;
}
}
}
}
}
当enemies中有一个人发现了玩家,所以其他敌人的enemy.isInfoShared也会被设置为true。用来制作游戏里的“共享仇恨”效果。在第一个关卡中,最后的4辆敌军坦克就是“共享仇恨”的,他们都是看到玩家就会开始巡逻的模式,在共享下一个敌人看到了玩家也就相当于4个都看到了。
“炮弹模块负责处理炮弹的碰撞检测,以及火力测试”
炮弹的碰撞检测使用OnCollisionEnter,炮弹的碰撞检测模式为Continuous Dynamic。
火力测试,顾名思义,就是炮弹能造成的伤害测试,本项目没有使用坦克世界和战争雷霆的计算等效装甲和炮弹穿深那套拟真系统,而是采用了桌游:战火FOW 的那套掷骰子系统。当炮弹打中目标时,火力测试会拿到一个随机数,火力值,如果火力值小于装甲,则这一次攻击没有产生影响,如果大于装甲值小于击毁值则坦克陷入瘫痪,如果大于击毁值则坦克击毁,大于弹药殉爆值则坦克殉爆。
Shell代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Shell : MonoBehaviour
{
public float force = 8000f;
public GameObject impactFX;
private Rigidbody m_rb;
// 火力测试
private TankDestoryed.DamageType FirePowerTest(Enemy enemy)
{
TankDestoryed.DamageType damage = TankDestoryed.DamageType.NoEffect;
float firePower = Random.Range(2f, 7f);
if (firePower < enemy.armor)
{
damage = TankDestoryed.DamageType.NoEffect;
}
else
{
if (firePower < enemy.armor + 1)
{
damage = TankDestoryed.DamageType.BailedOut;
}
else
{
if (firePower <= enemy.armor + 3)
{
damage = TankDestoryed.DamageType.Destoryed;
}
else
{
if (firePower > enemy.armor + 3)
damage = TankDestoryed.DamageType.AmmoDetonation;
}
}
}
return damage;
}
void Start()
{
m_rb = this.GetComponent<Rigidbody>();
m_rb.velocity = this.transform.forward * force;
Destroy(this.gameObject, 10f);
}
void FixedUpdate ()
{
Debug.DrawLine(this.transform.position,this.transform.position + transform.forward,Color.red, 0.1f);
}
private void OnCollisionEnter(Collision collision)
{
if(collision.gameObject.tag == "Enemy")
{
Enemy enemy = collision.gameObject.GetComponent<Enemy>();
// 伤害判定在炮弹脚本中进行
TankDestoryed.DamageType damage = FirePowerTest(enemy);
switch (damage)
{
case TankDestoryed.DamageType.NoEffect:
Debug.Log("NoEffect");
break;
case TankDestoryed.DamageType.BailedOut:
enemy.startBailedOut = true;
enemy.isBailedOut = true;
Debug.Log("BailedOut");
break;
case TankDestoryed.DamageType.Destoryed:
enemy.tankDestoryed.isTankDestoryed = true;
enemy.tankDestoryed.isAmmoDetonation = false;
enemy.tankDestoryed.destoryedTank = enemy.transform;
break;
case TankDestoryed.DamageType.AmmoDetonation:
enemy.tankDestoryed.isTankDestoryed = true;
enemy.tankDestoryed.isAmmoDetonation = true;
enemy.tankDestoryed.destoryedTank = enemy.transform;
enemy.tankDestoryed.destoryedTankTurretTrans = enemy.turret;
break;
}
if(damage == TankDestoryed.DamageType.NoEffect)
{
// 没有产生效果,炮弹不能立刻删除
Destroy(this.gameObject, 0.5f);
}
else
{
// 产生了效果,炮弹立刻删除
Destroy(this.gameObject);
}
}
else
{
Destroy(this.gameObject, 0.5f);
}
// 产生了效果 生成冲击特效
GameObject temp = Instantiate(impactFX, collision.contacts[0].point, Quaternion.FromToRotation(Vector3.up, collision.contacts[0].normal));
Destroy(temp, 3f);
}
}
“损毁模块主要负责坦克的销毁功能。”
坦克的损毁由挂载到场景中的GameManager对象的特定脚本来实现。其中敌人的坦克和玩家的坦克用不同的脚本来摧毁。摧毁过程是:首先判断是否为弹药殉爆,然后根据类型生成摧毁后的残骸,最后销毁游戏对象。
弹药殉爆和普通的摧毁不同,弹药殉爆会产生两个游戏对象,一个是对应坦克的炮塔,一个是对应坦克的车身,同时对炮塔施加一个向上的力。来达到坦克弹药殉爆的视觉效果。
坦克损毁类型:
// 坦克损坏类型
public enum DamageType {
NoEffect, BailedOut, Destoryed, AmmoDetonation };
玩家坦克被摧毁:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerDestoryed : MonoBehaviour
{
public Player player;
public GameObject TankBody_AmmoDetonated;
public GameObject TankDestoryed;
public GameObject TankTurret_AmmoDetonated;
public GameObject TPCamera;
public float ammoDetonateForce = 800f;
[HideInInspector]
public bool isDestoryed;
[HideInInspector]
public bool isAmmoDetonation = false;
private void Start()
{
player = GameObject.FindGameObjectWithTag("Player").GetComponent<Player>();
isDestoryed = false;
}
private void Update()
{
if(isDestoryed)
{
TPCamera.GetComponent<Camera>().enabled = true;
}
}
// Update is called once per frame
void LateUpdate()
{
if(isDestoryed)
{
DestoryPlayer();
}
}
private void DestoryPlayer()
{
if (isAmmoDetonation)
{
Transform body = player.transform;
Instantiate(TankBody_AmmoDetonated, body.position, body.rotation);
// 殉爆的炮塔受力受随机数影响
Instantiate(TankTurret_AmmoDetonated,
player.turret.position,
player.turret.rotation).GetComponent<Rigidbody>().AddForce((
Vector3.up + new Vector3(Random.Range(-0.05f, 0.05f), 0, Random.Range(-0.05f, 0.05f)))
* Random.Range(ammoDetonateForce * 0.8f, ammoDetonateForce * 1.2f),
ForceMode.Impulse);
}
else
{
Instantiate(TankDestoryed, player.transform.position, player.transform.rotation);
}
Destroy(player.gameObject);
//isDestoryed = false;
}
}
敌人坦克被摧毁:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EnemyDestoryed : MonoBehaviour
{
// 是否有坦克被击毁
public bool isTankDestoryed = false;
// 是否为殉爆
public bool isAmmoDetonation = false;
public float ammoDetonateForce = 800f;
[HideInInspector]
public Transform destoryedTank;
[HideInInspector]
public Transform destoryedTankTurretTrans;
// 坦克残骸预制体
public GameObject currentTankBody_AmmoDetonated;
public GameObject currentTankDestoryed;
public GameObject currentTankTurret_AmmoDetonated;
// Start is called before the first frame update
void Start()
{
isTankDestoryed = false;
isAmmoDetonation = false;
destoryedTank = null;
}
// Update is called once per frame
void Update()
{
if (isTankDestoryed)
{
if (isAmmoDetonation)
{
Transform body = destoryedTank.transform;
Instantiate(currentTankBody_AmmoDetonated, body.position, body.rotation);
// 殉爆的炮塔受力受随机数影响
Instantiate(currentTankTurret_AmmoDetonated,
destoryedTankTurretTrans.position,
destoryedTankTurretTrans.rotation).GetComponent<Rigidbody>().AddForce((
Vector3.up + new Vector3(Random.Range(-0.05f, 0.05f), 0, Random.Range(-0.05f, 0.05f)))
* Random.Range(ammoDetonateForce * 0.8f, ammoDetonateForce * 1.2f),
ForceMode.Impulse);
Destroy(destoryedTank.gameObject);
isAmmoDetonation = false;
}
else
{
Instantiate(currentTankDestoryed, destoryedTank.position, destoryedTank.rotation);
Destroy(destoryedTank.gameObject);
}
isTankDestoryed = false;
destoryedTank = null;
}
}
}
效果展示:击毁效果
“Scene模块主要负责场景里的UI管理,关卡管理,游戏的暂停和退出。”
主要是游戏开始,关卡管理,退出游戏,游戏暂停,很简单,写的很烂,看看就行。
Level脚本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
// 关卡管理器
public class Level : MonoBehaviour
{
public GameObject gameOverUI;
public GameObject MissionCompleteUI;
public GameObject gamePauseUI;
public string currentScene;
public string nextScene;
private bool isMissionComplete;
private bool isGamePaused;
private void Start()
{
isMissionComplete = false;
isGamePaused = false;
}
void Update()
{
// 判断任务失败条件
if (this.GetComponent<PlayerDestoryed>().isDestoryed)
{
gameOverUI.SetActive(true);
if (Input.GetKey(KeyCode.Space))
{
SceneManager.LoadScene(currentScene);
}
}
// 判断任务完成条件
if(GameObject.FindGameObjectWithTag("Enemy") == null) // 敌人已被消灭完全
{
isMissionComplete = true;
}
// 加载下一关
if(isMissionComplete)
{
MissionCompleteUI.SetActive(true);
if (Input.GetKey(KeyCode.Space))
{
SceneManager.LoadScene(nextScene);
}
}
// 游戏暂停中
if (isGamePaused)
{
if (Input.GetKeyDown(KeyCode.Escape))
{
Debug.Log("Quit");
Application.Quit();
}
if (Input.GetKey(KeyCode.Space))
{
isGamePaused = false;
gamePauseUI.SetActive(false);
Time.timeScale = 1f;
}
}
// 游戏暂停判断
if (Input.GetKey(KeyCode.Escape))
{
isGamePaused = true;
gamePauseUI.SetActive(true);
Time.timeScale = 0;
}
}
}
游戏在玩家被击毁时按ESC键不会退出游戏,而是进入暂停,这是个BUG。
好了,如果你看到这里,感谢您看完了这篇博客。我是一名努力成为游戏开发者的大三学生。欢迎关注我。