Unity射击游戏笔记

本文讲述了在游戏开发中如何通过AimConstraint组件调整枪口方向以精确射击,处理第三人称视角下的瞄准问题,以及在使用IK时遇到的手部位置偏差。同时探讨了物体父节点变化对子物体大小的影响,提供了解决跳跃动画与碰撞检测问题的策略。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

using System;
using System.Collections.Generic;
using UnityEngine;
namespace GenshinShooters{
    public enum CharacterSide{
        human,monster,observer
    }
[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(AudioSource))]
[DisallowMultipleComponent]
//盛放改变Animator参数的方法、动画事件
public class CharacterBase : MonoBehaviour
{
    //动画参数
    public const string bodyStatusPara="bodyStatus",
    gunStatusPara="gunStatus",runningPara="running",
    verticalSpeedPara="Vertical Speed",horizontalSpeedPara="Horizontal Speed",
    takeDropPara="TakeDrop",putBackGunPara="PutBackGun",climbPara="climb",swimPara="Swim",
    firePara="fire",deadPara="dead",onGroundPara="onGround",jumpPara="Jump",reloadPara="reload";
    public const int groundLayer=8;
    const int upperLimitStand=60,lowerLimitStand=-55,upperLimitProne=10,lowerLimitProne=-10;
    // [HideInInspector]
    public Animator animator;
    // [HideInInspector]
    public AudioSource audioSource;
    [HideInInspector]
    public Transform head,leftHand,rightHand,chest;
    public Backpack backpack;
    public Transform rifleHolder,handgunHolder;
    public bool pullTrigger;//相当于扣下扳机
    protected Vector2 speed;
    float moveLerpRate=.3f;
    public AudioClip[]hitAudio,dieAudio,runAudio; 
    [Range(-90,90)]
    public float angleX=0;
    public Transform targetAxis;
    public int life=100;
    public Weapon rifleScript,handgunScript;
    public bool canBeHurt=true;
    #region 做各种动作之前的判断
    public bool readyToFire=false,readyToReload=true,readyToRun=true;
    public ClimbMonitor climbMonitor;
    public bool ReadyToJump(){//站着&&接触地面
        //在地面上的判断放在动画状态机转换里。跳跃可能有延迟,但不忽略玩家输入。
        return animator.GetInteger(bodyStatusPara)==2&&animator.GetBool(onGroundPara);;
    }
    #endregion
    protected virtual void Awake(){
        animator=GetComponent<Animator>();
        audioSource=GetComponent<AudioSource>();
        FindBodyParts();
    }
    protected virtual void Start(){
        if(rifleScript){
            rifleScript.owner=this;
        }
        if(handgunScript){
            handgunScript.owner=this;
        }
        backpack=GetComponent<Backpack>();
        FindMyShotTriggers();
    }
    void Update(){
        TargetLookUpDown(upperLimitStand,lowerLimitStand);
        if(rifleScript&&readyToFire){
            rifleScript.FireControlWithAnim(pullTrigger);
        }
    }
    public void TargetLookUpDown(int upperLimit,int lowerLimit){
        angleX=Mathf.Clamp(angleX,lowerLimit,upperLimit);
        targetAxis.localRotation=Quaternion.Euler(angleX,0,0);
    }
    [ContextMenu("寻找身体部位")]
    public void FindBodyParts(){
        animator=GetComponent<Animator>();
        chest=animator.GetBoneTransform(HumanBodyBones.Chest);
        head=animator.GetBoneTransform(HumanBodyBones.Head);
        leftHand=animator.GetBoneTransform(HumanBodyBones.LeftHand);
        rightHand =animator.GetBoneTransform(HumanBodyBones.RightHand);
    }
    #region 运动方法
    public virtual void Move(Vector2 moveInput){
        speed=new Vector2(Mathf.Lerp(speed.x,moveInput.x,moveLerpRate),
        Mathf.Lerp(speed.y,moveInput.y,moveLerpRate));
        animator.SetFloat(verticalSpeedPara,speed.y);
        animator.SetFloat(horizontalSpeedPara,speed.x);
    }
    public virtual void Rotate(Vector2 aimVector){
        transform.Rotate(0,aimVector.x,0);
        angleX-=aimVector.y;
    }
    public virtual void Run(bool running){
        animator.SetBool(runningPara,running);
    }
    public virtual void Jump(){
    }
    public void Climb(){
        animator.SetTrigger(climbPara);
    }
    #endregion
    #region 改变姿态方法
    public virtual void StandUp(){
        animator.SetInteger(bodyStatusPara,2);//从趴着站起来,进入过渡状态
    }
    public virtual void GoProne(){
        animator.SetInteger(bodyStatusPara,0);
    }
    public virtual void GoCrouch(){
        animator.SetInteger(bodyStatusPara,1);//从站着蹲下去,直接蹲
    }
    public virtual void LeanLeft(){//左斜身

    }
    public virtual void LeanRight(){//右斜身
        
    }
    public virtual void CancelLean(){//取消斜身

    }
    #endregion
    #region 枪和物品相关方法
    void SetDropGunTransform(Weapon gun){
        gun.transform.SetParent(null);//不再跟随右手
        //放下枪的两种方案1.使用刚体和碰撞体自由掉落2.使用代码放置
        gun.transform.position=transform.position+.1f*Vector3.up;
        gun.transform.eulerAngles=transform.eulerAngles+new Vector3(0,-90,90);
        gun.Dropped();
    }
    public void TakeGun(Weapon gun){
        GunDataBin gunDataBin=GunMagDataManager.Instance.GetGunData(gun.id);
        switch(gunDataBin.gunType){
            case GunTypes.Handgun:
                TakeHandgun(gun);
                break;
            case GunTypes.MainGun:
                TakeRifle(gun);
                break;
        }
        gun.owner=this;
        animator.SetTrigger(takeDropPara);
    }
    public void PutDownGun(int gunStatus=-1){
        if(gunStatus==-1){
            gunStatus=animator.GetInteger(gunStatusPara);
        }
        switch(gunStatus){
            case 1:
            SetDropGunTransform(rifleScript);
            rifleScript.FireControlWithAnim(false);
            rifleScript=null;
            break;
            case 2:
            SetDropGunTransform(handgunScript);
            handgunScript.FireControlWithAnim(false);
            handgunScript=null;
            break;
        }
        animator.SetInteger(gunStatusPara,0);
        animator.SetTrigger(takeDropPara);//动画事件触发脚本方法
    }
    public void SwapGun(Weapon gun){
        GunDataBin gunDataBin=GunMagDataManager.Instance.GetGunData(gun.id);
        if(gunDataBin.gunType==GunTypes.Handgun){
            SetDropGunTransform(handgunScript);
        }
        else{
            SetDropGunTransform(rifleScript);
        }
        TakeGun(gun);
    }
    public virtual bool TakeItem(Item item){
        Mag mag=item as Mag;
        MagInPack magData=new MagInPack(mag.id,mag.roundNum);
        bool result=backpack.PutMagInPack(magData);//是否因为背包满了而添加失败
        if(result){
            animator.SetTrigger(takeDropPara);
            DestroyImmediate(item.gameObject);//按F捡起的物品一定在场景里,需要销毁
        }
        return result;
    }
    public virtual bool TakeItemFromPack(ItemInPack itemInPack){
        bool result=backpack.PutMagInPack(itemInPack as MagInPack);
        if(result){
            animator.SetTrigger(takeDropPara);
        }
        return result;
    }
    [ContextMenu("使用步枪")]
    public virtual void UseRifle(){
        animator.SetInteger(gunStatusPara,1);
    }
    [ContextMenu("收起枪")]
    public void PutAwayGun(){
        // animator.SetTrigger(putBackGunPara);
        animator.SetInteger(gunStatusPara,0);
    }
    public virtual void UsePistol(){
        animator.SetInteger(gunStatusPara,2);
    }
    public Weapon GetGunInHand(){
        Weapon gun=null;
        switch(animator.GetInteger(gunStatusPara)){
            case 1:
            gun=rifleScript;
            break;
            case 2:
            gun=handgunScript;
            break;
        }
        return gun;
    }
    public virtual void Reload(){//背包里没有同种子弹就直接跳过
        Weapon gun=GetGunInHand();
        if(backpack.CheckPackForMag(
            GunMagDataManager.Instance.GetGunData(gun.id).magID)){//背包里有当前枪的弹匣
            animator.SetTrigger(reloadPara);//执行换弹动画
        }
    }
    public void UpdateAmmo(Weapon gun){
        int ammo=gun.ammo;//先取,防止背包快慢的时候不允许弹匣放回去
        gun.ammo=backpack.FetchMagFromPack(GunMagDataManager.Instance.GetGunData(gun.id).magID);
        if(ammo>0){
            MagInPack magData=new MagInPack(GunMagDataManager.Instance.GetGunData(gun.id).magID,ammo);
            backpack.PutMagInPack(magData);
        }
    }
    protected virtual void TakeHandgun(Weapon gun){
        handgunScript=gun;
        gun.transform.SetParent(handgunHolder);
        gun.transform.localPosition=Vector3.zero;
        gun.transform.localEulerAngles=Vector3.zero;
    }
    protected virtual void TakeRifle(Weapon gun){
        rifleScript=gun;
        gun.transform.SetParent(rifleHolder);
        gun.transform.localPosition=Vector3.zero;
        gun.transform.localEulerAngles=Vector3.zero;
    }
    public virtual void ToggleAutoMode(){
        Weapon gun=GetGunInHand();
        switch(gun.autoMode){//希望能简化这一块
            case Weapon.AutoMode.Semi:
            if(GunMagDataManager.Instance.GetGunData(gun.id).burst){
                gun.autoMode=Weapon.AutoMode.Burst;
                gun.animator.SetBool(Weapon.burstPara,true);
            }
            else{
                if(GunMagDataManager.Instance.GetGunData(gun.id).fullAuto){
                    gun.autoMode=Weapon.AutoMode.Full;
                }
            }
            break;
            case Weapon.AutoMode.Burst:
            if(GunMagDataManager.Instance.GetGunData(gun.id).fullAuto){
                gun.autoMode=Weapon.AutoMode.Full;
            }
            else{
                if(GunMagDataManager.Instance.GetGunData(gun.id).semiAuto){
                    gun.autoMode=Weapon.AutoMode.Semi;
                }
            }
            gun.animator.SetBool(Weapon.burstPara,false);
            break;
            case Weapon.AutoMode.Full:
            if(GunMagDataManager.Instance.GetGunData(gun.id).semiAuto){
                gun.autoMode=Weapon.AutoMode.Semi;
            }
            else{
                if(GunMagDataManager.Instance.GetGunData(gun.id).burst){
                    gun.autoMode=Weapon.AutoMode.Burst;
                }
            }
            break;
        }
    }
    #endregion
    public virtual void TakeDamage(int damage,string hint=""){
        if(!canBeHurt){
            return;
        }
        life-=damage;
        if(life<=0){
            Die(hint);
        }
        else{
            PlayRandomClip(hitAudio);
        }
    }
    public virtual void Die(string hint){
        PlayRandomClip(dieAudio);
    }
    public void PlayRandomClip(AudioClip[]audios){
        if(audios.Length>0&&!audioSource.isPlaying){
            int ind=UnityEngine.Random.Range(0,audios.Length);
            audioSource.clip=audios[ind];
            audioSource.Play();
            }
    }
    [ContextMenu("寻找中枪触发器")]
    void FindMyShotTriggers(){
        BodyTrigger[] bodyTriggers=GetComponentsInChildren<BodyTrigger>();
        for(int i=0;i<bodyTriggers.Length;i++){
            bodyTriggers[i].myCharacter=this;
        }
    }
    #region 动画事件
    void MoveMagInHand(){
        Weapon gun=GetGunInHand();
        if(gun){//判断换弹中人物死亡
            if(gun.mag){
                gun.mag.SetParent(leftHand,true);
            }
            else{
                Debug.Log("没指定枪的弹匣!");
            }
        }
    }
    public void MoveMagInGun(){//这个方法是动画事件,不能加参数!!!!!
        Weapon gun=GetGunInHand();
        if(gun){
            gun.mag.SetParent(gun.magParent);
            gun.mag.localPosition=gun.magPositionInRifle;
            gun.mag.localEulerAngles=gun.magEulerInRifle;
        }
    }
    #endregion
}
}

