적 구현
적은 플레이어 오브젝트를 복사 + 붙여넣기 하여 기본 틀로 사용한다.
적 스크립트 또한 플레이어 스크립트와 비슷하다.
[field: Header("References")]
[field: SerializeField] public EnemySO Data { get; private set; }
[field: Header("Animations")]
[field: SerializeField] public PlayerAnimationData AnimationData { get; private set; }
public Rigidbody Rigidbody { get; private set; }
public Animator Animator { get; private set; }
public ForceReceiver ForceReceiver { get; private set; }
public CharacterController Controller { get; private set; }
private EnemyStateMachine stateMachine;
void Awake()
{
AnimationData.Initialize();
Rigidbody = GetComponent<Rigidbody>();
Animator = GetComponentInChildren<Animator>();
Controller = GetComponent<CharacterController>();
ForceReceiver = GetComponent<ForceReceiver>();
stateMachine = new EnemyStateMachine(this);
}
private void Start()
{
stateMachine.ChangeState(stateMachine.IdlingState);
}
private void Update()
{
stateMachine.HandleInput();
stateMachine.Update();
}
private void FixedUpdate()
{
stateMachine.PhysicsUpdate();
}
적의 Scriptable Object와 상태 머신 또한 이름만 바뀌고 비슷하다.
[CreateAssetMenu(fileName = "EnemySO", menuName = "Characters/Enemy")]
public class EnemySO : ScriptableObject
{
[field: SerializeField] public float PlayerChasingRange { get; private set; } = 10f;
[field: SerializeField] public float AttackRange { get; private set; } = 1.5f;
[field: SerializeField][field: Range(0f, 3f)] public float ForceTransitionTime { get; private set; }
[field: SerializeField][field: Range(-10f, 10f)] public float Force { get; private set; }
[field: SerializeField] public int Damage { get; private set; }
[field: SerializeField][field: Range(0f, 1f)] public float Dealing_Start_TransitionTime { get; private set; }
[field: SerializeField][field: Range(0f, 1f)] public float Dealing_End_TransitionTime { get; private set; }
[field: SerializeField] public PlayerGroundData GroundedData { get; private set; }
}
public class EnemyStateMachine : StateMachine
{
public Enemy Enemy { get; }
// States
public Transform Target { get; private set; }
public EnemyIdleState IdlingState { get; }
public EnemyChasingState ChasingState { get; }
public EnemyAttackState AttackState { get; }
// 이동
public Vector2 MovementInput { get; set; }
public float MovementSpeed { get; private set; }
public float RotationDamping { get; private set; }
public float MovementSpeedModifier { get; set; } = 1f;
public EnemyStateMachine(Enemy enemy)
{
Enemy = enemy;
Target = GameObject.FindGameObjectWithTag("Player").transform;
IdlingState = new EnemyIdleState(this);
ChasingState = new EnemyChasingState(this);
AttackState = new EnemyAttackState(this);
MovementSpeed = enemy.Data.GroundedData.BaseSpeed;
RotationDamping = enemy.Data.GroundedData.BaseRotationDamping;
}
}
기본 상태는 아래 플레이어를 쫓아가는 코드 외에는 전부 똑같다.
sqrMagnitude의 sqr은 제곱근으로, 제곱근을 푸는 연산이 무겁기 때문에 벡터의 크기에 한 번 더 곱해버리는 계산이다.
private Vector3 GetMovementDirection()
{
return (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).normalized;
}
protected bool IsInChaseRange()
{
// if (stateMachine.Target.IsDead) { return false; } // 플레이어가 죽었을 때 쫓아가지 않는다
float playerDistanceSqr = (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).sqrMagnitude;
return playerDistanceSqr <= stateMachine.Enemy.Data.PlayerChasingRange * stateMachine.Enemy.Data.PlayerChasingRange;
}
public class EnemyIdleState : EnemyBaseState
{
public EnemyIdleState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
{
}
// 플레이어의 Idle 공유
public override void Enter()
{
stateMachine.MovementSpeedModifier = 0f;
base.Enter();
StartAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
StartAnimation(stateMachine.Enemy.AnimationData.IdleParameterHash);
}
public override void Exit()
{
base.Exit();
StopAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
StopAnimation(stateMachine.Enemy.AnimationData.IdleParameterHash);
}
public override void Update()
{
if (IsInChaseRange())
{
stateMachine.ChangeState(stateMachine.ChasingState); // 적과 플레이어가 일정 거리가 되면 쫓아간다
return;
}
}
}
적이 플레이어를 쫓는 상태
public class EnemyChasingState : EnemyBaseState
{
public EnemyChasingState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
{
}
public override void Enter()
{
stateMachine.MovementSpeedModifier = 1;
base.Enter();
StartAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
StartAnimation(stateMachine.Enemy.AnimationData.RunParameterHash);
}
public override void Exit()
{
base.Exit();
StopAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
StopAnimation(stateMachine.Enemy.AnimationData.RunParameterHash);
}
public override void Update()
{
base.Update();
if (!IsInChaseRange()) // 플레이어가 사정거리에 들어오지 않았다면
{
stateMachine.ChangeState(stateMachine.IdlingState);
return;
}
else if (IsInAttackRange())
{
stateMachine.ChangeState(stateMachine.AttackState);
return;
}
}
private bool IsInAttackRange()
{
// if (stateMachine.Target.IsDead) { return false; }
float playerDistanceSqr = (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).sqrMagnitude;
return playerDistanceSqr <= stateMachine.Enemy.Data.AttackRange * stateMachine.Enemy.Data.AttackRange;
}
}
적이 플레이어를 공격하는 상태
public class EnemyAttackState : EnemyBaseState
{
private bool alreadyAppliedForce;
public EnemyAttackState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
{
}
public override void Enter()
{
stateMachine.MovementSpeedModifier = 0;
base.Enter();
StartAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
StartAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);
}
public override void Exit()
{
base.Exit();
StopAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
StopAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);
}
public override void Update()
{
base.Update();
ForceMove();
float normalizedTime = GetNormalizedTime(stateMachine.Enemy.Animator, "Attack");
if (normalizedTime < 1f)
{
if (normalizedTime >= stateMachine.Enemy.Data.ForceTransitionTime)
TryApplyForce();
}
else
{
if (IsInChaseRange()) // 플레이어가 쫓아가는 거리에 있다면
{
stateMachine.ChangeState(stateMachine.ChasingState);
return;
}
else
{
stateMachine.ChangeState(stateMachine.IdlingState);
return;
}
}
}
private void TryApplyForce()
{
if (alreadyAppliedForce) return;
alreadyAppliedForce = true;
stateMachine.Enemy.ForceReceiver.Reset();
stateMachine.Enemy.ForceReceiver.AddForce(stateMachine.Enemy.transform.forward * stateMachine.Enemy.Data.Force);
}
}
플레이어의 애니메이션 스크립트를 기반으로 동작하기 때문에 플레이어 애니메이션 스크립트에 적의 기본 공격을 추가한다.
[SerializeField] private string baseAttackParameterName = "BaseAttack";
public int BaseAttackParameterHash { get; private set; }
public void Initialize()
{
BaseAttackParameterHash = Animator.StringToHash(baseAttackParameterName);
}
유니티로 돌아와서 적의 Scriptable Object를 생성하고 적 스크립트에 연결한다.
기본 공격에 대한 애니메이션을 연결하면 적이 플레이어를 쫓아와서 때린다.
피격 및 처치
피격을 위해 HP와 무기 스크립트를 만든다.
public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
private int health;
public event Action OnDie;
public bool IsDead => health == 0;
private void Start()
{
health = maxHealth;
}
public void TakeDamage(int damage)
{
if (health == 0) return;
health = Mathf.Max(health - damage, 0);
if (health == 0)
OnDie?.Invoke();
Debug.Log(health);
}
}
public class Weapon : MonoBehaviour
{
[SerializeField] private Collider myCollider;
private int damage;
private float knockback;
private List<Collider> alreadyColliderWith = new List<Collider>();
private void OnEnable()
{
alreadyColliderWith.Clear();
}
private void OnTriggerEnter(Collider other)
{
if (other == myCollider) return;
if (alreadyColliderWith.Contains(other)) return;
alreadyColliderWith.Add(other);
if(other.TryGetComponent(out CharacterHealth health))
{
health.TakeDamage(damage);
}
if(other.TryGetComponent(out ForceReceiver forceReceiver))
{
Vector3 direction = (other.transform.position - myCollider.transform.position).normalized;
forceReceiver.AddForce(direction * knockback);
}
}
public void SetAttack(int damage, float knockback)
{
this.damage = damage;
this.knockback = knockback;
}
}
공격 애니메이션이 처리 될 때, 데미지를 줄 것인지 처리하기 위해 적 공격 상태 스크립트에 코드를 추가한다.
private bool alreadyAppliedDealing;
public override void Enter()
{
alreadyAppliedForce = false;
alreadyAppliedDealing = false;
stateMachine.MovementSpeedModifier = 0;
base.Enter();
StartAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
StartAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);
}
public override void Update()
{
base.Update();
ForceMove();
float normalizedTime = GetNormalizedTime(stateMachine.Enemy.Animator, "Attack");
if (normalizedTime < 1f)
{
if (normalizedTime >= stateMachine.Enemy.Data.ForceTransitionTime)
TryApplyForce();
if (!alreadyAppliedDealing && normalizedTime >= stateMachine.Enemy.Data.Dealing_Start_TransitionTime) // 딜 안했을 때
{
stateMachine.Enemy.Weapon.SetAttack(stateMachine.Enemy.Data.Damage, stateMachine.Enemy.Data.Force);
stateMachine.Enemy.Weapon.gameObject.SetActive(true);
alreadyAppliedDealing = true;
}
if (alreadyAppliedDealing && normalizedTime >= stateMachine.Enemy.Data.Dealing_End_TransitionTime) // 딜 했을 때
{
stateMachine.Enemy.Weapon.gameObject.SetActive(false);
}
}
else
{
if (IsInChaseRange()) // 플레이어가 쫓아가는 거리에 있다면
{
stateMachine.ChangeState(stateMachine.ChasingState);
return;
}
else
{
stateMachine.ChangeState(stateMachine.IdlingState);
return;
}
}
}
적 스크립트에 무기와 플레이어가 죽었는지에 대한 코드를 추가한다.
[field: SerializeField] public Weapon Weapon { get; private set; }
public Health Health { get; private set; }
void Awake()
{
AnimationData.Initialize();
Rigidbody = GetComponent<Rigidbody>();
Animator = GetComponentInChildren<Animator>();
Controller = GetComponent<CharacterController>();
ForceReceiver = GetComponent<ForceReceiver>();
Health = GetComponent<Health>();
stateMachine = new EnemyStateMachine(this);
}
private void Start()
{
stateMachine.ChangeState(stateMachine.IdlingState);
Health.OnDie += OnDie;
}
void OnDie()
{
Animator.SetTrigger("Die");
enabled = false;
}
유니티로 돌아와서 적 오른쪽 손에 무기 Collider와 Rigidbody를 걸어주고 무기 스크립트를 붙여준다.
적 오브젝트에 붙은 스크립트에 무기를 넣어준다.
플레이어에도 동일하게 스크립트와 무기에 Collider, Rigidbody를 추가한다.
주석 처리했던적 기본 상태 스크립트에서 주석 처리했던 죽음 처리를 풀고 적 상태 머신에서 임시로 처리한 Transform을 Health로 바꾼다.
public class EnemyStateMachine : StateMachine
{
public Enemy Enemy { get; }
// States
public Health Target { get; private set; }
public EnemyStateMachine(Enemy enemy)
{
Enemy = enemy;
Target = GameObject.FindGameObjectWithTag("Player").GetComponent<Health>();
IdlingState = new EnemyIdleState(this);
ChasingState = new EnemyChasingState(this);
AttackState = new EnemyAttackState(this);
MovementSpeed = enemy.Data.GroundedData.BaseSpeed;
RotationDamping = enemy.Data.GroundedData.BaseRotationDamping;
}
}
'부트캠프 > Study' 카테고리의 다른 글
<프로젝트> Unity 게임 개발 심화 개인과제 (1) | 2023.12.27 |
---|---|
<Unity> 프로젝트 빌드 하는 방법(Building) (1) | 2023.12.26 |
<Unity> 3D 게임 개발 심화 - RPG FSM(3) (0) | 2023.12.24 |
<Unity> 3D 게임 개발 심화 - RPG FSM(2) (1) | 2023.12.23 |
<Unity> 3D 게임 개발 심화 - RPG FSM(1) (2) | 2023.12.22 |