人物
人物的代码总数在几百行到上千行,可以分为几个模块,适合用partial class分散到几个脚本:
- 基础行为(移动、旋转、俯仰、跑步、蹲下、站起);
- 物理相关(重力、地面检测、跳跃、枪被遮挡检测、游泳检测);
- 装备交互(拿出枪、收起枪、切换枪、放下枪、捡起枪、(手榴弹也有这么一套)、换弹、射击、切换自动模式;
输入模块和玩家人物的耦合方式
有输入调用人物和人物监听输入两种架构。输入调用人物的方式,到后期如果做载具功能,调用之前还要先判断玩家有没有开载具,根据徒步还是开载具调用相应类的移动方法。而监听架构,人物上车时解除监听,玩家的车注册监听,输入模块只管调用委托,代码看起来更舒服。
不过即使游戏有步行、开汽车、开坦克、开飞机,还是可以输入模块调用玩家。只需要让人物、汽车、坦克、飞机都继承接口IControllable,输入模块持有一个IControllable controllable,上车时把汽车赋值到controllable。不过这样又有问题是人物接收的输入比汽车要多了蹲、瞄准、换弹等动作。接口要定义一系列所有子类都要实现的方法,那么是否IControllable定义蹲、换弹?定义了汽车就用不上,不定义,人物又要用。
综上,监听架构允许人物、汽车、坦克、飞机绑定不同数量种类的监听,更合适。
一个人物监听输入架构的典型样子
输入模块:
public event UnityAction<Vector2> onMove; public event UnityAction<Vector2> onRotate; public event UnityAction<bool> onRun; public event UnityAction<bool> onFire; public event UnityAction onStartPullTrigger; public event UnityAction onStopPullTrigger; public event UnityAction onUseMainGun; public event UnityAction onUseHandgun;玩家人物模块:
private void OnEnable() { MyInput.Single.onMove += Move; MyInput.Single.onRotate += Rotate; MyInput.Single.onRun += Run; MyInput.Single.onFire += SetPullTrigger; MyInput.Single.onStartPullTrigger += StartPullTrigger; MyInput.Single.onStopPullTrigger += StopPullTrigger; MyInput.Single.onUseMainGun += CheckUseRifle; MyInput.Single.onUseHandgun += ChechUseHandgun; } private void OnDisable() { if (MyInput.Single) { MyInput.Single.onMove -= Move; MyInput.Single.onRotate -= Rotate; MyInput.Single.onRun -= Run; MyInput.Single.onFire -= SetPullTrigger; MyInput.Single.onStartPullTrigger -= StartPullTrigger; MyInput.Single.onStopPullTrigger -= StopPullTrigger; MyInput.Single.onUseMainGun -= CheckUseRifle; MyInput.Single.onUseHandgun -= ChechUseHandgun; } }趴下功能
需要实现
- 碰撞体倒下,由于CharacterController的碰撞体只能立着,需要加一个倒下的胶囊碰撞体。
- 趴下前在人物低处从前到后做一个连线检测,没有检测到障碍物则执行趴下
- 人物在斜面上趴着的时候身体和斜面平行,通过向下发射射线打到地面,得到碰撞点法线,再把人物y轴对齐法线。人物站起后还要把y轴调回竖直方向;
- 站起的上方有没有空间的范围检测;
- 所有上半身动画的趴下版本:换弹(不同枪型换弹动画还不一样)、打药、扔手雷。
人物在斜面上趴着的时候身体和斜面平行
向下发射线,击中地面,获得击中点法向,使用Quaternion.FromToRotation(),把人物y轴转向法向。
RaycastHit raycastHit; void Start(){ } void Update(){ if(Physics.Raycast(transform.position,Vector3.down, out raycastHit,1,MyGameManager.Instance.layersGround)){ transform.rotation*=Quaternion.FromToRotation(transform.up,raycastHit.normal); } }射击逻辑
射击输入检测
- 需要在准备好射击(手里有枪&&没在跑步&&没在换弹&&枪有子弹)&&玩家按下鼠标时射击。
- 人物可能有多把枪,需要先判断哪把是手里的。
- 人在射击中还按着鼠标突然死亡、开始跑步、换弹等各种动作,停止射击。
准备好射击和有输入分别用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; } }半自动射击的实现
严格的半自动射击除了在鼠标按下时射击,还需要玩家高频按下鼠标时也不超过枪的全自动射速。
使用InputManager
使用GetMouseButtonDown()时才把firing设置true。这不能防止玩家高频按下鼠标时半自动射速超过全自动。
使用InputSystem
- 可以定义一个bool fireReg用来记录上一帧有没有按下左键,在执行射击后把fireReg=triggerPulled,射击判断写成triggerPulled&&!fireReg。
- PlayerInput的回调函数只在输入值变化时执行,可以在回调函数执行且input.ReadValue<float>()==1时执行StartPullTrigger(),input.ReadValue<float>()==0时执行StopPullTrigger()。之所以要得到松开鼠标的时机是因为扔手雷需要这个时机。StartPullTrigger()放在输入类还是人物类?姑且放在人物类。现在的问题是人物在Update()里检测全自动射击,枪是半自动模式时要把这个检测关掉?或者退一步,检测射击的代码放在人物类还是枪类?应该是人物类先判断有没有使用枪,有则执行这把枪的射击检测,就是说检测代码在枪类,由人物类执行。从逻辑上,半自动全自动不应该影响人物执行射击检测,人物只负责收到输入后扣下扳机,半自动是枪的属性,半自动枪射击后停止射击(就是说方案1更符合实际)。但是这里是输入类得到按下鼠标的时机,要把信号经过“人物可以射击”的判断后传给枪,人物不再在Update()里检测射击,而是由输入类调用StartPullTrigger(),里面是一次射击。这和“人物只管根据输入扣扳机”的逻辑相违背,好像半自动模式人物不再监听输入,而是由鼠标按下射击直接让枪射击。
三连发实现
先总结三连点射需要处理的所有情况:
1.按一下鼠标,发射三发;
2.一直按着鼠标,发射三发;
3.剩余子弹不到三发,按一下鼠标,发射完;
如果不允许切换为半自动,不考虑3,就可以做一个触发三次Fire()动画事件的AnimationClip,设计会大大简单(参考求生之路的scar)。要考虑3,则可以:
第一次发射后根据是不是Burst选择到Idle还是下一个射击状态,每次发射根据还有没有子弹判断是到下一个发射状态还是Hold Open,最后一次发射到Idle或Hold Open。
总之充分利用动画状态机的逻辑功能,可以使代码简化。
击中不同部位造成不同伤害
要实现击中不同部位造成不同伤害需要由中枪触发器实现。
或者直接用Unity内置的Ragdoll生成器。
这样写射击射线检测的时候需要把人物的碰撞体排除,把身体触发器包括,人物根节点和骨骼节点需要在不同层。如果测试时明明击中人却显示没有击中,可以排查是否人物碰撞体和中枪触发器在同一层。
public class BodyTriggerTool : MonoBehaviour { Animator animator; public Transform head,hips; [ContextMenu("创建头和躯干中枪触发器")] void CreateTrunkTrigger(){ if(!animator){ animator=GetComponent<Animator>(); } //躯干触发器 hips=animator.GetBoneTransform(HumanBodyBones.Hips); BodyTrigger bodyPart=hips.gameObject.AddComponent<BodyTrigger>(); bodyPart.bodyPartOption=BodyPartOptions.trunk; CapsuleCollider trunkTrigger; if(!hips.TryGetComponent(out trunkTrigger)){ trunkTrigger=hips.gameObject.AddComponent<CapsuleCollider>(); trunkTrigger.isTrigger=true; trunkTrigger.radius=.1f; trunkTrigger.height=animator.GetBoneTransform(HumanBodyBones.Neck).position.y-hips.position.y; trunkTrigger.center=new Vector3(0,trunkTrigger.height/2,0); } //头触发器 SphereCollider headTrigger; head=animator.GetBoneTransform(HumanBodyBones.Head); BodyTrigger headBodyPart=head.gameObject.AddComponent<BodyTrigger>(); headBodyPart.bodyPartOption=BodyPartOptions.head; if(!head.TryGetComponent(out headTrigger)){ headTrigger=head.gameObject.AddComponent<SphereCollider>(); headTrigger.isTrigger=true; headTrigger.center=new Vector3(0,.12f,0); headTrigger.radius=.15f; } } public Transform[]bodyParts; public Transform[] bodyPartChildren; void GetBones(){ //加触发器的骨骼 bodyParts=new Transform[]{ animator.GetBoneTransform(HumanBodyBones.LeftUpperArm), animator.GetBoneTransform(HumanBodyBones.RightUpperArm), animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg), animator.GetBoneTransform(HumanBodyBones.RightUpperLeg), animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg), animator.GetBoneTransform(HumanBodyBones.RightLowerLeg), animator.GetBoneTransform(HumanBodyBones.LeftLowerArm), animator.GetBoneTransform(HumanBodyBones.RightLowerArm) }; //上面骨骼的子骨骼,用于确定触发器长度 bodyPartChildren=new Transform[]{ animator.GetBoneTransform(HumanBodyBones.LeftLowerArm), animator.GetBoneTransform(HumanBodyBones.RightLowerArm), animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg), animator.GetBoneTransform(HumanBodyBones.RightLowerLeg), animator.GetBoneTransform(HumanBodyBones.LeftFoot), animator.GetBoneTransform(HumanBodyBones.RightFoot), animator.GetBoneTransform(HumanBodyBones.LeftHand), animator.GetBoneTransform(HumanBodyBones.RightHand) }; } [ContextMenu("创建四肢中枪触发器")] public void CreatLimbTriggers(){ if(!animator){ animator=GetComponent<Animator>(); } GetBones(); CapsuleCollider shotTrigger; BodyTrigger bodyPart; for(int i=0;i<bodyParts.Length;i++){ if(bodyParts[i]&&!bodyParts[i].TryGetComponent(out shotTrigger)){ shotTrigger=bodyParts[i].gameObject.AddComponent<CapsuleCollider>(); shotTrigger.isTrigger=true; } shotTrigger=bodyParts[i].GetComponent<CapsuleCollider>(); shotTrigger.height=bodyPartChildren[i].localPosition.y; shotTrigger.center=new Vector3(0,shotTrigger.height/2,0); shotTrigger.radius=.05f; if(!bodyParts[i].TryGetComponent(out bodyPart)){ bodyPart=bodyParts[i].gameObject.AddComponent<BodyTrigger>(); } } } [ContextMenu("清除中枪触发器")] void ClearBodyTriggers(){ Collider collider; BodyTrigger[] bodyTriggers=GetComponentsInChildren<BodyTrigger>(); for(int i=0;i<bodyTriggers.Length;i++){ if(bodyTriggers[i].TryGetComponent(out collider)){ DestroyImmediate(collider); } DestroyImmediate(bodyTriggers[i]); } } }瞄准
瞄准相机配置
在枪上挂一个记录瞄准相机位置的节点。步枪瞄准和手枪瞄准的方案不太一样。
手枪瞄准
手枪的瞄准相机不能做手枪的子物体,因为手枪射击要上跳,瞄准相机做手枪的子物体就和手枪一起上跳。人物的眼睛是不会随手枪上跳的。手枪的瞄准相机需要跟随枪的位置,不跟随枪的旋转。实现这个功能有几种方案:
- 父物体为空,使用Cinemachine,瞄准相机Follow枪的瞄准节点,LookAt设置目标物
也就是瞄准相机的旋转由目标物决定。还可以加一点damping看起来更真实,但是Composer不要加Vertical Damping,否则上抬的时候相机可能穿到枪身里。
- 父物体为头,使用Cinemachine,瞄准相机Follow枪的瞄准节点,LookAt不设置
也就是瞄准相机的旋转由头决定。
步枪瞄准
步枪射击时因为托腮,人物眼睛随枪上跳,瞄准相机就可以直接挂在瞄准位置。
其他方案,总之位置可以用Follow、PositionConstraint、ParentConstraint或父子级约束,旋转可以用LookAt、RotationConstraint、ParentConstraint或父子级约束。
瞄准镜
我直接参考了这两篇文章:
【unity小技巧】使用三种方式实现瞄准瞄具放大变焦效果_unity放大镜效果-CSDN博客
【unity小技巧】实现FPS武器的瞄准放大效果(UGUI实现反向遮罩,全屏遮挡,局部镂空效果)_unity 开镜-CSDN博客
第二篇文章的效果:挺不错,能满足要求。
第一篇文章方案三,相机输出到贴图,贴图再应用到瞄准镜后端,实现局部放大的效果:
材质配置:Base Map颜色选黑,贴图给Emission Map,颜色选白色。
效果:
可惜我的人物头发和瞄准镜后端穿模了,没法用。
弹道:子弹起点和方向
众所周知,第三人称相机和枪离得稍远,如果子弹从枪口发出,沿枪z轴飞行,如果相机和枪z轴平行,弹着点只能在无穷远处和屏幕中心一致,弹着点近时都不能命中屏幕中心。虽然这是最真实的设计,用户体验却不好。
总结起来弹道和第三人称相机的关系有这么3种设计:
- 子弹从枪口发出,方向和相机z轴平行;
- 子弹从枪口发出,朝相机中心落点飞行;
- 子弹从相机发出,沿相机z轴飞行;
如果硬要用1这种真实设计,应该提供机瞄视角,并提醒用户使用机瞄。如果没有机瞄,使用越肩视角瞄准,就必须使用2或3.
然后我去看了一下和平精英的设计,发现如下几点:
- 第三人称,没有障碍物时弹着点在屏幕中心(说明弹道和相机z轴不平行);
- 有障碍物时可能挡住子弹,无法击中屏幕中心(说明子弹不是从相机发出,而是从枪口发出);
- 瞄准近处时第三人称和机瞄屏幕中心的落点不一致,但子弹总能击中屏幕中心;
可以得出结论和平精英的弹道是方案2,从枪口发出去找屏幕中心落点。
在跑步中开枪、坐在车里开枪都能看到人物立即摆出据枪姿势,但是子弹击中准星落点,说明弹道完全和枪管没关系。
不同情况下得到弹道方向
大多数时间,子弹都不沿枪z轴发射:对于玩家,子弹朝准星落点发射,第三人称和机瞄状态相机位置不同,如果用Cinemachine两个状态使用两个虚拟相机,还要先得到激活中的相机。
对于敌人,需要弹道指向玩家+一些随机偏差。
发射子弹应该是枪的方法,但是玩家瞄准、玩家未瞄准、敌人得到弹道的逻辑不一样!难道枪发射子弹时弹道还要由外部某类算好?
射击命中检测
有射线检测方案和发射弹头实体的方案。
射线检测方案
比较简单,弹道只能是直线,没有弹头火光飞过效果,没有击中延迟。
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倒计时刷新。
防止人物攻击打到自己
有
- 层过滤;
- 物理检测拿到碰撞体,判断是自己;
可以把玩家和敌人放不同层,但是敌人也不能打到自己,敌人的数量不确定,不可能一个敌人一个层,只能拿到碰撞体判断。但是发现Physics.Linecast只能获得第一个碰撞体,没有Physics.LinecastAll。
动画系统
射击游戏动作系统的特点:
- 很难不用分层和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()加了倾斜角度渐变。
在持枪的状态机行为脚本里调用。
换弹读条功能
几个要点:
- UGUI的Image设置Filled;
- 使用了animatorStateInfo.normalizedTime写入fillAmount。在状态机行为脚本里执行的,因为它的生命周期函数的输入参数直接就有animatorStateInfo。麻烦的是要判断一下这个实例是不是玩家。
public class ReloadState : SMBBase{ bool isPlayer; override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { base.OnStateEnter(animator, stateInfo, layerIndex); myCharacter.reloading = true; isPlayer = myCharacter == MyInput.Instance.player; if (isPlayer) { PanelGame.Instance.actionPregress.enabled = true; } } override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { if (isPlayer) { PanelGame.Instance.actionPregress.fillAmount = stateInfo.normalizedTime; } } // OnStateExit is called when a transition ends and the state machine finishes evaluating this state override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { myCharacter.reloading = false; if (isPlayer) { PanelGame.Instance.actionPregress.enabled = false; } }不同枪的换弹动画
长枪的换弹动画可以分为几类:
右边拉栓(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找到所有人物脚本实例?很明显开销太大。如果有扇区触发器碰撞体,是最合适的,但是没有。只能退而求其次,先用球形触发器碰撞体,把附近的人物加到一个列表,再做扇区检测。
随机放置敌人位置及遇到的困难
测试中每次敌人都在相同的位置,次数多了及其无聊。就想让敌人的位置在一个区域内随机,又不能穿模在建筑里。
使用do while循环随机得到一个位置,使用Physics.CheckSphere()和Physics.OverlapSphere()判断此处有没有建筑,结果完全在建筑内部也得到没有碰撞体。
然后又想到先得到随机位置,NavMesh.SamplePosition把人物放到就近导航网格上。还要先给建筑物挂载NavMesh Obstacle,勾选carve防止在内部生成导航网格。这个是可行的。
然后为了防止敌人在玩家面前生成需要给生成器弄一个较大的触发半径,五十米以上。
然后我们希望在玩家看不见的转角在随机时间、随机位置生成敌人,可以先判断位置在不在玩家面前的扇形区域。但是这样可能直接生成在玩家身后,玩家体验很差。
第一人称
首先在建模软件里把头部分离。
注意头部网格不要和头部骨骼或任何骨骼同名。否则动画系统出问题,前面全白干。
第一人称把头部renderer设置shadows only。相机的旋转要跟随瞄准轴,位置跟随头部骨骼。需要加个约束。或者代码控制。
模块职责划分
人物捡枪 放下枪(包括地上和箱子里)一定会涉及到两个模块的操作。
每个模块应该负责到哪里?
人物:负责在自己的武器挂点生成或销毁枪模型,以及建立、消除引用。
地上的枪模:销毁自己
管理器:在地上生成枪模
箱子:数量变化
那么玩家的操作应该调用管理器,管理器完成对两个模块的调用。
人物掏出手榴弹时这份数据是否要移除背包?还是扔出时移除?
我觉得是不应该移除的,假如掏出手榴弹移除背包,此时捡东西把背包容量撑满,手榴弹就塞不回去了。(然后我看了一下荒野行动,它把手榴弹塞回背包就是增加了占用,把背包塞满,手榴弹槽的手榴弹是塞不回背包的,但是它手榴弹槽有东西时也可以空手,等于它的手榴弹槽也有容量,不一定要占用手。而且它的背包可以轻微超过容量)
那么手里的手榴弹也算背包占用,就必须在手榴弹投出时把背包数据-1。
动画调用逻辑 和 动画逻辑分离
有很多需要动画执行到某帧执行的逻辑:
换弹生效、治疗生效、换弹治疗进度条。动作进度条可以直接使用状态机行为脚本读取动画进度。
用动画关键帧、事件调用逻辑,能直接看见那一帧动画的状态,复用动画状态机的逻辑。
但是专业项目好像大多是动画、逻辑分离的。动画某一帧执行的逻辑通过程序延迟调用,延迟时间和动画的时间手动调成一致。