如何判断FPS的枪摆正了?

有一个充要条件:

枪的z轴和第一人称相机的z轴平行。这样不管经过多远,枪的正前方和第一人称相机正前方的落点的距离保持不变,而这个偏差在很远处是可以忽略不计的。

还有一个检验方法:在第一人称截个图,沿枪管画出一条直线,看是否经过屏幕中心。

相机

第三人称相机

随人物站、蹲、趴改变高度

方案1:TPP相机挂animator,用动画改变shoulder Offset

不持枪状态改变仰角

方案1:只改变相机仰角

问题:由于不改变人物头部的仰角,相机仰视可能拍到人物头内部。

方案2:改变人物头的仰角,带动FPP相机

改变人物头的仰角还有几种方案:

1.LootAtConstraint看向目标物,可以防抖,但是跑步时人物头动作僵硬;

2.RotationConstraint约束到人物根节点,但是x轴旋转加一个Offset,不需要目标物,也可以防抖,也僵硬;

3.animator.SetBoneLocalRotation(),不需要目标物,应该有防抖的写法,但是我还没研究出来;

总结下来,目标物方案需要手动建立、指定目标物,比较麻烦,但是控制一个TargetAxis就能控制TPP相机、FPP相机、人物头的旋转,使用很方便。

不用目标物,FPP相机、TPP相机、头部不能用LookAt和LookAtConstraint,TPP相机跟随人物根节点,不会晃动,可以用transform.localEulerAngle,为了让人物在视野里,灵敏度还要调;FPP相机跟随人物头,需要防抖,使用RotationContraint约束到人物根节点;

