Unity射击游戏笔记

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

如何判断FPS的枪摆正了?

有一个充要条件:

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

4c0f79325af65fdb1df68c90e960ddd6.png

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

1526af50f3e0600b93a0bc39990f3a75.png

相机

第三人称相机

改变仰角

方案1:使用目标物

人物建立一个TargetAxis,TargetAxis前方很远处建一个target,相机Follow人物根节点,LookAt target,鼠标改变TargetAxis的仰角。

这个方案需要多建两个物体,切换控制的人物需要把这两个物体迁移或新建。

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

方案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约束到人物根节点;

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

feab4327876a252474603272915b80c3.png

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

d437ad8739363fcfa8f08c0e73b3fd84.png

11c1c4ca38c0a671c505fdec2d2e8b2b.png

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

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

453b472c624350d963d1958e44f0095b.png

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

射击

射速控制

动画和代码的比较

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

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

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

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

代码方案

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

31f77090e3a14b71bf645475d5fde042.png

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

动画方案

枪上挂Animator。

枪的动画状态机

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

41f999e30a00425cb87ec383df6d207b.png2fe7a03dba4540aeb98d0323697ef26a.png

枪的射击动画

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

20642de7e99c4fca9c67df59819467a8.png

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

进入和退出射击状态的动画过渡要特别注意设置短,接近突变。

89419c3e3a1c460a974195a3df0a4ad7.png

射击控制代码

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

09fa8e87ec4c4b4cae4666c99ec5e470.png

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.如果使用旧输入系统,使用GetMouseButtonDown(),全自动使用GetMouseButton()

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

这样写有一个特性,就是如果玩家按鼠标的速度比枪的射速还快,在射击冷却前松开再按下鼠标,也不会射击。之所以不叫bug,因为如果玩家手速快过了全自动的射速,就允许枪的射速超过全自动射速,这是不合理的。第一种方法就是这样,只要手速够快,半自动可以比全自动还快。

三连点射(Burst)实现

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

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

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

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

如果不考虑3,就可以做一个触发三次Fire()动画事件的AnimationClip。要考虑3,则可以:

6f49ad75eea7401ca33ce6ae9b369367.png

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

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

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

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

720d213b63364ceea9fb3f5b6aa0b98d.png

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

2214a4891cd24275a933710a00cc399e.png

再用外挂脚本改成多选:

2cd48115ce8a4607a8194a972493fd60.pngaad9fe2ed9f2491fa37d825cde1673a0.png

看起来好像不错:

3212a6000ad34beb90821bc4bfdfc30a.png

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

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

27a4902426964bfdbb06f3876986d9cd.png

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

30d9a2cd27604285bade5ebc7a9aa206.png

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

e11c7ca53ac049dda572bf7404ba139f.png5ad059a80f6843789cb3a94fa0da5f77.png

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

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;
        }

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

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

b18cae637e114b9bb86c02965ddb65d1.png

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

e0cb33e957a247418538013b6d82a7c0.png

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

e8fc0141ef564c158ee0d8226741d856.png2d1ab609ca3c4705a00374445329fcff.png

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

瞄准

瞄准相机配置

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

手枪瞄准

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

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

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

39a25ed163f74ca2a5441941bb1e1f62.png

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

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

步枪瞄准

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

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

瞄准镜

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

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

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

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

65677fa69bcb401e9e404edce6620856.png

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

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

0a6c67c2c1fc4606b519e19cc982c067.png

效果:

1007f67a08e04c71a6e3eced52c8ab28.png

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

1679ea132ac8409e8897cef0da1d456d.png

射击命中检测

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

射线检测方案

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

 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);
        }
    }
}

动画状态机的设计

关于Layers和Avatar Mask

首先,如果想实现诸如边走路边换弹、边走路边换枪,又不想做朝八个方向的边走路边换弹的动画,Layers和Avatar Mask是肯定要用的。但是有些状态要双臂和躯干叠加,有些状态又是全身动作,导致身体层和双臂层出现了很多同名的状态,状态机的状态几乎增加了一倍。

