概述 这个项目是一个俯视角的3D动作游戏Demo,包含角色控制、敌人行为、关卡互动系统,以及一些简单的UI控制。
Character Controller Overview 下面是用来展示角色预制件的结构图,可以看到,根对象上有Player 、Player Lights 、Damage Caster 以及CheckPlane 这几个子对象,它们的具体作用如下
Player: 包含角色的Mesh和Rig,用来渲染角色实体 以及控制角色动画 ,除此之外VFX 也在这里面
Player Lights: 跟随角色移动的光照,用来丰富画面
Damage Caster: 包含所有的判定盒,用来判定攻击是否与怪物发生碰撞
CheckPlane: 随角色移动的不被渲染的平面,用来识别鼠标的位置,进而更改角色的旋转
角色的动画机上面的动画也在使用图表可视化地展示出来,包含以下动画片段,其中一些动画在特别的位置插入了动画事件,用来响应一些玩家控制的行为。
Player Movement 角色的移动实现如图,使用了一个PlayerInput 用来获取设备输入,然后将值传给Movement 脚本中的**_movementVelocity,在 Movement中使用 CalculatePlayerMovement**函数计算移动。
角色在对角线上需要归一化,使得对角线上的速度一致,旋转需要匹配摇杆的朝向。
1 2 3 4 5 6 7 8 9 10 11 _movementVelocity.Set(_playerInput.HorizontalInput,0f ,_playerInput.VerticalInput); _movementVelocity.Normalize(); _movementVelocity = Quaternion.Euler(0 , -45f , 0 ) * _movementVelocity; _movementVelocity *= MoveSpeed * Time.deltaTime; if (_movementVelocity != Vector3.zero) transform.rotation = Quaternion.LookRotation(_movementVelocity);
Camera 相机使用的是Cinemachine 的Virtual Camera ,将Follow 和Look At 都设置为Player ,阻尼Damping 设置为0,相机距离Camera Distance 设置为32,Aim 类型从composer 设置为None ,并在Transform 中设置Rotation 为:X = 45, Y = -45,用来模拟正交视图 ,同时将FOV 设置为25,Near Clip 设置为10,Far Clip 设置为60。
为什么不直接用正交视图呢?因为有些VFX 不适用。
State Machine 角色的状态包括:Normal 、Attacking 、Dead 、BeingHit 、Slide 以及Spawn ,它们处理的相关活动如下:
Normal: 角色移动、下坠状态
Attacking: 包括三段Combo的攻击状态,可以被Slide 打断
Dead: 角色死亡状态,将会直接退出状态机,失去对角色的控制,并且触发着色器中的消融特效
BeingHit: 角色受击状态,会朝着受击的方向强制位移一段距离,触发效应的VFX特效,并且会无敌一段时间
Slide: 角色闪避状态,会中断Normal 、Attacking 状态,往前位移一段距离,并且会无敌一段时间
Spawn: 角色重生状态,重生期间无敌,并且触发着色器中的显现特效
Animator 角色的动画机包含的状态如下:
其中,攻击状态由三个Combo动画组成:
某些动画片段会包含动画事件,如下图所示:
Combo 在Attacking状态中,只有当Combo_1或者Combo_2的时候才能执行Combo的切换,并且通过插值时间,使得在动画快要结束时才能切换Combo。此外,Slide可以打断所有Attack。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 if (Time.time < attackStartTime + AttackSlideDuration){ float timepassed = Time.time - attackStartTime; float lerptime = timepassed / AttackSlideDuration; _movementVelocity = Vector3.Lerp(transform.forward * AttackSlideSpeed, Vector3.zero, lerptime); } if (_playerInput.SpaceButtonDown && _characterController.isGrounded){ SwitchStateTo(CharacterState.Slide); break ; } if (_playerInput.MouseButtonDown && _characterController.isGrounded){ string currentClipName = _animator.GetCurrentAnimatorClipInfo(0 )[0 ].clip.name; attackAnimationDuration = _animator.GetCurrentAnimatorStateInfo(0 ).normalizedTime; if (currentClipName != "LittleAdventurerAndie_ATTACK_03 " && attackAnimationDuration > 0.5f && attackAnimationDuration < 0.7f ) { _playerInput.MouseButtonDown = false ; SwitchStateTo(CharacterState.Attacking); CalculatePlayerMovement(); } }
Check Plane 为了实现角色跟随鼠标旋转,为Player 对象添加一个Plane ,并取消勾选MeshRender ,这样就不会被渲染。然后新建一个图层Cursor ,将该Plane 更改为此图层。使用射线检测是否与该图层发生碰撞,并将碰撞的位置与角色的位置相减得到角色的旋转方向。
1 2 3 4 5 6 7 8 9 10 private void RotateToCursor (){ Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray,out hit,1000 ,1 <<LayerMask.NameToLayer("CursorTest" ))) { Vector3 cursorPos = hit.point; transform.rotation = Quaternion.LookRotation(cursorPos - transform.position, Vector3.up); } }
开发中可以使用OnDrawGizmos 将鼠标位置绘画出来,使用RotateToCursor 脚本,用于旋转角色的朝向。在角色控制器中的状态机中,当切换至新状态时计算角色的朝向。
VFX 角色的VFX包括:脚步、治疗以及剑气,使用PlayerVFXManager 脚本来管理它们
其中,Player Run VFX 修改了动画机的状态函数,只用当奔跑的时候才会触发。
1 2 3 4 5 6 7 8 9 10 11 override public void OnStateEnter (Animator animator, AnimatorStateInfo stateInfo, int layerIndex ){ if (animator.GetComponent<PlayerVFXManager>() != null ) animator.GetComponent<PlayerVFXManager>().update_footstep(true ); } override public void OnStateExit (Animator animator, AnimatorStateInfo stateInfo, int layerIndex ){ if (animator.GetComponent<PlayerVFXManager>() != null ) animator.GetComponent<PlayerVFXManager>().update_footstep(false ); }
Enemy Behavior 敌人的结构图如下,包含Enemy Mesh和 Rig 、VFX组 以及DamageCaster
Enemy Movement 烘焙场景的Navigation ,更改高度,使得怪物无法通过较高地势的区域。
烘焙之后的场景如下图所示,怪物只能在此处移动。
使用Nav Mesh 来引导敌人朝着角色行动,具体行为如下图所示:
在Awake 中寻找Tag 为Player 的对象进行锁定
1 2 3 4 5 6 7 if (!isPlayer){ _navMeshAgent = GetComponent<NavMeshAgent>(); TargetPlayer = GameObject.FindWithTag("Player" ).transform; _navMeshAgent.speed = MoveSpeed; SwitchStateTo(CharacterState.Spawn); }
使用CalculateEnemyMovement 函数来控制怪物移动,将Nav Mesh Agent 中的Stopping Distance 重写为2,在靠近角色的时候将状态更改为攻击状态,进行攻击。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private void CalculateEnemyMovement (){ if (Vector3.Distance(TargetPlayer.position, transform.position) >= _navMeshAgent.stoppingDistance) { _navMeshAgent.SetDestination(TargetPlayer.position); _animator.SetFloat("Speed" ,0.2f ); } else { _navMeshAgent.SetDestination(transform.position); _animator.SetFloat("Speed" ,0f ); SwitchStateTo(CharacterState.Attacking); } }
在状态机中,当从正常状态改为攻击状态时,敌人攻击结束后将转向到玩家
1 2 3 4 5 if (!isPlayer) { Quaternion newRotation = Quaternion.LookRotation(TargetPlayer.position - transform.position); transform.rotation = newRotation; }
Enemy BeingHit 角色和玩家共用一个伤害判断脚本
当敌人被玩家击中时,会触发着色器的闪烁效果,以及一个方向为被击中方向的粒子特效,因此,需要一个函数来处理这个方向,我们将它放在造成伤害的函数中,每次敌人或者玩家被击中就会触发。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 private void AddImpact (Vector3 attackerPos, float force ){ Vector3 impactDir = transform.position - attackerPos; impactDir.Normalize(); impactDir.y = 0 ; ImpactOnCharacter = impactDir * force; } public void ApplyDamage (int damage, Vector3 attackerPos = new Vector3( )){ if (isInvincible) { return ; } if (_health != null ) { _health.ApplyDamage(damage); } if (!isPlayer) { GetComponent<EnemyVFXManager>().PlayBeingHitVFX(attackerPos); } StartCoroutine(MaterialBlink()); if (isPlayer) { SwitchStateTo(CharacterState.BeingHit); AddImpact(attackerPos, EnemyAttackForce); } else { AddImpact(attackerPos,2.5f ); } }
在DamageCaster中处理碰撞造成伤害的行为。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 private void OnTriggerEnter (Collider other ){ if (other.tag == TargetTag && !DamageTargetList.Contains(other)) { Character _character = other.GetComponent<Character>(); if (_character != null ) { _character.ApplyDamage(damage,transform.parent.position); PlayerVFXManager playerVFXManager = transform.parent.GetComponent<PlayerVFXManager>(); if (playerVFXManager != null ) { RaycastHit hit; Vector3 originPos = transform.position + (-_damageCasterCollider.bounds.extents.z) * transform.forward; bool isHit = Physics.BoxCast(originPos, _damageCasterCollider.bounds.extents / 2 , transform.forward, out hit, transform.rotation, _damageCasterCollider.bounds.extents.z, 1 << 6 ); if (isHit) { playerVFXManager.PlaySlash(hit.point + new Vector3(0 , 0.5f , 0 )); } } } DamageTargetList.Add(other); } }
Enemy Shooting 对于远程攻击的怪物的攻击,会生成一个伤害球,与角色发生碰撞。
Enemy Shooting脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private void Awake (){ _Character = GetComponent<Character>(); } public void ShootTheDamageOrb (){ Instantiate(DamageOrb, ShootingPoint.position, Quaternion.LookRotation(ShootingPoint.forward)); } private void Update (){ _Character.RotateToPlayer(); }
DamageOrb脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 private void Awake (){ _Rigidbody = GetComponent<Rigidbody>(); } private void FixedUpdate (){ _Rigidbody.MovePosition(transform.position + transform.forward * speed *Time.deltaTime); } private void OnTriggerEnter (Collider other ){ Character _character = other.gameObject.GetComponent<Character>(); if (_character != null && _character.isPlayer) { _character.ApplyDamage(Damage,transform.position); } Instantiate(HitVFX, transform.position, Quaternion.identity); Destroy(gameObject); }
Enemy Drop 怪物死亡会掉落治疗球,使用碰撞检测捡起,会判断是否是被Tag为Player 的对象碰撞。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public enum PickUpType { Heal,Coin } //Heal public PickUpType Type; public int Value = 20; //Coin public ParticleSystem CollectedVFX; private void OnTriggerEnter(Collider other) { if (other.tag == "Player") { other.gameObject.GetComponent<Character>().PickUpItem(this); if (CollectedVFX != null) { Instantiate(CollectedVFX, transform.position, Quaternion.identity); } Destroy(gameObject); } }
Level Enemy Spawn Spawner 用来控制怪物的生成,SpawnEnemys 会获取SpawnPoint 的位置,在这个位置上面生成怪物。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 private void Awake (){ var spawnArray = transform.parent.GetComponentsInChildren<SpawnPoint>(); _spawnPointsList = new List<SpawnPoint>(spawnArray); _charactersList = new List<Character>(); } private void Update (){ if (_charactersList.Count == 0 || !isSpawned) { return ; } bool ALL_Spawn_Enermy_Dead = true ; foreach (Character _character in _charactersList) { if (_character.CurrentState != Character.CharacterState.Dead) { ALL_Spawn_Enermy_Dead = false ; break ; } } if (ALL_Spawn_Enermy_Dead) { if (OnAllSpawnCharacterDead != null ) { OnAllSpawnCharacterDead.Invoke(); } _charactersList.Clear(); } } public void SpawnEnemys (){ if (isSpawned) return ; isSpawned = true ; foreach (var _spawnPoints in _spawnPointsList) { if (_spawnPoints.EnemyToSpawn != null ) { GameObject spawnedGameObject = Instantiate(_spawnPoints.EnemyToSpawn, _spawnPoints.transform.position, _spawnPoints.transform.rotation); spawnedGameObject.transform.GetComponent<Character>().SwitchStateTo(Character.CharacterState.Spawn); _charactersList.Add(spawnedGameObject.GetComponent<Character>()); } } } private void OnTriggerEnter (Collider other ){ if (other.tag == "Player" ) { SpawnEnemys(); } } private void OnDrawGizmos (){ Gizmos.color = Color.red; Gizmos.DrawWireCube(_Collider.transform.position,_Collider.bounds.size); }
Level 此外,Spawner还控制着关卡大门的打开,只有当当前关卡怪物都死亡时,才会触发OnAllSpawnCharacterDead 事件,打开门扉。
GameManager 此外,游戏UI也有这几种状态:GamePlay 、GamePause 、GameOver 、GameIsFinished
GamePlay: 游戏进行状态,会隐藏除了角色状态以外的UI
GamePause: 游戏暂停状态,会将Time.timeScale
设为0,使得游戏停止,由于所有的角色、敌人、关卡行为都是基于Time
,此时场景内的所有东西都会停止。
GameOver: 游戏结束状态,当角色死亡时触发,可以重新开始游戏或者回到开始界面
GameIsFinished: 游戏通过状态,游戏通过时触发。