人物在斜面上趴着的时候身体和斜面平行

实现方法1:人物趴下的时候用代码把刚体的x轴旋转约束解除

刚开始做的时候没发现这个方法因为忘了在人物趴下时把碰撞体平放,导致碰撞体翻倒。于是研究出了下面复杂的方法。

实现方法2:在OnCollisionStay()里获得人物和地面的碰撞,得到碰撞点的法向,然后用SetFromToRotation()旋转。

这个方法的问题:趴下的时候人物很容易不停晃。

射击逻辑

射击输入检测

  • 需要在准备好射击(手里有枪&&没在跑步&&没在换弹&&枪有子弹)&&玩家按下鼠标时射击。
  • 人物可能有多把枪,需要先判断哪把是手里的。
  • 人在射击中还按着鼠标突然死亡、开始跑步、换弹等各种动作,停止射击。

准备好射击和有输入分别用bool readyToFire和bool pullTrigger,它们与的结果每帧检测,不能有“保持原状”的情况,否则容易出现射击不能开始或停止的情况。

射速控制

动画和代码的比较

游戏里大部分参数都既可以用代码控制也可以用动画控制。二者的特点不同。

动画:时间控制精确,逻辑死板;

代码:逻辑灵活,时间控制困难,只有第一帧和每一帧执行的生命周期函数,其他时机都要写计时器代码控制。