把玩家所有的操作分成躯干-下半身动作、纯双臂动作、全身动作。

躯干-下半身动作:站立及其移动、蹲着及其移动

纯双臂动作:换弹、收枪、拔枪

全身动作:跑步、趴着及其移动(爬行)、趴下、站起、攀爬、捡东西。(爬行是全身动作,无法边爬行边换弹,就把趴着的所有动作定义为全身动作。)全身动作里又有可以叠加的,如跑步是同一个躯干&腿叠加不同的双臂,也有单纯一个动作的如趴下、攀爬。

跳跃可以是躯干&腿动作也可以是全身动作。如果定义为全身动作,且由Trigger触发,就会出现一个Trigger需要触发两层转换的情况,很容易出问题。

躯干-下半身动作只在Trunk&LowerBody层有状态;

纯双臂动作只在arm层有状态;前两类动作可以任意组合。

全身动作的几种方案:

方案1:放在躯干&腿层,做全身动作时双臂层为None(motion)

这样有难以解决的问题:None状态同时代表趴着状态和站着无枪状态,从None到步枪站立无法知道是趴着站起来还是站着取枪,然后趴着站起来时同时做了取枪动作。看来Arm层状态合并太狠会出问题。

801e3ef0f429404b9a4b9996c8fac1d7.png

方案2:躯干&腿层和双臂层都放,状态同名

一个全身动作在身体层和双臂层都有状态,状态多,比较乱。已经放弃。

305af38243be4a22b02dea3ca052baba.pnge8bea2824d954ab8843ab84a2c54ac1f.png

方案3:在这两层上面单独建一层WholeBody

5c5c2e443d8a474b99f4b70331116cb3.png

84702f1e6c494323bfe65aab5b4ff0c8.png

Trunk&LowerBody层只考虑人物的躯干和腿有什么状态。需要的状态有noGunStanding(身体向正前方站立)、gunStanding(身体向右偏45度站立)、crouching(拿枪和不拿枪共用一个躯干&下半身姿势)、NoGun Jump、Gun Jump(允许玩家边跳边开枪、换弹);

后来我又把跳跃改成全身动作了。因为边跑边跳跃时WholeBody层进入一下None状态又到跑步状态,出现在空中跑步的情况,然后我把全身层进入跑步的条件加了一个onGround,不会在跳跃中跑步了,又遇到了新问题:跳跃是靠触发器进入的,允许边跳跃边瞄准射击意味着一个触发器同时触发两层的转换,如果稍有不同步就只能触发一层,出现异常状态。最终决定只在全身层做一个跳跃状态。

bf73c23e60c2455caa7569da90b772a3.png

arm层只考虑人物双臂有什么状态。有noGunIdle、rifleIdle、rifleReload、putAwayRifle、takeOutRifle、rifleRunning;

8eab59acbabe40e0be9637bfe9a968dd.png

WholeBody层有所有用到全身的状态。和一个不覆盖下层的None(motion)。有running、prone、goProne、getUp、climbing;

动画状态机的一个简化技巧:动作状态到空闲状态无需设置到所有空闲状态的转换,只需转换到其中一个,然后靠空闲状态中间的转换。但是空闲状态到动作状态是所有空闲状态都要设置转换。例如蹲着Crouching到捡东西PickUp,捡完后无需设置PickUp到Crouching的转换,只需有PickUp到Standing的转换,再通过控制身体高度的参数从Standing转换到Crouching。

射击上跳动画

本来想在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状态也由动画事件的方法根据枪的类型判断。

捡枪扔枪换枪功能

需要处理的情景/需要做的测试:

1.手里没枪,身上也没步枪时捡步枪,手枪也有类似情景

2.手里没枪,身上有步枪时换步枪

3.手里有步枪时换步枪

4.手里有步枪时捡手枪

5.

6.手里没枪,身上有步枪时扔步枪

一种逻辑简单的设计

1.步枪放到背上

2.背上的步枪放到地上,地上的步枪放到背上

3.手里的步枪放到地上,地上的步枪放到背上

