第三人称射击游戏核心代码

本文是学习官方教程SURVIVAL SHOOTER TUTORIAL的笔记;
主要目的是用于记录一些关键的代码和步骤;
如果感兴趣,推荐还是观看官方的教程:教程链接

预览

1. 控制角色移动

创建地板用于射线捕捉,把Layer设定为Floor

新建一个PlayerMovement脚本,绑定在角色上,并且添加以上属性

public float speed = 6f;
Vector3 movement;
Animator anim;
Rigidbody playerRigidbody;
public int floorMask;
float camerRayLength = 100f;

void Awake (){
    floorMask = LayerMask.GetMask ("Floor");  //绑定LayerMask
    anim = GetComponent<Animator> ();
    playerRigidbody = GetComponent<Rigidbody> ();
}

射线检测是否和地面碰撞,并且让角色旋转

void Turing() {
    //捕捉主摄像机和鼠标交集的射线
    Ray camRay = Camera.main.ScreenPointToRay (Input.mousePosition);
    RaycastHit floorHit;
    //射线, out 射线点, 长度, 层级,以上几个关键属性如果产生交集就让角色旋转
    if (Physics.Raycast (camRay, out floorHit, camerRayLength, floorMask)) {
        Vector3 playerToMouse = floorHit.point - transform.position;
        playerToMouse.y = 0f;
        Quaternion newRotation = Quaternion.LookRotation (playerToMouse);
        playerRigidbody.MoveRotation (newRotation);
    }
}

捕捉输入的h和v,移动角色

void Move(float h, float v) {
    movement.Set (h, 0, v);
    movement = movement.normalized * speed * Time.deltaTime;
    playerRigidbody.MovePosition (transform.position + movement);
}

如果角色移动就播放移动动画

//如果输入的上下左右不等于0,则让动画状态机的IsWalking属性变为true,具体的动画切换在角色绑定的动画状态机中
void Animating (float h, float v){
    bool walking = (h != 0f || v != 0f);
    anim.SetBool ("IsWalking", walking);
}

FiexdUpdate输入Input,并且统一调用以上几个方法

void FixedUpdate(){
    //具体的单词参考Edit->ProjectSetting->Input面板
    float h = Input.GetAxisRaw ("Horizontal");  
    float v = Input.GetAxisRaw ("Vertical");
    Move (h, v); //移动角色
    Animating (h, v); //判断是否播放移动动画
    Turing (); //旋转角色
}

ok 运行游戏试试看,此时玩家可以使用键盘移动了,并且会面朝鼠标指向的方向.

2. 控制摄像机跟随玩家

新建一个CameraLookAt脚本,代码如下

public class CameraLookAtPlayer : MonoBehaviour {
    public Transform target;    //用于编辑器中绑定玩家
    public float smoothing = 5f;    //用于计算顺滑度
    Vector3 offset;
    void Start() {
        //首先初始化的时候保存相机和玩家的相对位置
        offset = transform.position - target.position;
    }
        
//这里不要使用FixedUpdate, 移动端屏幕会有视觉卡顿
    void Update () {
        //计算出相机跟随的位置
        Vector3 targetCamPos = target.position + offset;
        //设置相机的位置,这里用到了Vector3.Lerp,是一个差值计算,使得移动更柔和.但是会略微消耗计算量
        //由于主摄像机只有1个,所以可以忽略这个计算量的消耗
        transform.position = Vector3.Lerp (transform.position, targetCamPos, smoothing * Time.deltaTime);
    }
}

在项目中设定好摄像机和玩家的距离

然后在把CamerLookAt脚本绑定在主摄像机上,并且把玩家设定到target属性中


相机的设置

ok,运行一下游戏,此时摄像机会跟随玩家移动,并且有一个缓慢加速的过程

3.创建一个敌人,自动寻路跟随玩家

首先添加Navigation烘焙

  1. 首先把场景中需要烘焙的物件设置为static, 一些障碍物体要添加碰撞体
  2. 点击Window->Navigation面板,设置烘焙属性并且烘焙

创建一个怪物并且基础设置

  1. 设置Rigidbody
  2. 添加一个胶囊碰撞体,CapsuleCollider组件,用于寻找玩家做碰撞
  3. 添加一个球碰撞体,SphereCollider组件,用于以后攻击玩家做碰撞
  4. 添加一个寻路组件,NavMeshAgent,用于配合Navigation寻路
  5. 给怪物设置一个动画控制机AminatorController,用于切换动画.