比如写一个人物定时眨眼的效果,二者都能控制模型skinned mesh renderer的blendShapes,动画需要打4个关键帧,极简单;代码需要一个计时器变量,在Update()判断到没到该眨眼的帧,用Mathf.Lerp()写过渡效果……

代码方案

我一开始的射击功能就是靠代码、计时器变量写的。

而枪的射速是固定的,用动画实现应该会更简单。

动画方案

枪上挂Animator。

枪的动画状态机

枪的动画状态机的设计面临几个选择:

1.进入射击状态的条件用bool还是trigger;

2.射击状态的动画是否选择loop time,是选择loop time在firing为false时退出,还是不选择loop time在Exit time退出,或者说连发射击时枪在射击状态反复播放还是在射击和Idle间反复跳转;

我这里的设计

firing为true时进入Recoil,Recoil通过hasExitTime退出,另外通过hasAmmo判断是跳转到Idle还是HoldOpen(空仓挂机),换弹后hasAmmo=true,进入Idle。

枪的射击动画

Fire()在第一帧执行,一共有几帧取决于枪的射速,枪一秒射击n发,动画一秒60帧,动画长度就是60/n。动画有回膛就做回膛,无事可做就加个无关紧要的属性,比如Scale保持不变。

如果想加一个小的后座,加给MeshRenderer所在的物体,不要加给根节点,否则开枪的时候枪会跑到世界的那个位置。换句话说枪的实体别做根节点。

进入和退出射击状态的动画过渡设置为0。

射击控制代码

人物脚本声明一个bool pullTrigger,玩家按下左键,判断人物准备好射击后(没在跑步、换弹),把pullTrigger=true,把枪动画状态机的参数bool firing=true;

public void FireControlWithAnim(bool triggerPulled){
        switch(autoMode){
            case AutoMode.Full:
            animator.SetBool("firing",triggerPulled);
            break;
            case AutoMode.Semi:
            case AutoMode.Burst:
            animator.SetBool("firing",triggerPulled&&!firingReg);
            firingReg=triggerPulled;
            break;
        }
    }

半自动射击的实现

严格的半自动射击除了在鼠标按下时射击,还需要玩家高频按下鼠标时也不超过枪的全自动射速。

1.使用InputManager系统,使用GetMouseButtonDown()时才把firing设置true。这不能防止玩家高频按下鼠标时半自动射速超过全自动。

2.使用PlayerInput系统,可以定义一个bool fireReg用来记录上一帧有没有按下左键,在执行射击后把fireReg=triggerPulled,射击判断写成triggerPulled&&!fireReg。

3.给枪的Recoil状态回到Idle状态的条件加一个firing flase,Recoil状态的动画不选择Loop time。这样按下鼠标后枪进入Recoil状态发射一次,然后一直卡在Recoil动画最后一帧,松开鼠标后回到Idle。但是这样半自动和全自动的射击需要两个状态了。

三连点射(Burst)实现

先总结三连点射需要处理的所有情况:

1.按一下鼠标,发射三发;

2.一直按着鼠标,发射三发;

3.剩余子弹不到三发,按一下鼠标,发射完;

如果不允许切换为半自动,不考虑3,就可以做一个触发三次Fire()动画事件的AnimationClip,设计会大大简单(参考求生之路的scar)。要考虑3,则可以:

第一次发射后根据是不是Burst选择到Idle还是下一个射击状态,每次发射根据还有没有子弹判断是到下一个发射状态还是Hold Open,最后一次发射到Idle或Hold Open。

总之充分利用动画状态机的逻辑功能,可以使代码简化。

不同枪支持不同自动方式的实现

枪当前的自动方式我是用枚举表示的:

有些枪只有半自动,将来可能还要做3发点射,还需要一个变量记录枪支持哪些自动方式。我试了用多选枚举实现。先定义一个枚举:

再用外挂脚本改成多选:

看起来好像不错:

但是我在预制体里修改自动方式,却记录不下来,退出进去又变成Nothing;在场景里修改,开始运行又变成Nothing。

在外挂脚本OnInspectorGUI()里打印一下,发现是鼠标一进入监视器面板就执行OnInspectorGUI(),但是也不应该改变我设置的值。

在外挂脚本里加一句赋值成Semi,又变成运行时自动变成Semi。

这个方案失败了,我决定用多个bool hasXXX记录这个信息,和多选枚举的本质是一样的,只是把各个位拆出来。