4.手枪放到身上,如果身上有手枪则放到地上

6.不允许,只能扔手里的枪

这套方案保证了枪进入手里只有一种途径,就是从身上的挂载点拿;会有枪在身上和地上的位置突变。(后来发现,情况2会出现gunStatus是1但是步枪在背上的情况,必须用下面的折中设计。)

一种真实但逻辑复杂的设计

1.步枪放到手里

2.先把背上的步枪拿到手里,再手里的步枪放地上,地上的步枪放手里

3.手里的步枪放地上,地上的步枪放手里

4.把步枪收起来,把手枪放到手里,使用手枪或者换回步枪

6.不允许

我本来想做第二套方案。这套方案的情景2、4都会玩家按一个按键触发两个动作,先gunStatus=0,进入空手状态,设置PickUp,进入拾取状态后通过动画事件gunStatus=1。结果Arm层有收枪的过渡状态,Body层没有,就收到PickUp进入拾取状态,然后Arm层根据gunStatus=1做了取枪动作,原本Arm层应该也收到PickUp进入拾取状态,然后直接进入拿步枪状态的。这涉及到一个Trigger能否同时触发两层的转换,以及两层准备转换的时机不一致,一层先消耗了Trigger的情况。到这里情况的复杂有点超出我的能力,我决定做第一种方案。

复盘方案二,问题就在为了模拟真实强制

人物空手状态才能拿枪;又为了用户体验,选择一键执行两步动作,导致两层出现不同步。一二两种方案折中,又有了第三种方案

折中的设计

1.步枪放到手里

2.再手里的步枪放地上,地上的步枪放背上

3.手里的步枪放地上,地上的步枪放手里

4.把地上的手枪放到腿上

6.不允许

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

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

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

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

动作条件的判断/动作系统非法状态的避免

以射击条件为例,不能射击的情况很多,包括人物动作没准备好(换弹、跑步、手里没枪、爬行)、枪没准备好(枪里没子弹、射击冷却没结束)。

两种思路

一个是给人物写一个方法ReadyToFire(),玩家按下射击时执行,里面检查所有可能影响射击的条件,包括没有在换弹&&没有在跑步&&手里有枪&&没有在爬行&&枪里有子弹。没有在跑步可以用Animator参数isRunning判断,换弹是触发器触发的,我只能使用animator.GetCurrentAnimatorStateInfo(1).IsName("Reload")靠名字判断,到了没有在爬行,爬行和趴着空闲的动画在一个混合树里,属于一个状态,趴着空闲时可以射击,使用状态名字判断也不行了。然后可以通过判断Animator的VerticalSpeed、HorizontalSpeed判断……

一个是给人物维护一个变量public bool readyToFire,玩家按下射击时把这个变量加入判断条件,其他地方的代码设置这个变量,可以在1.人物脚本;2.状态机脚本;3.动画事件。这样直接在站、蹲的瞄准状态的状态脚本的OnStateEnter()把这个置true,OnStateExit()里置false,前面的一大堆判断都不用做了,特别是判断状态名字的一长串。趴着的状态还是需要根据VerticalSpeed、HorizontalSpeed判断,写在状态脚本的OnStateUpdate()里。

a584d029ab732d8d865d584b20688c12.png

同理,其他动作都可以维护一个这样的变量:

d2b56f963c44771d5f8ba88e65e3ddf7.png

然后判断是否能瞄准的代码从这样:

f793027d193ef3d8c1edb3a088c47c1f.png

变成了这样:

0ae00625cf3dc31cb3a78dc927e4fc33.png83309f18fb735fec398d9266faf520e4.png

为什么维护变量比执行判断方法简单了这么多?因为它利用了动画状态机本身的逻辑,换弹、跑步两个状态本身的互斥的。后来我发现,判断射击直接放在射击状态机行为脚本的OnStateUpdate()就行了,连readyToXXX变量都省了。同理,判断能不能换弹根本不用readyToReload,只在持枪状态机行为脚本里的OnStateUpdate()监听换弹输入就行了。

