概述

这个项目是一个俯视角的3D动作游戏Demo,包含角色控制、敌人行为、关卡互动系统,以及一些简单的UI控制。

Character Controller

Overview

下面是用来展示角色预制件的结构图,可以看到,根对象上有PlayerPlayer LightsDamage Caster以及CheckPlane这几个子对象,它们的具体作用如下

  • Player:包含角色的Mesh和Rig,用来渲染角色实体以及控制角色动画,除此之外VFX也在这里面
  • Player Lights:跟随角色移动的光照,用来丰富画面
  • Damage Caster:包含所有的判定盒,用来判定攻击是否与怪物发生碰撞
  • CheckPlane:随角色移动的不被渲染的平面,用来识别鼠标的位置,进而更改角色的旋转

image-20231130210841295

角色的动画机上面的动画也在使用图表可视化地展示出来,包含以下动画片段,其中一些动画在特别的位置插入了动画事件,用来响应一些玩家控制的行为。

image-20231130210854377

Player Movement

角色的移动实现如图,使用了一个PlayerInput用来获取设备输入,然后将值传给Movement脚本中的**_movementVelocity,在Movement中使用CalculatePlayerMovement**函数计算移动。

image-20231202215858112

角色在对角线上需要归一化,使得对角线上的速度一致,旋转需要匹配摇杆的朝向。

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

相机使用的是CinemachineVirtual Camera,将FollowLook 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

角色的状态包括:NormalAttackingDeadBeingHitSlide以及Spawn,它们处理的相关活动如下:

  • Normal:角色移动、下坠状态
  • Attacking:包括三段Combo的攻击状态,可以被Slide打断
  • Dead:角色死亡状态,将会直接退出状态机,失去对角色的控制,并且触发着色器中的消融特效
  • BeingHit:角色受击状态,会朝着受击的方向强制位移一段距离,触发效应的VFX特效,并且会无敌一段时间
  • Slide:角色闪避状态,会中断NormalAttacking状态,往前位移一段距离,并且会无敌一段时间
  • Spawn:角色重生状态,重生期间无敌,并且触发着色器中的显现特效

Animator

角色的动画机包含的状态如下:

image-20231203160426109

其中,攻击状态由三个Combo动画组成:

image-20231203160434701

某些动画片段会包含动画事件,如下图所示:

image-20231203205421896

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;
//获取该动画播放的时间,1 代表在动画的结尾,0.5 代表中间。
attackAnimationDuration = _animator.GetCurrentAnimatorStateInfo(0).normalizedTime;
//只用当Combo_1或者Combo_2的时候才能执行Combo的切换
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脚本来管理它们

image-20231203205709156

其中,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和 RigVFX组以及DamageCaster

image-20231203211240357

Enemy Movement

烘焙场景的Navigation,更改高度,使得怪物无法通过较高地势的区域。

image-20231203201505469

烘焙之后的场景如下图所示,怪物只能在此处移动。

image-20231203201536122

使用Nav Mesh来引导敌人朝着角色行动,具体行为如下图所示:

image-20231203224845281

Awake中寻找TagPlayer的对象进行锁定

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会将敌人引到目标玩家的位置
_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

角色和玩家共用一个伤害判断脚本

image-20231203234835643

当敌人被玩家击中时,会触发着色器的闪烁效果,以及一个方向为被击中方向的粒子特效,因此,需要一个函数来处理这个方向,我们将它放在造成伤害的函数中,每次敌人或者玩家被击中就会触发。

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

对于远程攻击的怪物的攻击,会生成一个伤害球,与角色发生碰撞。

image-20231203235707534

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的位置,在这个位置上面生成怪物。

image-20231204000844746

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事件,打开门扉。

image-20231204001310893

GameManager

此外,游戏UI也有这几种状态:GamePlayGamePauseGameOverGameIsFinished

  • GamePlay:游戏进行状态,会隐藏除了角色状态以外的UI
  • GamePause:游戏暂停状态,会将Time.timeScale设为0,使得游戏停止,由于所有的角色、敌人、关卡行为都是基于Time,此时场景内的所有东西都会停止。
  • GameOver:游戏结束状态,当角色死亡时触发,可以重新开始游戏或者回到开始界面
  • GameIsFinished:游戏通过状态,游戏通过时触发。

image-20231204001925350