切换自动方式的代码非常麻烦:

switch(gun.autoMode){//希望能简化这一块
            case Weapon.AutoMode.Semi:
            if(gun.hasBurst){
                gun.autoMode=Weapon.AutoMode.Burst;
                gun.animator.SetBool("burst",true);
            }
            else{
                if(gun.hasFullAuto){
                    gun.autoMode=Weapon.AutoMode.Full;
                }
            }
            break;
            case Weapon.AutoMode.Burst:
            if(gun.hasFullAuto){
                gun.autoMode=Weapon.AutoMode.Full;
            }
            else{
                if(gun.hasSemiAuto){
                    gun.autoMode=Weapon.AutoMode.Semi;
                }
            }
            gun.animator.SetBool("burst",false);
            break;
            case Weapon.AutoMode.Full:
            if(gun.hasSemiAuto){
                gun.autoMode=Weapon.AutoMode.Semi;
            }
            else{
                if(gun.hasBurst){
                    gun.autoMode=Weapon.AutoMode.Burst;
                }
            }
            break;
        }

击中不同部位造成不同伤害

要实现击中不同部位造成不同伤害需要由中枪触发器实现。

中枪触发器的形状参数不全需要手调,有很多可以用代码自动设置,如四肢触发器的height可以用子骨骼的localPosition.y,center的y值是height的一半,x和z是0,radius需要手调。躯干可以找Hips的三级子节点Neck和Hips的高度差作为height,radius也是手调,头的radius也是手调。

或者直接用Unity内置的Ragdoll生成器。

这样写射击射线检测的时候需要把人物的碰撞体排除,把身体触发器包括,人物根节点和骨骼节点需要在不同层。

瞄准

瞄准相机配置

在枪上挂一个记录瞄准相机位置的节点。步枪瞄准和手枪瞄准的方案不太一样。

手枪瞄准

手枪的瞄准相机不能做手枪的子物体,因为手枪射击要上跳,瞄准相机做手枪的子物体就和手枪一起上跳。人物的眼睛是不会随手枪上跳的。手枪的瞄准相机需要跟随枪的位置,不跟随枪的旋转。实现这个功能有几种方案:

  • 父物体为空,使用Cinemachine,瞄准相机Follow枪的瞄准节点,LookAt设置目标物

也就是瞄准相机的旋转由目标物决定。还可以加一点damping看起来更真实,但是Composer不要加Vertical Damping,否则上抬的时候相机可能穿到枪身里。

  • 父物体为头,使用Cinemachine,瞄准相机Follow枪的瞄准节点,LookAt不设置

也就是瞄准相机的旋转由头决定。

步枪瞄准

步枪射击时因为托腮,人物眼睛随枪上跳,瞄准相机就可以直接挂在瞄准位置。

其他方案,总之位置可以用Follow、PositionConstraint、ParentConstraint或父子级约束,旋转可以用LookAt、RotationConstraint、ParentConstraint或父子级约束。

瞄准镜

我直接参考了这两篇文章:

【unity小技巧】使用三种方式实现瞄准瞄具放大变焦效果_unity放大镜效果-优快云博客

【unity小技巧】实现FPS武器的瞄准放大效果(UGUI实现反向遮罩,全屏遮挡,局部镂空效果)_unity 开镜-优快云博客

第二篇文章的效果:挺不错,能满足要求。

第一篇文章方案三,相机输出到贴图,贴图再应用到瞄准镜后端,实现局部放大的效果:

材质配置:Base Map颜色选黑,贴图给Emission Map,颜色选白色。

效果:

可惜我的人物头发和瞄准镜后端穿模了,没法用。

子弹起点和方向

众所周知,第三人称相机和枪离得稍远,如果子弹从枪口发出,沿枪z轴飞行,如果相机和枪z轴平行,弹着点只能在无穷远处和屏幕中心一致,弹着点近时都不能命中屏幕中心。虽然这是最真实的设计,用户体验却不好。

总结起来弹道和第三人称相机的关系有这么3种设计:

  1. 子弹从枪口发出,方向和相机z轴平行;
  2. 子弹从枪口发出,朝相机中心落点飞行;
  3. 子弹从相机发出,沿相机z轴飞行;

如果硬要用1这种真实设计,应该提供机瞄视角,并提醒用户使用机瞄。如果没有机瞄,使用越肩视角瞄准,就必须使用2或3.

然后我去看了一下和平精英的设计,发现如下几点:

  1. 第三人称,没有障碍物时弹着点在屏幕中心(说明弹道和相机z轴不平行);
  2. 有障碍物时可能挡住子弹,无法击中屏幕中心(说明子弹不是从相机发出,而是从枪口发出);
  3. 瞄准近处时第三人称和机瞄屏幕中心的落点不一致,但子弹总能击中屏幕中心;

可以得出结论和平精英的弹道是方案2,从枪口发出去找屏幕中心落点。

射击命中检测