给添加怪物寻路代码

public class ZombunnyMovement_ym : MonoBehaviour {
    Transform player;
    NavMeshAgent nav;
    void Awake(){
        //因为怪物是生成器生成的,所以player需要在生成的时候遍历一下场景,找到玩家
        player = GameObject.FindGameObjectWithTag ("Player").transform;
        //读出导航组件
        nav = GetComponent<NavMeshAgent> ();
    }

    void Update() {
        //导航到目的地:玩家的坐标,这里注意,一定是transform.position,而不是transform
        nav.SetDestination (player.position);
    }
}

ok,此时运行游戏,怪物会一直朝着玩家移动

4.设置血条UI

首先设置UI如下图效果

  1. 在场景中创建一个Canvas
  2. 里面创建一个空物体用来存放HealthUI
  3. 创建一个Slider,删除手柄,只要进度条,用来显示血量


    Hierarchy中的层级

    如图,左下角有一个红心,和一个Slider,Slider删掉了手柄


    声明一下,这种UI不是我做的......

5.给玩家添加生命控制的脚本,和给敌人添加攻击的脚本

首先是添加控制玩家生命的代码

新建一个PlayerHealth脚本

using UnityEngine;
using System.Collections;
using UnityEngine.UI;

public class PlayerHealth_ym : MonoBehaviour {

    public int startingHealth = 100;
    public int currentHealth;   //当前血量
    public Slider healthSlider; //用来存放Slider,收到伤害修改它的值
    public Image damageImage;   //之前设置的收到伤害覆盖全屏红色闪烁的图片
    public AudioClip deathClip; //用来存放死亡的声音
    public float flashSpeed = 5f;
    public Color flashColor = new Color (1f, 0f, 0f, 0.1f);

    Animator anim;
    AudioSource playAudio;
    PlayerMovement_ym playMovement; //用来死亡的时候取消移动组件,防止玩家移动
    bool isDead;
    bool damaged;

    void Awake () {
        anim = GetComponent<Animator> ();
        playAudio = GetComponent<AudioSource> ();
        playMovement = GetComponent<PlayerMovement_ym> ();
        currentHealth = startingHealth;
    }

    // Update is called once per frame
    void Update () {
        //如果受到伤害,就改变图片颜色,否则,Lerp过渡颜色到空颜色
        if(damaged) {
            damageImage.color = flashColor;
        } else {
            damageImage.color = Color.Lerp (damageImage.color, Color.clear, flashSpeed * Time.deltaTime);
        }
        damaged = false;
    }

    //public方法,用于怪物攻击玩家时调用
    public void TakeDamage(int amount) {
        damaged = true;
        currentHealth -= amount;
        healthSlider.value = currentHealth;
        playAudio.Play ();
        if(currentHealth <= 0 && !isDead) {
            Death ();
        }
    }

    //死亡时播放死亡声音,设置死亡动画,并且关闭玩家移动方法
    void Death(){
        isDead = true;
        anim.SetTrigger ("Die");
        playAudio.clip = deathClip;
        playAudio.Play ();
        playMovement.enabled = false;
    }
}

接着给脚本的public属性绑定物件,如图:

接下来是给怪物添加攻击脚本

新建一个Empty_Attack脚本:

using UnityEngine;
using System.Collections;

public class Empty_Attack_ym : MonoBehaviour {


    public float timeBetweenAttacks = 0.5f; //攻击间隔
    public int attackDamage = 10;   //攻击力
    public bool playerInRange;  //玩家是否在攻击范围内

    Animator anim;
    GameObject player;
    PlayerHealth_ym playerHealth;

    float timer;

    void Awake(){
        player = GameObject.FindGameObjectWithTag ("Player");
        playerHealth = player.GetComponent<PlayerHealth_ym> ();
        anim = GetComponent<Animator> ();
    }

    //当碰撞体进入
    void OnTriggerEnter (Collider other){
        if(other.gameObject == player) {
            playerInRange = true;
        }
    }

    //当碰撞体离开
    void OnTriggerExit (Collider other) {
        if(other.gameObject == player) {
            playerInRange = false;
        }
    }

