本练习项目主要涉及摄像机控制、物理、动画和智能寻路的相关知识。
1. 游戏场景
- 场景使用了Lightmap和Light Probe表现静态和动态模型的光影效果;
- 对场景模型选择【Component】->【Physics】->【Mesh Collider】,为其添加多边形碰撞体组件。在实际项目中,模型通常比较复杂,这时就需要做两组模型,一组用于显示,模型有较高的质量;另一组相对简单的模型用于物理碰撞(为了提高性能)。
2. 主角、摄像机、武器
主角:
由于是FPS,所以主角不可见,通过创建一个空的游戏体,设置Tag为Player即可。
- 【Component】->【Physics】->【Character Controller】,为主角添加一个角色控制器组件,实现在控制主角移动的同时,与场景的碰撞产生交互,从而避免穿墙;
- 在【Character Controller】组件中,调整碰撞体的位置和大小,以适应主角尺寸;
- 给主角添加Rigidbody组件,取消选中【Use Gravity】,并选中【Is Kinematic】,使用脚本控制移动。
在主角脚本中通过GetComponent获取CharacterController组件,通过调用该组件的Move函数,移动通过this.transform.TransformDirection本地化后的方向,在移动的同时会自动计算移动体与场景之间的碰撞。
摄像机:
在主角脚本中添加以下代码,使摄像机伴随着主角移动。
// 摄像机Transform
Transform m_camTransform;
// 摄像机旋转角度
Vector3 m_camRot;
// 摄像机高度(即主角的脚相对于眼睛高度)
float m_camHeight = 1.8f;
float m_camWidth = 0.3f;
// Start is called before the first frame update
void Start()
{
m_transform = this.transform;
m_ch = this.GetComponent<CharacterController>();
// 获取摄像机
m_camTransform = Camera.main.transform;
// 设置摄像机初始位置(使用TransformPoint获取Player在X轴偏移一定高度的位置)
m_camTransform.position = m_transform.TransformPoint(0, m_camHeight, -m_camWidth);
// 设置摄像机的旋转方向与主角一致
m_camTransform.rotation = m_transform.rotation;
m_camRot = m_camTransform.eulerAngles;
// 锁定鼠标
Screen.lockCursor = true;
}
// Update is called once per frame
void Update()
{
if (m_life <= 0) {
return;
}
Control();
}
void Control() {
Vector3 motion = Vector3.zero; // 移动的方向
motion.x = Input.GetAxis("Horizontal") * m_moveSpeed * Time.deltaTime;
motion.z = Input.GetAxis("Vertical") * m_moveSpeed * Time.deltaTime;
motion.y -= m_gravity * Time.deltaTime; // 重力
// 使用角色控制器提供的Move函数进行移动,其会自动检测碰撞
m_ch.Move(m_transform.TransformDirection(motion));
// 获取鼠标移动距离
float rh = Input.GetAxis("Mouse X");
float rv = Input.GetAxis("Mouse Y");
// 旋转摄像机
m_camRot.x -= rv;
m_camRot.y += rh;
m_camTransform.eulerAngles = m_camRot;
// 使主角的面向方向与摄像机一致
Vector3 camrot = m_camTransform.eulerAngles;
camrot.x = 0;
camrot.z = 0;
m_transform.eulerAngles = camrot;
// 更新摄像机位置(始终与Player一致)
m_camTransform.position = m_transform.TransformPoint(0, m_camHeight, -m_camWidth);
}
武器:
将武器绑定到摄像机上,使其随着主角移动。
- 将摄像机的位置和旋转角度都设为0;
- 将摄像机的Clipping Planes/Near设为0.1,使其可以看到更近处的物体;
- 将武器的与之体拖入场景,置于摄像机层级下方,称为摄像机的子物体,然后调整武器位置到合适位置。
3. 敌人
寻路:
- 选择场景模型,单击Inspector窗口Static旁边的小三角形按钮显示下拉列表,确认【Navigation Static】被选中,Unity将指导这样的模型用于寻路计算;
- 选中【Window】->【Navigation】,打开Navigation窗口,切换到Bake窗口;
- Bake窗口主要是定义地形对寻路的影响:
- Agent Radius和Height可理解为寻路者的半径和高度。
- Max Slope是最大坡度,超过这个坡度寻路者则无法通过。
- Step Height是楼梯的最大高度,超过这个高度寻路者则无法通过。
- Drop Height表示寻路者可以跳落的高度极限。
- Jump Distance表示寻路者的跳跃距离极限。
- 在Navigation窗口中设置好选项后,单击【Bake】按钮对地形进行计算(会保存为一个文件,名为NavMesh.asset),单击【Clear】按钮会清除计算结果。
- 选中敌人,通过选中【Component】->【Nav Mesh Agent】,将寻路组件指定给敌人。然后在Inspector窗口中进行进一步的设置,Speed是最大运动速度,Angular Speed是最大旋转速度。在Agent Type中选择【Open Agent Settings】,可以打开Navigation的Agent窗口,设置Radius和Height,表示寻路者的半径和高度。
敌人脚本实现:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
[AddComponentMenu("GameScript/Enemy")]
public class Enemy : MonoBehaviour
{
Transform m_transform;
Animator m_anim;
// 主角
Player m_player;
// 寻路组件
NavMeshAgent m_agent;
// 移动速度
float m_movSpeed = 2.5f;
// Start is called before the first frame update
void Start()
{
m_transform = this.transform;
m_anim = this.GetComponent<Animator>();
m_player = GameObject.FindGameObjectWithTag("Player").GetComponent<Player>();
m_agent = this.GetComponent<NavMeshAgent>();
m_agent.speed = m_movSpeed; // 设置寻路器的行走速度
m_agent.SetDestination(m_player.transform.position); // 设置寻路目标(最好在Update函数中每隔一段时间,重新定位目标位置)
}
}
如果希望自行控制移动,可使用CalculatePath函数计算出路径,然后按路径节点移动。
动画:
- 给敌人添加一个Animator组件,并在Controller中指定Animator Controller。取消选中【Apply Root Motion】复选框,强迫使用脚本控制游戏体的位置而不是通过动画。
- 选择【Window】->【Animator】,打开Animator窗口,Animator Controller的信息都显示在这里。在此窗口能看到敌人的所有动画,双击动画图标即可在Project窗口中找到原始动画资源。单击Parameters旁边的“+”按钮,然后在展开的子菜单中选中【Bool】,创建对应动画个数的Bool类型数值,用于与动画过渡相关联,并在脚本中控制它们。
- 对于已预设好动画过渡(在动画窗口中用连接线表示)的动画来说,默认情况下动画之间通过播放时间自动过度。当然,现在要使其收脚本控制:选择连线,在Conditions中将动画过渡条件设为上面创建好的Bool类型,当Bool值为true时即执行对应连线的动画过渡。
给敌人添加行为脚本逻辑:
// 移动速度
float m_movSpeed = 2.5f;
// 旋转速度
float m_rotSpeed = 5.0f;
// 计时器
float m_initTime = 1;
float m_timer = 1;
// 生命值
int m_life = 5;
// 出生点
protected EnemySpawn m_spawn;
// 攻击距离
float attackDist = 1.5f;
public void Init(EnemySpawn spawn) {
m_spawn = spawn;
m_spawn.m_enemyCnt ++;
}
// Update is called once per frame
void Update()
{
// 如果主角生命值为0,不做操作
if (m_player.m_life <= 0) {
return;
}
// 更新定时器
m_timer -= Time.deltaTime;
// 获取当前动画状态
AnimatorStateInfo stateInfo = m_anim.GetCurrentAnimatorStateInfo(0);
// 如果处于待机且不是过渡状态
if (stateInfo.fullPathHash == Animator.StringToHash("Base Layer.idle") && !m_anim.IsInTransition(0)) {
m_anim.SetBool("idle", false);
// 停止寻路
m_agent.ResetPath();
// 待机一段时间
if (m_timer > 0) {
return;
}
// 如果距离主角距离小于1.5,进入攻击动画状态
if (Vector3.Distance(m_transform.position, m_player.transform.position) < attackDist) {
m_anim.SetBool("attack", true);
} else {
// 重置定时器
m_timer = 1;
// 设置寻路目标点
m_agent.SetDestination(m_player.transform.position);
// 进入跑步动画状态
m_anim.SetBool("run", true);
}
}
// 如果处于跑步且不是过渡状态
if (stateInfo.fullPathHash == Animator.StringToHash("Base Layer.run") && !m_anim.IsInTransition(0)) {
m_anim.SetBool("run", false);
// 每隔1秒重新定位主角的位置
if (m_timer < 0) {
m_agent.SetDestination(m_player.transform.position);
m_timer = 1;
}
// 如果距离主角距离小于1.5,进入攻击动画状态
if (Vector3.Distance(m_transform.position, m_player.transform.position) < attackDist) {
// 停止寻路
m_agent.ResetPath();
m_anim.SetBool("attack", true);
}
}
// 如果处于攻击且不是过渡状态
if (stateInfo.fullPathHash == Animator.StringToHash("Base Layer.attack") && !m_anim.IsInTransition(0)) {
RotateTo(); // 面向主角
m_anim.SetBool("attack", false);
// 如果播完动画,重新进入待机状态
if (stateInfo.normalizedTime >= 1.0f && !m_anim.GetBool("idle")) {
m_anim.SetBool("idle", true);
// 重置定时器
m_timer = 0;
// 伤害主角
m_player.OnDamage(1);
}
}
// 如果处于死亡且不是过渡状态
if (stateInfo.fullPathHash == Animator.StringToHash("Base Layer.death") && !m_anim.IsInTransition(0)) {
m_anim.SetBool("death", false);
if (stateInfo.normalizedTime >= 1.0f) {
GameManager.Instance.AddScore(100);
Destroy(this.gameObject);
// 更新出生点的计数
m_spawn.m_enemyCnt --;
}
}
}
void RotateTo() {
// 获取目标(Player)方向
Vector3 targetdir = m_player.transform.position - m_transform.position;
// 计算出新方向
Vector3 newDir = Vector3.RotateTowards(m_transform.forward, targetdir, m_rotSpeed * Time.deltaTime, 0.0f);
// 旋转至新方向
m_transform.rotation = Quaternion.LookRotation(newDir);
}
public void OnDamage(int damage) {
m_life -= damage;
if (m_life <= 0) {
m_agent.ResetPath();
m_anim.SetBool("death", true);
}
}
4. UI界面
- 在Hierarchy窗口中单击鼠标右键,选择【UI】->【Image】->【GUI Texture】窗口2D图像控件,在Source Image中引用图片资源作为贴图。
- 创建文本作为信息显示,同时创建开始游戏按钮等UI。
- 创建一个空的游戏体,并命名为GameManager,并创建脚本GameManager.cs,指定给GameManager。该脚本主要用于控制UI界面的显示,以及背景音乐播放等逻辑。(使用单例模式)
5. 交互
主角的射击:
- 在主角脚本中,有个OnDamage函数,用于减少主角生命值,并更新UI显示,当生命值小于0时,则需要取消鼠标锁定(在上面的主角脚本的Start函数中,进行了鼠标锁定)。
- 在主角脚本的Update函数中,添加以下代码,实现射击功能。
// 枪口transform
Transform m_muzzlepoint;
// 射击时,射线能射到的碰撞层
public LayerMask m_layer;
// 射中目标后的例子效果
public Transform m_fx;
// 射击音效
public AudioClip m_audio;
// 射击间隔时间计时器
float m_shootTimer = 0;
// Update is called once per frame
void Update()
{
if (m_life <= 0) {
return;
}
Control();
// 更新射击间隔时间
m_shootTimer -= Time.deltaTime;
// 鼠标左键射击
if (Input.GetMouseButton(0) && m_shootTimer <= 0) {
m_shootTimer = 0.2f;
// 播放音效
this.GetComponent<AudioSource>().PlayOneShot(m_audio);
// 减少弹药
GameManager.Instance.ReduceAmmo(1);
// RaycastHit用来保存射线的探测结果
RaycastHit info;
// 从muzzlepoint的位置,向摄像机面向的正方向射出一根射线
// 射线只能与m_layer所指定的层碰撞
bool hit = Physics.Raycast(m_muzzlepoint.position, m_camTransform.TransformDirection(Vector3.forward), out info, 100, m_layer);
if (hit) {
// 如果射中了Tag为enemy的游戏体
if (info.transform.tag.CompareTo("Enemy") == 0) {
Enemy enemy = info.transform.GetComponent<Enemy>();
// 减少敌人生命
enemy.OnDamage(1);
// 在射中的地方释放一个粒子效果
Instantiate(m_fx, info.point, info.transform.rotation);
}
}
}
}
- 添加两个碰撞层:enemy和level。将enemy层指定给敌人,level层指定给场景模型,接着创建一个Tag,命名为enemy,指定给敌人。
- 在场景中选择主角的游戏体,添加Audio Source组件,并在脚本中进行音效(如射击音效)播放等逻辑。之后在主角脚本中,将layer设为enemy和level,这样主角的射击射线可几种敌人和场景。
敌人的进攻和死亡:
- 选择【Component】->【Physics】->【Capsule Collider】,给敌人添加碰撞体;
- 在敌人的OnDamage函数中,减少主角生命值,在生命值小于0时,则进入死亡状态(参考以上的敌人脚本代码);
- 敌人的攻击罗即,参考以上敌人代码。
6. 小地图
- 选择【GameObject】->【Create Other】->【Camera】,创建一个新的摄像机,作为小地图的专用摄像机。调整位置,使其在场景上方垂直向下,并设置为Orthographic,取消透视并调整Size的值改变视图大小,设置Viewport Rect改变摄像机显示区域的位置和大小。
- 创建一个球体,将其材质设为Self-Illumin/Diffuse,作为主角(或敌人)的“替代体”,同时取消球体的Sphere Collider,并且置于主角(或敌人)的层级之下。
- 创建一个新的Layer,命名为dummy,并设置球体的Layer为dummy。
- 选择主摄像机,取消显示dummy层,球体在主摄像机视图中将不会被显示出来。
- 选择小地图摄像机,使其只显示level和dummy层,这样在小地图中只能看到场景和球体。
- 需要注意的是:由于主摄像机上已有一个Audio Listener,而同意个场景中只允许存在一个该组件,故而要取消选中(或删除)小地图摄像机的【Audio Listener】复选框。