有射线检测方案和发射弹头实体的方案。

射线检测方案

比较简单,弹道只能是直线,没有弹头火光效果。

 void FireRayCast(){
        RaycastHit _hit;
        if(Physics.Raycast(bulletOrigin,bulletVector,out _hit,fireRange,fireLayerMask)){//击中点效果
        if(debugger){
            debugger.position=_hit.point;
        }
            // Debug.Log($"打中了{_hit.transform}");Debug.Log($"碰撞体{_hit.collider}");
            if(_hit.collider.TryGetComponent(out bodyPart)){//打到人
                bodyPart.GetHurt(damageData);
            }
            else{//打到东西,播放粒子效果
                ImpactEffectRecorder myImpactEffect;
                if(_hit.transform.TryGetComponent(out myImpactEffect)){
                    GameObject effectInstance;
                    effectInstance=Depool(myImpactEffect.impactEffectPrefab.gameObject,_hit.point);//缓冲池出池
                    effectInstance.transform.LookAt(_hit.point + _hit.normal);
                    // Destroy(shotObj,5f);
                    // Invoke("Enpool",5f);
                }
                else{
                    Debug.Log(_hit.transform.name+"没有击中效果!");
                }
            }
        }
    }

发射弹头实体方案

能模拟抛物线弹道,有子弹火光。但是击中检测不能用碰撞检测,因为子弹每帧飞过几米到几十米,大概率穿过物体。只能用一个Vector3记录上一帧的位置,使用Physics.Linecast()做连线检测。这样击中检测的轨迹其实是拟合抛物线的折线。但是勉强能用了。

发射代码:

[Header("发射弹头实体需要的信息")]
    public MyBullet bulletPrefab;
    public int bulletSpeed;
    void FireBulletGameObject(){
        if(!bulletPrefab){
            Debug.Log("没有弹头预制体!");
            return;
        }
        GameObject bullet=Instantiate(bulletPrefab.gameObject);
        if(muzzleEffects.Length>0){
            bullet.transform.position=muzzleEffects[0].transform.position;
        }
        else{
            bullet.transform.position=transform.position;
        }
        bullet.transform.rotation=transform.rotation;
        if(bulletSpeed==0){
            bulletSpeed=700;
        }
        bullet.GetComponent<Rigidbody>().velocity=transform.forward*bulletSpeed;
        bullet.GetComponent<MyBullet>().damageData=damageData;
        Destroy(bullet,1);
    }

弹头脚本:

public class MyBullet : MonoBehaviour
{
    public LayerMask bulletLayerMask;
    int groundLayer=8;
    public Weapon.DamageData damageData;
    Vector3 lastFramePosition;
    void Start(){
        lastFramePosition = transform.position;
    }
    RaycastHit raycastHit;
    BodyTrigger bodyTrigger;
    ImpactEffectRecorder myImpactEffect;
    void Update(){
        if(Physics.Linecast(lastFramePosition,transform.position,out raycastHit,bulletLayerMask,QueryTriggerInteraction.UseGlobal)){
            if(raycastHit.collider.gameObject.layer==groundLayer){
                if(raycastHit.transform.TryGetComponent(out myImpactEffect)){
                    GameObject effectInstance;
                    effectInstance=MyGameManager.Instance.Depool(myImpactEffect.impactEffectPrefab.gameObject,raycastHit.point);//缓冲池出池
                    effectInstance.transform.LookAt(raycastHit.point+raycastHit.normal);
                }
                else{
                    Debug.Log(raycastHit.transform.name+"没有击中效果!");
                }
            }
            else{
                if(raycastHit.collider.TryGetComponent(out bodyTrigger)){
                    bodyTrigger.GetHurt(damageData);
                }
            }
            Destroy(gameObject);
        }
    }
}

击中反馈

击中敌人时准星周围显示一小段时间的X提示击中。

做一个X图标的预制体,每次击中敌人时执行一次显示。显示时实例化一个X,一小段时间后消失。这样每次击中的X的消失时间互不影响。如果只用一个X显示隐藏,用协程隐藏X还要先停止之前击中隐藏X的协程。连续击中时很多个X图标叠在一起,但是不影响效果。

float hitHintLife=.2f;
    public void ShowHitHint(){//显示击中反馈
        GameObject hitHint=Instantiate(hitHintPrefab.gameObject,crosshair.transform);
        hitHint.transform.localPosition = Vector3.zero;
        Destroy(hitHint,hitHintLife);
    }

动画系统

射击游戏动作系统的特点:

  • 很难不用分层和AvatarMask;
  • 有些动作需要生动性(跑步、跳),有些动作需要精确性(主要是瞄准);
  • 人和随身物品的交互关系较复杂,武器在不同的动作跟随不同节点,有些动作受多个节点影响;

移动时保持上半身稳定的问题

我想让人物移动的同时人物端枪瞄准前方。所以我加了一个Arm层,移动放在Base层,希望走路时上半身稳定。

 AvatarMask覆盖上半身