    void Update(){
        timer += Time.deltaTime;
        //当攻击正在范围内,并且timer的值大于攻击间隔,进行攻击
        if(timer >= timeBetweenAttacks && playerInRange) {
            Attack ();
        }
        //当玩家当前生命小于0, 怪物的动画状态机切换为玩家已死的状态(此时播放开心的动画);
        if(playerHealth.currentHealth <= 0) {
            anim.SetTrigger ("PlayerDie");
        }
    }

    void Attack(){
        timer = 0f;
        if(playerHealth.currentHealth > 0) {
            //调用玩家生命控制器的被攻击的方法,传入攻击伤害值
            playerHealth.TakeDamage (attackDamage);
        }
    }
}

ok,运行一下,玩家会被怪物打死...

6. 给玩家添加攻击动作,和怪物生命脚本

首先配置资源

  1. 把玩家射击的粒子预设组件拷贝到玩家的枪上
  2. 给玩家的枪添加一个Line Renderer组件
    • 设置Parameters的StartWidth和EndWidth为0.07
    • 设置Materials->Element0的材质为LineRenderMaterial
  3. 给玩家的枪添加一个Light组件,并且设置好亮度角度, 初始取消它,等待攻击的时候再临时激活
  4. 把怪物受到伤害的粒子组件拷贝到怪物身上
  5. 把怪物的Layer设置为Shootable,一会射线检测的LayerMask用得到

开始添加玩家攻击脚本PlayerShooting

代码都有注释, 就不需要额外说明了

using UnityEngine;
using System.Collections;

public class PlayerShooting_ym : MonoBehaviour {
    
    public int damagePerShot = 20;  //伤害
    public float timeBetweenBullets = 0.15f;    //攻击间隔
    public float range = 100f;  //射线最大距离

    float timer;    //用于攻击间隔计时
    Ray shootRay;   //子弹的射线
    RaycastHit shootHit;
    int shootableMask;  //用于存放LayerMask
    ParticleSystem gunParticles;
    LineRenderer gunLine;   //射线的渲染
    AudioSource gunAudio;
    Light gunLight;         //枪上的灯光
    float effectsDisplayTime = 0.2f;    //粒子时长比例

    void Awake() {
        shootableMask = LayerMask.GetMask ("Shootable");
        gunParticles = GetComponent<ParticleSystem> ();
        gunLine = GetComponent<LineRenderer> ();
        gunAudio = GetComponent<AudioSource> ();
        gunLight = GetComponent<Light> ();
    }

    void Start () {
        
    }
    
    // Update is called once per frame
    void Update () {
        timer += Time.deltaTime;
        //当点击Fire1键时,并且攻击间隔达到预设时,可以攻击
        if(Input.GetButton("Fire1") && timer >= timeBetweenBullets) {
            Shoot ();
        }
        //当计时器大于枪的粒子时间时,取消射击的样式, 这里射击样式的时间是攻击间隔 * 粒子时长比例
        if(timer >= timeBetweenBullets * effectsDisplayTime) {
            DisableEffects ();
        }
    }

    // Shoot方法是关键
    void Shoot() {
        timer = 0f;

        //激活声音,灯光
        gunAudio.Play ();
        gunLight.enabled = true;

        //停止之前的射击粒子,重新播放
        gunParticles.Stop ();
        gunParticles.Play ();

        //打开LineRenderer组件, 设置0点为gameobject默认位置
        gunLine.enabled = true;
        gunLine.SetPosition (0, transform.position);

        //设置射线的位置
        shootRay.origin = transform.position;
        //设置射线的方向,正前方
        shootRay.direction = transform.forward;

        //用于捕捉射线,然后根据射线的射击点进行做一些事情
        //物理.射线捕捉(射线, 得到的点shootHit, 最大距离, 进行计算的Layer) 
        if(Physics.Raycast(shootRay, out shootHit, range, shootableMask)) {
            //得到怪物的生命组件
            ZombunnyHealth_ym enemyHealth = shootHit.collider.GetComponent<ZombunnyHealth_ym> ();
            //如果怪物的生命组件不为空, 调用这个组件的TakeDamage方法进行扣血
            if(enemyHealth != null) {
                enemyHealth.TakeDamage (damagePerShot, shootHit.point);
            }
            //设置LineRenderer的第二个位置,为shootHit.point, 两点确定一条直线
            gunLine.SetPosition (1, shootHit.point);
        }
        else {
            //如果没有捕捉到射击点
            //设置LineRenderer的第二个位置为, 射线初始位置 + 射线方向(0, 0, 1) * 最大距离
            gunLine.SetPosition (1, shootRay.origin + shootRay.direction * range);
        }
    } 

