FPS游戏

 [Unity练习项目]

创建时间:2020-03-20 00:45:28       最近更新时间:2020-03-21 20:22:10

游戏日志详情

本练习项目主要涉及摄像机控制、物理、动画和智能寻路的相关知识。

 

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】复选框。
反馈
╱╲