并非所有不能同时做的动作都要在相应的状态脚本里把另一个动作的readyToXXX置false,有一些因为状态之间没有转换,天然不会同时进行,比如在Rifle Run状态因为没有转换,不可能到Reload状态,但是这个图是Arm层,跑步在Body层也有状态,Reload没有,换弹过程中跑步会导致Body层进入跑步状态,这时候就要在Reload状态脚本里限制一下。具体哪些地方要加限制只能一个个试。我又试了一下,跑步中按换弹,虽然没有播放换弹动画,但是播放了换弹声音,子弹数更新了,因为状态之间没有转换只阻止了播放动画,我的Reload()方法是输入触发的。还是要加限制;要么就改成动画事件触发。

d856b28db8cf18169f75d4abcd900917.png

动作条件判断的测试

射击游戏动作系统都可以做这几个测试判断系统的严谨性:

1.跑步时按换弹,是否不换弹或结束跑步后换弹;

2.换弹时按跑步,是否不会跑步;

3.瞄准状态按换弹、跑步,是否会解除瞄准;

4.换弹、跑步状态按瞄准,是否不会瞄准;

以上测试可以总结为跑步、换弹、瞄准3个动作的互斥测试。

7.趴下、站起的过程中按换弹、跑步、机瞄,是否不会做相应动作或当前动作完成后再做;

8.瞄准状态按爬行,是否会解除瞄准

9.瞄准状态扔掉手里的枪、交换枪是否会解除瞄准

10.瞄准状态把枪收起来或者换成手枪是否会解除瞄准

92bcb227b9cba134f0a96d3bdf567e16.png

写了一大堆,突然发现上面一大堆条件就是离开瞄准状态的各种途径,只需要在站、蹲瞄准状态脚本的OnStateExit()、趴瞄准状态脚本的OnStateUpdate()(水平、垂直速度比较大时)、OnStateExit()执行关闭瞄准方法即可,无需在各个输入的时候写。但是有一个例外,手枪射击的时候的上跳我是用人物的手腕上跳做的,这时候就不应该解除瞄准了。

5241ae395b7115677ed88088652f275f.png

为了解决这个问题我只好把人物手腕上跳状态删掉,单独给手枪做一个上跳,不属于人物动画状态机,射击的时候手腕不动,效果僵硬了点,但是先这样吧。

68420451de829024070782fc08ba0939.png

d5e5ddaa95e6f8d82a2179940193add1.pngb363ab94c36fba03e1a734a10b7149c0.png

对叛乱:沙漠风暴做了上面的测试,结果是

1.没换弹,跑步结束也不换

2.换弹动作被打断,进入跑步,弹匣没插,此时开枪没子弹或者只有一发,此时按换弹只插弹匣。这个处理牛逼。

bd57742116a33866cd354b8c6c01e50f.jpeg

3.会

4.换弹时不会瞄准,会打断跑步进入瞄准

7.按换弹被直接忽略,瞄准会等趴下后进入,按跑步会再站起来开始跑步

斜身功能

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

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

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

47aca5735ec4460aa8123d3ffb204b59.png

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

5a1d68e2410b411ea8784171b16a1e31.png

跳跃和地面检测

物理效果

如果角色挂有RigidBody,可以通过rigidBody.velocity=jumpSpeed*Vector3.up实现。如果角色使用Animator的Root Motion移动,可以通过

3b3140c5ea5e08a9e6b6543fa43935b5.png

实现移动中跳跃,跳跃中保持起跳的速度。

动画效果

简单的跳跃只需要滞空动画。如果想做得更逼真,可以添加起跳和落地动画。我这里做滞空和落地动画。落地动画因为本身属于过渡动画,为了避免自动过渡出现不想要的效果,应该把滞空到落地、落地到站立的过渡设置尽量短。

9f62a74fafb14e60996a7c3e4fa480a1.png8bba5e4170f3405f8b4fcd4e755db386.png

程序判定

满足玩家输入跳跃&&人物在地面上就跳跃。定义一个bool onGround记录是否在地上,可以在脚本里或动画参数。