    void DisableEffects(){
        //取消LineRenderer组件和灯光组件
        gunLine.enabled = false;
        gunLight.enabled = false;
    } 

}

给怪物添加生命组件ZombunnyHealth

    public int startingHealth = 100;    //初始生命
    public int currentHealth;   //当前生命
    public float sinkSpeed = 2.5f;  //下沉速度
    public int scoreValue = 10; //此怪物杀死计多少分
    public AudioClip deathClip; //保存死亡时播放的声音

    Animator anim;
    AudioSource enemyAudio;
    ParticleSystem hitParticles;
    CapsuleCollider capsuleCollider;    //capsulecollider是用来做移动碰撞捕捉
    bool isDead;
    bool isSinking;

    void Awake(){
        anim = GetComponent<Animator> ();
        enemyAudio = GetComponent<AudioSource> ();
        hitParticles = GetComponentInChildren<ParticleSystem> ();
        capsuleCollider = GetComponent<CapsuleCollider> ();
        currentHealth = startingHealth;
    }

    // Use this for initialization
    void Start () {
        
    }
    
    // Update is called once per frame
    void Update () {
        //如果可以下沉, 设定下沉的动画
        //transform.Translate是位置变化,必须放在循环中,才能达到像动画一样的效果
        if(isSinking){
            //位置.变化((0, -1, 0) * 下沉速度)
            transform.Translate (-Vector3.up * sinkSpeed * Time.deltaTime);
        }
    }

    public void TakeDamage(int amount, Vector3 hitPoint) {
        if(isDead) {
            return;
        }
        //播放挨打的声音
        enemyAudio.Play ();

        //设置粒子的位置等于攻击点的位置
        hitParticles.transform.position = hitPoint;
        //播放粒子
        hitParticles.Play ();

        //扣血
        currentHealth -= amount;
        if(currentHealth <= 0) {
            //调用死亡方法
            Death ();
        }
    }

    void Death() {
        isDead = true;
        //把碰撞体设置为isTrigger
        capsuleCollider.isTrigger = true;
        //设置动画控制器的触发"Dead"
        anim.SetTrigger ("Dead");
        //切换声音片为死亡声音
        enemyAudio.clip = deathClip;
        //播放声音
        enemyAudio.Play ();
    }

    //这个是公开方法, 实在模型动画里的event调用的,当动画播放到某一个时间时,会调用这个函数
    public void StartSinking(){
        //取消寻路组件
        GetComponent<NavMeshAgent> ().enabled = false;
        //取消刚体组件
        GetComponent<Rigidbody> ().isKinematic = true;
        //设置可以下沉, 一会在Updata里面就会调用下沉动画
        isSinking = true;

        //添加修改静态变量,然后更新UI的分数
        SourceValue_ym.score += scoreValue;

        //两秒之后销毁本物体
        Destroy (gameObject, 2f);
    }
}

给场景添加计分Text

  1. 在场景中间添加一个Text,如图:


  2. 给Text添加一个SourceValue组件,用于改变Text的文字
using UnityEngine;
using System.Collections;
using UnityEngine.UI;

public class SourceValue_ym : MonoBehaviour {

    public static int score;
    Text text;
        
    void Awake () {
        text = GetComponent<Text> ();
        score = 0;
    }

    // Update is called once per frame
    void Update () {
        //之前在ZombunnyHealth组件中已经添加了怪物死亡修改score的方法
        text.text = "Score: " + score;
    }
}

ok,现在运行一下游戏, 玩家可以射击攻击怪物了,并且怪物会被玩家打死, 而且有计分

一具尸体...

6. 创建怪物生成器

首先创建两个新怪物,引用之前的逻辑

第一个怪物ZomBear:

  1. 把ZomBear的模型拖进Scene中
  2. 把Zombunny的所有组件拷贝到ZomBear上: 拷贝所有组件的方法扩展链接
  3. 因为两个动画状态机和Avatar(化生)都是一样的,所以动画状态机可以使用同一个

