《燃烧的地平线》是一款类坦克世界/战争雷霆的第三人称载具射击游戏,是我大三学期的游戏开发基础课程期末作品,该作品使用Unity3D引擎开发。本博客旨记录开发心得,分享开发经验,以及纪念这款虽然很拉跨但是是自己独立开发的游戏。由于本人尚在学习阶段,开发手法也不是很标准,欢迎大佬们指出错误和不足。
最后需要说明的是本项目无任何商业用途。所使用模型及粒子特效来自互联网。其中模型及粒子特效采用了POLYGON的二战模型包。其余部分均由本人独立开发完成。
游戏项目展示:《燃烧的地平线》——Unity3D游戏开发课期末作品展示
源代码:Burning-Horizon-Source-Code
要讲解这个项目,先要说明我做了些什么。这是该项目大概的几个模块。
- Player模块
- Enemy模块
- 炮弹模块
- 损毁模块
- Scene模块
Player模块主要负责处理玩家的输入并实现玩家坦克的移动,瞄准,开火,玩家坦克的各项状态,玩家坦克的音效播放以及摄像机控制。
Enemy模块主要负责处理敌人的索敌,瞄准,开火,移动,敌人坦克的各项状态以及敌人坦克的音效播放。
炮弹模块负责处理炮弹的碰撞检测,以及火力测试,炮弹添加了RigidBody以及Trail Renderer
损毁模块主要负责坦克的销毁功能。
Scene模块主要负责场景里的UI管理,关卡管理,游戏的暂停和退出。
接下来我会分开讲一讲各个模块的具体实现
Player模块
"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的位置,然后将其转换到屏幕坐标系。
效果:瞄准系统
本条更新于2021.6.22
炮塔瞄准旋转的最终方案为:
// 炮塔Handle的Transform Handle自身永远不旋转
public Transform turretHandle;
// 炮管Handle的Transform
public Transform gunHandle;
void Update()
{
// 如果炮塔没有锁死
if (!isLocked)
{
// 炮塔的实际旋转
Vector3 localTurretTarget = turretHandle.InverseTransformPoint(aimPosition);//问题出现的原因是因为使用this.transform调用的InverseTransformPoint
localTurretTarget.y = 0f; //过滤掉y轴的信息,防止炮塔出现绕x,z轴旋转的问题
Quaternion aimRotTurret = Quaternion.RotateTowards(turret.localRotation,
Quaternion.LookRotation(localTurretTarget), Time.deltaTime * rotateSpeed);
turret.localRotation = aimRotTurret;
// 炮管的实际旋转
Vector3 localGunTargetPos = gunHandle.InverseTransformPoint(aimPosition);
localGunTargetPos.x = 0f; //过滤掉x轴的信息,防止炮塔出现绕y,z轴旋转的问题
Vector3 clampedLocalVec2Target = localGunTargetPos;
// 根据俯仰角限制炮管