地面检测

碰撞检测方案

判定离地的代码写在OnCollisionExit(),在地面上的代码写在了OnCollisionStay(),不能写在OnCollisionEnter(),因为人物如果先接触另一块地面,再离开这一块地面,OnCollisionExit()的效果覆盖了OnCollisionEnter(),人物被判断为离地。如果写在OnCollisionStay(),离开一块地面时OnCollisionExit()执行一次,而OnCollisionStay()一直执行,人物还被判断为在地面上。坠落伤害的代码则可以放OnCollisionEnter()。

但是这样写还有严重的问题:下坡的时候先靠animator水平移动,再靠重力下落贴紧斜面,onGround会不停变成false导致不能跳跃。

22e8671a703244f1b7dc075aa9086f05.png

补救方法:在人物碰撞体下端套一个稍大的触发器碰撞体,这个碰撞体离地时才判定离地。地面检测触发器为了和碰撞体区分开,只能用球形碰撞体,要么就挂在其他节点上。这样让做一个人物的流程更复杂了。

7f7071d6c0184e40a1969371aa592e3d.png

距离检测方案

速度检测方案

问题记录

跑步中跳跃有一定概率出现在空中跑步,看状态机显示进入跳跃状态后立刻退出进入了跑步状态。跳跃状态的退出条件是onGround==true,也就是说进入跳跃状态后立刻满足了退出的条件。

2a7d31be8f5a384719e001c76c151f10.png

解决方法:稍微增加了跑步到跳跃状态过渡的时长。

bc04d63c7a9f2d713990d670f1a0d2b4.png

枪要不要加刚体碰撞体?

枪的碰撞体用来做捡枪的检测,肯定要加,这里讨论的是非触发器碰撞体。

加了,扔枪的效果就更真实,但一个问题就是人物趴着的时候一低头枪会把人顶起来:

a20d6a4fd135cf4ba50c83ac3a9531d4.jpeg

用代码关闭碰撞体好像不错,但不难预测又可能出现趴着扔枪前枪已经和地面穿模,开启碰撞体也不能阻止枪穿到地下。要解决这个问题就要防止枪和地面穿模,可以增大趴着的最小仰角。但是如果人物趴在斜坡上这个最小仰角又不合适了。

总之给枪加碰撞体和刚体增加了一点物理效果,但是增加了游戏系统的不可控性。

武器信息界面的维护

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

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

1.拿出枪;

2.收起枪(隐藏);

3.交换枪;

4.放下枪;

5.射击;

6.换弹;

7.拿起手里枪的弹匣;

8.放下手里枪的弹匣;

9.改变自动模式;

背包系统

功能需求

  1. 查看玩家拥有的所有物品,可以放下其中的任意物品;(必须功能)
  2. 查看已死的他人背包的物品,可以拿起其中的任意物品;(必须功能)
  3. 查看附近的物品,可以拿起其中的任意物品(暂时不考虑背包容量);(可选功能)

3是可选功能,因为附近物品玩家也可以看着按F捡起,只是如果也能从背包界面捡起体验会更好。

2的实现有两种方案:1.检测附近区域的死人的背包,显示在附近物品栏(吃鸡的方案);2.看着死人,按F查看他的背包(从异变战区2学的方案)

背包系统面临的一个问题是玩家拿起一个物品放进背包时需要把该物品Destroy(),而该物品的脚本是继承MonoBehavior的,继承MonoBehavior的脚本必须挂载在物体上,物体被摧毁后该脚本实例也不存在,意味着把物品放进背包时需要把该物品脚本的信息转换成非继承Monobehaviour的形式(比如struct)转移到背包脚本里。

而从背包放下物品时需要从背包数据里得到物品的预制体引用,然后实例化。之前我定义的背包里一个弹匣的结构体是:

[Serializable]
    public struct MagInPack{
        public AllWeaponInfo.MagTypes gunModel;
        public int ammoNum;
        public MagInPack(AllWeaponInfo.MagTypes owningGun, int ammoNum){
            gunModel=owningGun;
            this.ammoNum=ammoNum;
        }
    }