第二个怪物Hellephant:

  1. 把Hellephant的模型拖进Scene中
  2. 把Zombunny的所有组件拷贝到Hellephant上
  3. 因为两个动画的Avatar不一样, 所以播放的动画不能重用, 但是动画逻辑可以重用
  • 创建一个Animator Override Controller(动画覆盖控制器)
  • 动画控制器选ZombunnyAC, Original中的动画片段选用Hellephant自己的动画
  • 这样就覆盖动画片段, 并且使用原有的动画逻辑了


    Animator Override Controller 例子

修改一下两个新怪物的属性,需要修改的内容有:

  • 移动速度
  • 生命值
  • 攻击力, 攻击间隔
  • 收到伤害声音, 死亡声音

接下来创建怪物生成器的代码

创建EnemyManager组件, 一会用来生成怪物

using UnityEngine;
using System.Collections;

public class EnemyManager_ym : MonoBehaviour {

    public PlayerHealth_ym playerHealth;    //玩家的血量
    public GameObject enemy;    //要创建的怪物
    public float spawnTime = 3f;    //创建间隔时间
    public Transform[] spawnPoints; //创建位置

    // Use this for initialization
    void Start () {
        //循环调用("方法名", 初始等待时间, 循环间隔时间)
        InvokeRepeating ("Spawn", spawnTime * 0.7f, spawnTime);
    }

    void Spawn() {
        if(playerHealth.currentHealth <= 0) {
            return;
        }
        //随机得到数组范围内的一个整数
        int spawnPointIndex = Random.Range (0, spawnPoints.Length);
        //实例化一个物件(物件, 位置, 旋转角度);
        Instantiate (enemy, spawnPoints [spawnPointIndex].position, spawnPoints [spawnPointIndex].rotation);
    }
}

  1. 在场景中创建一个空物体, 把坐标还原成(0, 0, 0), 取名为EnemyManager
  2. 在EnemyManager中创建三个空物体, 并且拖动到场景不同的位置中, 用于设定3种怪物的生成位置
  3. 在EnemyManager中绑定三个EnemyManager组件, 用于创建三种不同的怪物, 如图:
这是官方的做法, 有优有劣吧
ok, 可以出不同怪物了

ok, 现在运行一下游戏, 怪物从不同地方出来了, 可以好好的干一仗了

7. 最后,完善失败场景

  1. 首先添加UI, GameOver Text提示,和灰蓝色Mask, 如最后效果图
  2. 放好位置之后, 把Text和Mask的颜色Alpha都设置为0, 平时让玩家看不到.
  3. 然后给UI做动画, 等待死亡就触发它
  • 选中整个HealthCanvas, 打开Window->Animation窗口, 新建一个动画
  • 此时系统会自动创建一个AnimatorController, 然后会要求取名新建一个AnimationClip
  • 添加Text和Mask的颜色动画, 缩放动画
  1. 给系统生成的HealthCanvas动画控制器做逻辑
  • 做一个空动画, 然后链接到之前那个UI的AnimationClip上
  • 添加一个Trigger("GameOver") 触发动画
  • 这类动画记得把Exit With Time去掉勾选, 这样一触发就会立刻切换动画状态

最后添加GameOverManager组件给HealthCanvas:

using UnityEngine;
using System.Collections;
using UnityEngine.SceneManagement;

public class GameOverManager_ym : MonoBehaviour {
    public PlayerHealth_ym playerHealth;
    public float restartDelay = 3f; //重新开始的等待时间

    Animator anim;
    float restartTimer; ////重新开始的计时

    void Start () {
        anim = GetComponent<Animator> ();
    }

    void Update () {
        if(playerHealth.currentHealth <=0) {
            //触发失败动画
            anim.SetTrigger ("GameOver");

            //计时
            restartTimer += Time.deltaTime;
            if (restartTimer >= restartDelay) {
                //新的场景切换, 这个demo切换原来的场景就是重新开始游戏
                SceneManager.LoadScene (0);
            }
        }
    }
}
死亡效果

ok, 玩家被怪物打死之后会有提示, 并且在3秒之后就会重新开始游戏啦

至此, 整个教程已经结束

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容