问题:如果走路动画Hips的旋转是摇晃的,那么AvatarMask覆盖的上半身也会跟着摇晃。

AvatarMask覆盖双臂

问题:走路时Chest朝向和静止时不一样,导致走路时双臂的方向歪。

静止时Chest的朝向:

前进时Chest的朝向:

效果:

AvatarMask覆盖双臂和根节点

又会导致腿走路的方向歪。

解决方法

给Spine加Rotation Constraint,由一个指向瞄准方向的物体约束它。先预览人物静止端枪的动画,再点Rotation Constraint的Is activated,组件会计算出当前Spine相对约束物体的旋转偏移,再Lock。

但是想在开始跑步时腰从约束状态平滑过渡到动画状态,这个约束平滑把权重降到0,腰也没有播放动画,而是局部旋转不变。AnimationRigging的Multi Rotation Constraint可以通过权重平滑变化到0平滑过渡到动画状态。这是AnimationRigging约束很重要的一个优势:

射击上跳动画

本来想在Arm层加一个从持枪空闲Pistol Stand到射击的状态。但是遇到了问题:Pistol Stand状态脚本的OnStateExit()我写了解除瞄准,因为离开Pistol Stand进入的所有状态都不能瞄准。然后射击时就会解除瞄准。

只能另开一层Hand放射击上跳动画。另外这里站和趴都有上跳动画,如果Hand层是Override,那么站的上跳动画就不能用于趴,但是改成Addictive,这个上跳动画可以同时用于站和趴。

物品拾取和扔掉功能

扔掉、捡起物品需要做的可以粗分为两部分:

1.对物体的操作(改变父级、设置位置旋转);

2.播放人物动作;

二者的调用关系可以是:

1.在一个方法里执行物体操作;

2.方法里设置状态机参数,在动画事件里执行物体操作;

动画状态机的问题

动画状态机里我只想用一个整数gunStatus表示手的状态(0空手、1拿步枪、2拿手枪),但是这样出现了一些含糊不清的情况:

gunStatus从1到0可能是扔掉步枪和收起步枪都需要从1到0,为了播放正确的动画,必须

1.加另外一个参数区分两种转换

2.扔枪也要把gunStatus设0,但要在进入扔枪状态后,防止进入背枪状态

给扔枪加了Trigger PutDown,捡枪加了Trigger PickUp,捡枪时先设置PickUp,捡起的动画调用动画事件,在里面把手的状态设置为1或2。捡枪后手应该进入1还是2状态也由动画事件的方法根据枪的类型判断。

一个功能要播放动画且执行一些代码,这些代码可放在3个地方:

1.和播放动画的代码写在一起,在动画开始时执行;

2.放在状态脚本的OnStateEnter()或OnStateExit();

3.使用动画事件。动画事件可以精确控制方法执行的时机,但是不能传参数,需要在脚本里加字段。

持枪跑步使用AvatarMask把上半身和下半身合成的动画,刚结束跑步后立即再进入跑步,上半身会剧烈晃动

鉴定为上下半身的状态转换时间不同,如果有一层还没有回到静止,而另一层已经回到静止,那么再开始跑步两层开始播放跑步动画的时机不同,晃动的节奏不一致。所以跑步动画不要用AvatarMask。AvatarMask只用于需要上下半身同时做不同的动作,如换弹和移动。

斜身功能

本来想做左右斜身的动画,发现用Avatar做这两个动画极难。

使用了animator.SetBoneLocalRotation()实现,和俯仰写在一个方法里,因为一帧里好像只有最后一次animator.SetBoneLocalRotation()是最终效果,所以把改变Spine的仰角和左右倾斜写到一个Quaternion,加给Spine的旋转。

使用Mathf.Lerp()加了倾斜角度渐变。

在持枪的状态机行为脚本里调用。

不同枪的换弹动画

长枪的换弹动画可以分为几类:

右边拉栓(AK系、Mini14、SKS等)、左边拉栓(MP5、G3、FAL等)、按按钮(AR系、SCAR等)、旋转后拉(栓动步枪)、左手撸滑套(霰弹枪)。

站、蹲、趴时碰撞体尺寸设置

站、蹲、趴状态人物的碰撞体尺寸不同,需要

武器信息界面的维护

武器信息栏包含武器名、自动模式、子弹数、弹匣数。

哪些情况需要更新武器信息栏:

1.拿出枪;

2.收起枪(隐藏);

3.交换枪;

4.放下枪;

5.射击;

6.换弹;

7.拿起手里枪的弹匣;

8.放下手里枪的弹匣;

9.改变自动模式;

除了把这一坨信息可能改变的情况都找出来加代码,有没有可能把这些信息做成属性,在Set方法里顺便改ui?首先这些信息都是谁的字段?使用的武器是人物的,自动方式和枪内子弹数是枪的,弹匣数又来自背包……然后人物并没有一个字段记录当前用的枪是xxx,而是记录当前携带的主枪是xxx,手枪是yyy,当前使用的枪通过动画参数可以获得。再加一个“正在使用的枪”字段就要保持它和人物动画正在使用的枪一致。