只有一个弹匣类型枚举作键,再加一个整数记录子弹数。为了获得这个弹匣的预制体,需要去一个弹匣信息的总表里查这种弹匣的预制体引用。这里总表记录的是枪枚举和枪的预制体,还要去枪的预制体脚本里得到弹匣预制体的引用。极为麻烦。

GameObject magPrefab=AllWeaponInfo.Instance.magInfo[Convert.ToInt32(magType)].gunPrefab.weaponData.magPrefab.gameObject;

所以我决定背包里的弹匣结构体记录一个弹匣预制体引用,免去查总表。然后检查器变成这样,添加弹匣时要拖一下预制体,而且这个枚举看起来多余了,拿预制体当键就可以。

e32d538183244ea3bd68323889e582ea.png

然后我发现拾取场景里的弹匣时我只知道这个实体弹匣,这个弹匣放进背包时这个物体就销毁了,背包里记录的应该是弹匣的预制体,也就是场景里的弹匣实体都要记录自己的预制体。

然后我发现让一个预制体的脚本里一个引用指向预制体它自己,预制体实例化后这个引用指向的也是场景里的实例它自己了。是不能让一个预制体脚本里的引用指向它自己来记录预制体的。

a413e75dc9144c1fa85825e82adff209.gif

不过背包里的弹匣数据保存有它的预制体,放下弹匣的时候把预制体引用传给实例的prefab引用就可以了(下图Drop()第5行);拿起弹匣的时候则从实例的prefab传给背包里的结构体。

f3fe4a646cb945e08215959c158e4048.png

附近物品栏和背包物品栏一项物品的脚本代码。主要是捡起和放下两个回调方法。捡起的方法极其繁琐,因为需要处理很多不同情况:

1.拿起地上的弹匣放进背包里,需要销毁场景里的物体;

2.拿起别人背包里的弹匣放进背包里,需要别人的背包数据移除这个物品的数据;

3.拿起手枪,自己有手枪,则交换手枪,更新手枪界面;

4.拿起手枪,自己没有手枪,则拿起手枪,更新手枪界面,添加按手枪界面放下手枪的回调;

5.拿起步枪,自己有步枪,则交换步枪,更新步枪界面;

6.拿起步枪,自己没有步枪,则拿起步枪,更新步枪界面,添加按步枪界面放下步枪的回调;

而按F拿起物品不存在2的情况。不管按F捡起还是背包界面捡起,都有一个判断是枪还是弹匣、是枪是捡起还是交换的过程。问题是这个过程放在哪里?

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

