개요
이번주 목요일은 현충일이였습니다.
많이 늦긴 했지만 먼저 국가를 위해 한 몸 바쳐 희생하신 분들께 애도를 표합니다.
아무튼 목요일이 현충일인 관계로 저희 학교는 수요일에 하교하여 목금토일을 내리 쉬게 되었죠.
그래서 집에서 열심히 프로젝트를 하겠노라고 마음을 먹고 집에와서 한게 진짜 없습니다.
아..이고... 하... 진짜 의욕이 안나더군요. 게임을 해도 손에 잘 잡히지 않고 늘 잘 읽던 소설도 읽히질 않아서 멍하니 누워서 의미없이 시간만 보냈습니다.
개발은 손에 잡혔냐구요? 당연히 아니죠. 노는 것도 손에 안잡힌다면 보통 일도 안잡히는 편입니다.
깃허브 커밋 주기 망한거 보십쇼!!!!
제가 집에와서 진짜 한게 없다는 말의 산증인입니다.
으악!!!!!
그래도 지금이라도 일하지 않으면 나중에 구르는것 뿐만 아니라 1초의 휴식없이 끊임 없이 굴러도 시간이 부족할것 같다는 생각이 들었습니다.
아무리 의욕이 안나도 일단 일을 시작하겠습니다. 총은 완성 됐으니 이제 적을 만들 차례죠. 한번 해봅시다.
Enemy를 위한 FSM 구조
FSM... Player에서 한번 썼죠? 적 또한 똑같은 구조 입니다.
하지만 Player는 한명이지만 적은 여럿이기 때문에 재사용성을 올리기위해 살짝 변화를 줄겁니다.
일단 Player처럼 EnemyState와 EnemyStateMachine을 만들어줍니다.
이걸 이제 만들 적들의 State와 StateMachine에게 상속시켜주려는 의도일까요?
반은 맞고 반은 틀립니다. EnemyStateMachine은 누구에게 상속하지 않을겁니다.
StateMachine이 모든 적마다 꼭 하나씩 있을 필요가 있습니까?
물론 있겠죠. 제 말은, 모든 적의 전용 클래스를 만들어 주어야 하냐는 말이죠.
무엇보다 번거롭죠. 만약 제가 CommonEnemy를 만들었다면 CommonEnemyStateMachine을 적고 거기 안에서 또 한번 CommonEnemyState를 위해 자료형을 일일히 바꿔줘야 한다는 귀찮음이 따릅니다.
똑같은 이유로 EnemyState또한 이중 상속을 하지 않을겁니다.
무슨 뜻이냐구요? 일단 그림을 봅시다.
저희는 Player의 State를 관리를 위해 PlayerState라는 부모 클래스를 만들어서 각각의 State들이 상속받도록 해주었습니다.
근데 이건 Player를 위한 단 하나의 클래스죠.
저희는 Enemy를 전체적으로 관리할 목적의 클래스를 만들었습니다.
그러니 이런식으로 가지가 갈라지겠죠.
생각만 해도 클래스 하나하나 만들어서 하나하나 관리하기 귀찮죠? 그래서 제네릭을 사용해 관리해줄겁니다.
각각의 Enemy들이 갖고있어야 하는 필수적이며 서로 모두 다른건 제가 생각했을때 각각의 State 정보를 담는 Enum밖에 없습니다.
좋습니다. 만들어보죠.
public class EnemyState<T> where T : Enum
일단 제네릭을 사용하기 위해 꺽쇠를 클래스 명 바로 옆에 적어주고 이 T자리에 올 수 있는건 Enum밖에 없다고 해줍니다.
하지만 EnemyState만 있어봤자 이것을 가지고 있을 EnemyStateMachine이 없으면 소용 없기 때문에 EnemyStateMachine 또한 똑같이 바꿔주겠습니다.
public class EnemyStateMachine<T> where T : Enum
이러면 이제 저 T자리의 각자 Enemy마다 가지고 있는 Enum만 잘 넣어주면 클래스를 양산하지 않아도 자동적으로 양산해주는 구조가 완성되었습니다.
...내부 구조요?
별거 없습니다. PlayerState와 같되, Enum이 들어갈자리에 모두 T가 꿰차고 들어간것뿐이죠.
public class EnemyStateMachine<T> where T : Enum
{
public EnemyState<T> CurrentState { get; private set; }
public Dictionary<T, EnemyState<T>> stateDictionary = new Dictionary<T, EnemyState<T>>();
private Enemy _enemyBase;
public void Initialize(T startState, Enemy enemy)
{
_enemyBase = enemy;
CurrentState = stateDictionary[startState];
CurrentState.Enter();
}
public void ChangeState(T newState, bool forceMode = false)
{
if (!_enemyBase.CanStateChangeable && !forceMode) return;
CurrentState.Exit();
CurrentState = stateDictionary[newState];
CurrentState.Enter();
}
public void AddState(T stateEnum, EnemyState<T> state)
{
stateDictionary.Add(stateEnum, state);
}
}
이런식으로 말입니다.
이제 간단하게 적을 하나 만들어봅시다.
Common Enemy
전 작명센스가 좋지 않습니다. 그저 Player를 발견하면 총을 쏘는 평범한 적이라서 Common Enemy죠.
아무튼 얘는 대충 이러한 FSM을 가지면 됩니다.
가만히 서 있다가 Player가 감지되면
-> 공격 상태로 전환하고 Player가 범위를 벗어나면
-> 범위 안으로 올때까지 Player를 향해 달리고
* 공격을 맞으면 모든 행동을 멈추고 넉백등의 피드백을 적용한 뒤에
-> 체력이 0보다 같거나 적다면 죽기.
대충 있어야할 State들은 Idle, Run, Attack, Hit, Dead 정도가 있겠네요.
...일단 적의 구조를 짭시다.
이미 짠거 아니냐구요? 아까 짠건 FSM구조라서 적 구조를 짜야합니다.
State만 있어봤자 할건 없어요.
미리 만들어놓은 Agent 클래스를 상속하는 Enemy 클래스를 만들어줍시다.
public abstract class Enemy : Agent
얘는 제네릭 아닙니다. 각각의 적마다 적을게 생각보다 많아서 말이죠.
그리고 모든 적은 Player를 감지해야합니다. 그리고 Player가 벽뒤에 있는데 감지가 되는건 어불성설이죠. 그렇기 때문에 Player를 감지하는 함수와 장애물을 감지하는 함수, 두개를 만들어줍니다.
public virtual Collider IsPlayerDetected()
{
int cnt = Physics.OverlapSphereNonAlloc(transform.position, runAwayDistance, _enemyCheckColliders, _whatIsPlayer);
return cnt >= 1 ? _enemyCheckColliders[0] : null;
}
public virtual bool IsObstacleDetected(float distance, Vector3 direction)
{
return Physics.Raycast(transform.position, direction, distance, _whatIsObstacle);
}
Player 감지는 OverlapSphere인데 왜 장애물 감지는 Raycast냐면...
일단 Player를 감지한 뒤에 감지가 됐다면 Player 방향으로 Ray를 쏘기만 하면 Player가 엄폐중인지 아닌지 쉽게 판단 가능해서입니다.
...다 아시죠?
아무튼 OverlapSphere 뒤에 붙어있는 NonAlloc이라는 키워드...에 대한 설명은 다른 게시글로 빼겠습니다. 여기서 설명하긴 좀 길겠네요.
간단하게 말하자면 OverlapSphere를 사용했을때 새로운 Collder 배열을 만들어 반환하는 대신 기존의 Collider 배열을 넣어주면 거기에다가 넣어주겠다 라는 뜻입니다.
이걸 사용하는 이유는 메모리 관리의 효율성때문이죠.
적고보니 이게 다네요.
아무튼 적이 Player를 끊임없이 추적하기 위해 Player의 Transform을 담아둘 변수 또한 준비해놓고 슬슬 움직임을 위한 EnemyMovement를 대충 만들어볼까요.
public Transform targetTrm;
별거 없습니다. 3D에선 개사기인 NavMeshAgent가 있기 때문이죠. 종착지만 찍어주면 알아서 장애물 피해서 움직입니다.
public void SetDestination(Vector3 destination)
{
if (!_navAgent.enabled) return;
_navAgent.isStopped = false;
_navAgent.SetDestination(destination);
}
문제는 넉백입니다.
public void GetKnockback(Vector3 force)
{
StartCoroutine(ApplyKnockback(force));
}
private IEnumerator ApplyKnockback(Vector3 force)
{
Vector3 destination = _navAgent.destination;
_navAgent.enabled = false;
_rigidbodyCompo.useGravity = true;
_rigidbodyCompo.isKinematic = false;
_rigidbodyCompo.AddForce(force, ForceMode.Impulse);
_knockbackThreshold = Time.time;
if (_isKnockback)
{
yield break;
}
_isKnockback = true;
yield return new WaitForSeconds(_physicsDelayTime);
yield return new WaitUntil(() => _rigidbodyCompo.velocity.magnitude < _knockbackThreshold || Time.time > _currentKnockbackTime + _maxKnockbackTime);
_rigidbodyCompo.velocity = Vector3.zero;
_rigidbodyCompo.angularVelocity = Vector3.zero;
_rigidbodyCompo.useGravity = false;
_rigidbodyCompo.isKinematic = true;
_navAgent.Warp(transform.position);
_navAgent.enabled = true;
_isKnockback = false;
}
와... 정신 나가죠? 별로 다시 뜯어보고 싶진 않은 코드입니다.
대충 넉백 중엔 이동과 넉백의 충돌 방지를 위해 NavMeshAgnet를 꺼주고 힘을 적용시켜준 다음에 NavMeshAgent를 다시 켜줄뿐입니다. 그 과정에서 넉백 된 후에 NavMeshAgent를 켰을때 넉백 전 위치로 순간이동 하는 현상을 막기 위해 Warp를 해줘야하기도 하고 넉백중에 한번 더 넉백당했다면 시간이랑 넉백만 넣어주고 코루틴을 탈출하는 등... 별건 없지만 뭐가 많긴 합니다.
이제 CommonEnemy 클래스를 만들어줍시다.
public class CommonEnemy : Enemy
{
private EnemyStateMachine<CommonEnemyStateEnum> _stateMachine;
public EnemyStateMachine<CommonEnemyStateEnum> StateMachine => _stateMachine;
public CommonEnemyGun GunCompo { get; protected set; }
protected override void Awake()
{
base.Awake();
GunCompo = GetComponent<CommonEnemyGun>();
_stateMachine = new EnemyStateMachine<CommonEnemyStateEnum>();
foreach (CommonEnemyStateEnum stateEnum in Enum.GetValues(typeof(CommonEnemyStateEnum)))
{
string typeName = stateEnum.ToString();
Type t = Type.GetType($"CommonEnemy{typeName}State");
try
{
var enemyState = Activator.CreateInstance(t, this, _stateMachine, typeName) as EnemyState<CommonEnemyStateEnum>;
_stateMachine.AddState(stateEnum, enemyState);
}
catch (Exception e)
{
Debug.LogError($"CommonEnemy : no state [ {typeName} ]");
Debug.LogError(e);
}
}
}
.
..
...
}
Reflection은 설명했으니 넘어가죠.
일단 처음은 IdleState라곤 돼있지만 사실상 Patrol(정찰)State인 State를 구현해보죠.
Player가 감지되는 순간 달려가야하기 때문에 Update에서 감지를 돌립시다.
public class CommonEnemyIdleState : EnemyState<CommonEnemyStateEnum>
{
public CommonEnemyIdleState(Enemy enemyBase, EnemyStateMachine<CommonEnemyStateEnum> stateMachine, string animBoolName) : base(enemyBase, stateMachine, animBoolName)
{
}
public override void UpdateState()
{
base.UpdateState();
Collider target = _enemyBase.IsPlayerDetected();
if (target == null) return;
Vector3 direction = target.transform.position - _enemyBase.transform.position;
direction.y = 0;
if (!_enemyBase.IsObstacleDetected(direction.magnitude, direction.normalized))
{
_enemyBase.targetTrm = target.transform;
_stateMachine.ChangeState(CommonEnemyStateEnum.Run);
}
}
}
음... 쉽죠?
Run으로 넘겨주기 전에 _targetTrm을 정해주어야 합니다. 정해주지 않으면 Run은 내가 넘겨받긴 했는데 대체 어디로 달리라는건지 하루종일 고민하다가 터져버리겠죠.
그리고 또, direction의 y를 0으로 바꿔주는 것을 잊으면 안됩니다. 이거 냅뒀다가 y가 위로 솟거나 아래로 꺼지는 순간 장애물 감지가 원활하지 않아질수도 있습니다.
이제 RunState를 적어봅시다.
private Vector3 _targetDestination;
private void SetDestination(Vector3 position)
{
_targetDestination = position;
_enemyBase.MovementCompo.SetDestination(position);
}
public override void Enter()
{
base.Enter();
SetDestination(_enemyBase.targetTrm.position);
}
일단 들어옴과 동시에 목적지를 정해줍니다.
_targetDestination은 그저 가독성 업을 위한 캐싱 변수입니다.
그리고 이제 공격 사거리 안에 들어왔는지 꾸준히 체크해줍니다.
public override void UpdateState()
{
base.UpdateState();
if (_enemyBase.MovementCompo.NavAgent.enabled)
{
_targetDestination = _enemyBase.MovementCompo.NavAgent.destination;
}
float distance = (_targetDestination - _enemyBase.targetTrm.position).magnitude;
if (distance >= 0.5f)
{
SetDestination(_enemyBase.targetTrm.position);
}
if (Vector3.Distance(_enemyBase.transform.position, _enemyBase.targetTrm.position) <= _enemyBase.attackDistance)
{
_stateMachine.ChangeState(CommonEnemyStateEnum.Fire);
}
그리고 공격 사거리 안에 들어왔다면 FireState로 이동해줍니다.
이제 FireState에선 공격해주고 다시 Run으로 탈출해주면 되겠죠.
public class CommonEnemyFireState : EnemyState<CommonEnemyStateEnum>
{
public CommonEnemyFireState(Enemy enemyBase, EnemyStateMachine<CommonEnemyStateEnum> stateMachine, string animBoolName) : base(enemyBase, stateMachine, animBoolName)
{
}
public override void Enter()
{
base.Enter();
//히히 총알 발싸!!!
}
public override void Exit()
{
_enemyBase.lastAttackTime = Time.time;
base.Exit();
}
public override void UpdateState()
{
base.UpdateState();
if (_endTriggerCalled)
{
_stateMachine.ChangeState(CommonEnemyStateEnum.Run);
}
}
}
참고로 _endTriggerCalled는 애니메이션이 끝날때 true로 바뀌는 bool값 입니다.
어떻게 감지하냐구요?...
당연히 수동이죠!
마치는 말
글쓰기 넘모 힘든데 여기서 마치겠습니다. 솔직히 구조 설명하면 모든걸 설명한거나 다름 없죠.
Hit이랑 DeadState는 알잘딱으로 상상해주시길 바라겠습니다. 이걸 읽는 당신은 저보다 매우 똑똑할테니 말이죠.
글빨이 안받네요. 읽어주셔서 감사합니다.
'개발 > UNNAMED' 카테고리의 다른 글
UNNAMED | 네번째 프로젝트 # 7 : 스테이지, 튜토리얼 (1) | 2024.07.03 |
---|---|
UNNAMED | 네번째 프로젝트 # 6 : 버튼과 기획 변경 (1) | 2024.06.10 |
UNNAMED | 네번째 프로젝트 # 4 : 총 (0) | 2024.06.02 |
UNNAMED | 네번째 프로젝트 # 3 : Player FSM (0) | 2024.06.01 |
UNNAMED | 네번째 프로젝트 # 2 : FPS Player Movement (0) | 2024.06.01 |