枪的自动方式和子弹数是字段,但是枪不知道自己是不是玩家手里的,还要访问主人判断是不是玩家。

背包里当前枪的弹匣数没有字段。

武器信息界面的维护还是没有一个简单的方法。使用的武器的变化发生在人物对象,枪的子弹数和自动方式变化发生在枪对象,弹匣数变化发生在背包对象。这些来源分散的事件都会导致界面变化。而且变化时还要判断是不是玩家的而不是npc的变化。人物对象可以写一个玩家子类,重写方法,但是枪是不可能给玩家的枪写一个特殊子类的。

人物死亡

人物在动画状态机的任何状态都可能死亡,不可能给每个状态加一个到死亡状态的转换。有几个办法解决。

Any State

注意Any State到Dead的转换不要勾选转换到自己。

animator.Play()

播放一个状态的名字,不管有没有转移,直接跳到那个状态。

AnimatorController加一层Dead

覆盖在所有层上,只有两个状态,Alive状态播放no Motion,Dead状态播放死亡。

NPC检测其他人

通过检测目标人物在不在自己前面的扇区判断有没有看到其他人。问题是怎么知道要检测的目标人物有哪些?FindObjects找到所有人物脚本实例?很明显开销太大。如果有扇区触发器碰撞体,是最合适的,但是没有。只能退而求其次,先用球形触发器碰撞体,把附近的人物加到一个列表,再做扇区检测。

脚步声

脚步声可以使用动画事件播放,或者速度大于某个值时用协程定时播放。

给每个移动动画加动画事件

需要加动画事件的有站走路、蹲走路,每种状态有6到8个方向的移动动画,拿各种武器的跑步,每个有前、左前、右前3种跑步,需要加事件的动画巨多。有什么办法简化吗?

问题

发现使用二维混合树移动时,停止移动后也在播放脚步声,可能是速度没有完全归0,混有一点移动动画。

那么就在播放脚步声的函数里再判断一下速度,大于某个值才播放。

专门做两个脚步声动画

如果所有走路状态的速度一样,所有跑步状态的速度一样,只做为走路、跑步各做一个播放脚步声的动画也行。在状态机加一层脚步声层,里面放一个二维混合树对应走路,二维混合树八个方向放同一个走路频率的脚步声动画,一个状态对应跑步,放跑步频率的脚步声动画。

协程定时播放

应该可以做,没做过。

不用累死累活加动画事件了,但是播放脚步声和脚落地的时机可能对不上。

随机放置敌人位置及遇到的困难

测试中每次敌人都在相同的位置,次数多了及其无聊。就想让敌人的位置在一个区域内随机,又不能穿模在建筑里。使用do while循环随机得到一个位置,使用Physics.CheckSphere()和Physics.OverlapSphere()判断此处有没有建筑,结果完全在建筑内部也得到没有碰撞体。

第一人称和第三人称射击游戏区别的一些总结

第一人称射击游戏一般不显示身体,这大大简化了系统的设计,比如:

1.改变瞄准仰角可以直接改变相机的仰角,双手是相机的子物体,无需改变人物腰部的旋转,而人物持枪状态腰部的俯仰是个极复杂的旋转;

2.走路是单纯的平移,没有双腿动画,移动换弹就不用把双手换弹动画叠加到身体层,也就无需用AvatarMask,甚至AnimatorController不用分身体和手臂层;

相比起来第三人称射击游戏要表现人物全身的动作,要复杂得多,但也有简单的地方:

1.人物动作要求不那么精确,换弹动作粗糙一点,因为有身体挡住,也看不出来;

要做兼顾第一第三人称的射击游戏,是难上加难。吃鸡就是这样,比如

1.跑步第一人称和第三人称用的是两个动画,第三人称手臂更靠下,摆动更大,第一人称手臂在相机视野内,摆动更小;

从工作量的排序看:

第一人称、单机、打僵尸(不需要看到枪手的身体,不用做全身动画)<第三人称(要做全身动画)<第一人称枪战或多人游戏(第一人称和第三人称都要做,第一人称精细的双手动画+第三人称全身和粗糙的双手动画)

这是第一人称的跑步:

同一个姿势第三人称是这样的:

2.跑步时第一人称相机挂在头上,又不能随头晃动,需要加某种约束;

游戏规则设计

规则设计的两个关键问题:1.怎么赢;2.死了怎么办。

第一个问题的方案:1.消灭指定敌人(cs歼灭模式等);2.占领所有目标点(战地、叛乱沙漠风暴Checkpoint);3.摧毁指定物品(cs爆破模式);4.到达某位置(绝地求生、逃离塔科夫)。

第二个问题的方案:1.读档(使命召唤、武装突袭等,只适用于单机游戏);2.局内指定地点重生(战地、叛乱沙漠风暴等);3.下一局重生(cs、绝地求生、逃离塔科夫等)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值