public class ItemUI : MonoBehaviour
{
    public Backpack playerPack,otherPack;
    public Item itemInstance;//对于周围的物体,指向场景里的物体实例;对于背包里的物体,为空
    public PackPanel packPanel;
    public int indexInOtherPack=-1;
    public void Take(){
        if(itemInstance is Mag){//拿弹匣
            Mag mag=itemInstance as Mag;
            if(itemInstance.gameObject.scene.name!=null){//物品在场景里(不是别人背包里的)
            }
            else{
                otherPack.magsInPack.RemoveAt(indexInOtherPack);//别人背包里的此物品-1
            }
            playerPack.myCharacter.TakeItem(mag);//作出拾取动作
            packPanel.DisplayItemsInPack(playerPack);//如果是通过背包界面捡起,背包界面背包内物品刷新
        }
        else{//拿枪或交换枪
            if(itemInstance is Weapon){//附近物品栏的枪
                Weapon weapon=itemInstance as Weapon;
                if(weapon.weaponData.gunType==AllWeaponInfo.GunTypes.Pistol){//是手枪
                    if(playerPack.myCharacter.pistolScript){//玩家有手枪,交换手枪
                        // packPanel.ShowItemAround(playerPack.myCharacter.pistolScript);
                        playerPack.myCharacter.SwapGun(weapon);
                    }
                    else{//玩家没有手枪,拿起手枪
                        playerPack.myCharacter.TakeItem(weapon);
                        packPanel.secondaryWeapon.GetComponent<Button>().onClick.AddListener(packPanel.DropSecondaryWeapon);
                    }
                    packPanel.DisplaySecondaryWeapon(weapon);
                }
                else{//是步枪
                    if(playerPack.myCharacter.rifleScript){//交换步枪
                        // packPanel.ShowItemAround(playerPack.myCharacter.rifleScript);
                        playerPack.myCharacter.SwapGun(weapon);
                    }
                    else{//拿起步枪
                        playerPack.myCharacter.TakeItem(weapon);
                        packPanel.mainWeapon.GetComponent<Button>().onClick.AddListener(packPanel.DropMainWeapon);
                    }
                    packPanel.DisplayMainWeapon(weapon);
                }
            }
        }
        StartCoroutine(NextFrameCoroutine());//保证弹匣实体被销毁后赞执行这个
    }
    IEnumerator NextFrameCoroutine(){
        // 等待到下一帧的开始
        yield return new WaitForEndOfFrame();
        // 下一帧执行的操作
        packPanel.DisplayItemsAround();
    }
    Vector3 putDownItemOffset=new Vector3(0,.1f,0);
    public void Drop(){
        if(itemInstance is Mag){//放下弹匣
            Mag mag=itemInstance as Mag;
            Vector3 position=playerPack.transform.position+putDownItemOffset;
            Mag magInstance=Instantiate(mag.gameObject,position,Quaternion.identity).GetComponent<Mag>();//实例化物品
            magInstance.prefab=itemInstance;
            playerPack.FetchMagFromPack(mag);//从背包列表拿出一个弹匣
            Weapon gun=playerPack.myCharacter.GetGunInHand();
            if(gun){//玩家手里有枪
                if(mag.gunModel==gun.magType){//放下和枪匹配的弹匣
                    InGamePanel.Instance.ShowWeaponInfo(gun,playerPack);//左下角弹匣数更新
                }
            }
            packPanel.DisplayItemsInPack(MyInput.Instance.player.backpack);//从背包脚本的数据重新读取一遍
            packPanel.DisplayItemsAround();
            //刷新附近物品列表放最后,因为会把这个物体摧毁
        }
    }
}

背包界面的物品UI,在场景里的有对应物体,在玩家背包里和附近其他人背包里的物品在场景里没有物体,都要生成物品UI。

525ac761f8fe4f4fbabf5dcef2d16f84.png

子弹信息记录的数据结构

而换弹的逻辑我没写把背包的子弹加到弹匣,而是没打完的弹匣放回背包,在弹匣队列尾部。这样背包里的子弹数不能用一个数字记录,必须用一个列表:

293f50114a544ef1bf6422e5111b62db.png

这样换弹的时候会麻烦点,需要在列表里遍历,直到找到需要的子弹。

后来发现,如果换弹逻辑做成把背包里的子弹加到弹匣里,那么应该定义子弹种类的枚举,然后使用相同子弹的不同枪从同一个子弹数字段里取子弹;如果换弹逻辑做成把背包里的弹匣换上,没打完的弹匣放回背包,那么使用相同子弹的不同枪的弹匣也是不通用的,比如G17和M9使用同一种子弹,但是弹匣不通用,容弹量不一样,应该定义枪的型号的枚举,定义子弹型号的枚举就没有意义。

需要做的测试

1.点开背包界面,显示的物品和人物拥有的一致

c8a02a5b0e59487a92faefebbc0486ca.png

2.走近其他物品,点开背包界面,其他物品出现在附近物品栏

0221c2455c164514b99b76a0468cb412.png

3.点拥有的枪或物品,把它放下,此物品出现在附近物品栏;如果是弹匣这样在背包里没有实体的物品,附近应该出现它的实体

f3fe4a646cb945e08215959c158e4048.png749f42d3df534a6a9825554b57ccf325.pngfd6131d434544d898e6c1e8880095ad4.png

4.捡起身上没有的物品,此物品从附近物品栏消失,加入拥有的物品;如果是弹匣,此物品在场景里的实体消失

5.和附近的枪交换

6.打开背包界面查看附近的物品,按F拾取,再打开背包界面,该物品从附近物品栏消失

7.打开背包界面查看附近的物品,按F放下枪,再打开背包界面,该物品出现在附近物品栏

附近物品列表及其维护

背包脚本声明了一个列表记录周围可拾取的物品:

5e5d2b73204a4d98be8ddd3d97323e44.png

原先的方案:OnTriggerEnter()、OnTriggerExit()

可拾取物品脚本通过OnTriggerEnter()、OnTriggerExit()方法把自己加入背包的可拾取物品列表:

b23cb25ed29d4f258b8b4e88e0f1aa44.png

问题是

1.人物经过一个物品或背包,没有打开背包界面,这些物品也会到附近物品列表走一趟,白白增加系统开销;

2.放下的物品也需要加入这个列表,拿起的物品需要移出这个列表,这些是通过触发器维护可拾取物品列表的特例,需要另外写代码维护。放下和拿起物品按F和从背包界面两种方式。

如果不做可拾取物品列表,背包界面不显示附近可拾取物品,拾取物品一律按F拾取,背包系统会简单很多。但是就无法拿其他人背包里的物品。

新方案1.Physics.OverlapSphere()

打开背包界面时用Physics.OverlapSphere()得到一个球形区域的碰撞体,筛选出可拾取的、没有主人的物品,加入附近物品列表。加之前要先把附近物品列表清空。

 public float pickUpRange=2;
    public LayerMask packCheckLayerMask;
    public void CheckItemsAround(){
        itemsAround.Clear();
        packsAround.Clear();
        Collider[] thingsAround=Physics.OverlapSphere(transform.position,pickUpRange,packCheckLayerMask);//检测周围球形区域里的物品
        Item item;
        Weapon gun;
        MyCharacter other;
        for(int i=0;i<thingsAround.Length;i++){
            if(thingsAround[i].TryGetComponent(out item)){//检测到是物品
                if(item is Weapon){
                    gun=item as Weapon;
                    if(gun!=myCharacter.rifleScript&&gun!=myCharacter.pistolScript&&gun.owner==null){//不是人物自己的枪&&枪没有主人
                        itemsAround.Add(item);
                    }
                }
            }
            else if(thingsAround[i].TryGetComponent(out other)){//检测到是人
                if(other.life<=0&&other.backpack){//人已经死了&&人有背包
                    for(int j=0;j<other.backpack.magsInPack.Count;j++){
                        packsAround.Add(other.backpack);
                    }
                }
            }
            
        }
    }

新方案2

是玩异变战区2发现的,看着尸体可以按F查看别人的背包,附近物品列表就变成了别人背包列表,地上的物品只能看着按F捡起。这是一个不错的方案,但是我已经按吃鸡那种区域检测的方案写了,不方便改。

通过背包界面捡起放下物品和捡起放下动画的矛盾

把物品加入背包的代码是在捡起动画触发的动画事件里,和点背包界面的物品有一个延迟,如果点快了,多次点只会触发一次动作,但是物品从附近物品里删除的代码是点后立即执行的,会导致物品丢失。

9a8e48aec490436bae5bf3784979b7b4.png

人物死亡

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

Any State

18834949362d44d5be0ac6cad6515f67.png66bbdbe12c6a4ae6ac65e1c9f068f506.png

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

animator.Play()

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

AnimatorController加一层Dead

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

NPC检测其他人

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

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

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

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

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

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

1.人物动作要求不那么精确,比如持枪如果枪和相机z轴如果稍微不平行,第一人称看着很奇怪,第三人称就不明显;换弹动作粗糙一点,因为有身体挡住,也看不出来;

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

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

这是第一人称的跑步:

2dfe202ca26b42abbb2bce387ffef374.png

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

05c67fecb7a24777ad420013f216244a.png6a4f07753ce241c99c41dbe0dfdad5f6.png

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

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值