미로스 - 3. 몬스터 행동 패턴과 전투(AI)요소
- 100 powerun
- 2024년 11월 27일
- 6분 분량
최종 수정일: 2월 20일
몬스터의 패턴과 전투 요소는 보스 기준으로 총 4개가 존재하고, 그 외의 몬스터는 단순 이동및 공격만 존재합니다.
public class MonsterData : ScriptableObject
{
[SerializeField]
private int _monsterID;
public int MonsterID { get { return _monsterID; } }
[SerializeField]
private string _monsterName;
public string MonsterName { get { return _monsterName; } }
[SerializeField]
private int _hp;
public int Hp { get { return _hp; } }
[SerializeField]
private int _attack;
public int Attack { get { return _attack; } }
[SerializeField]
private int _defense;
public int Defense { get { return _defense; } }
[SerializeField]
private float _moveSpeed;
public float MoveSpeed { get { return _moveSpeed; } }
[SerializeField]
private float _scanRange;
public float ScanRange { get { return _scanRange; } }
[SerializeField]
private float _attackRange;
public float AttackRange { get { return _attackRange; } }
}
대부분의 몬스터는 이 베이스로 만들어집니다. 6종류의 몬스터가 존재하는데, 예시로 한 몬스터를 확인해 보면
int id = 100;
public int maxHp = 100;
public int hp = 100;
public SHEnemyState state = SHEnemyState.Idle;
bool isWaiting = false;
//스테이터스 변수
//public float rotSpeed=3f;
public float movSpeed = 3f;
public float jmupPower = 3f;
public float viewRange = 10f;
public float traceRange = 11f;
위는 기본적인 스테이터스 변수와 상태 값을 가져오는 변수들로 이루어져 있습니다. 사이에 끼워져 있는 bool은 패턴 후 대기상태를 확인하는 변수입니다.
public GameObject s2_Shield;
public GameObject s2_Effect;
public GameObject s3_Effect;
이 몬스터는 총 3개의 이팩트를 가지고 있고
bool isSkill1 = false;
float skill1Range = 4f;
public float skill1Delay = 10f;
bool isSkill2 = false;
float skill2Range = 10f;
public float skill2Delay = 30f;
bool isSkill3 = false;
float skill3Range = 4f;
public float skill3Delay = 60f;
그에 맞춰 총 3개의 스킬을 가지고 있습니다. 스킬은 각각 사용 중인지 판단하는 bool, 스킬이 발동될 수 있는 사정거리, 그리고 스킬의 쿨타임을 가지고 있습니다.
public enum SHEnemyState
{
Idle = 0,
Walk,
Detect,
Attack,
Skill1,
Skill2,
Skill3,
Dead,
Delay
};
이 몬스터의 경우는 총 9개의 상태를 가지고 있으며 각 상태에 따라 취하는 행동 또한 달라집니다.
void Update()
{
ClifCheck();
WallCheck();
void ClifCheck()
{
RaycastHit2D[] hits= Physics2D.RaycastAll(transform.position, dir + Vector2.down, 2);
for (int i = 0; i < hits.Length; i++)
{
if (hits[i].transform.gameObject.layer == LayerMask.NameToLayer("Ground"))
{
moveAble= true;
return;
}
}
moveAble = false;
return;
}
void WallCheck()
{
RaycastHit2D[] hits = Physics2D.RaycastAll(transform.position, dir, 2);
for (int i = 0; i < hits.Length; i++)
{
if (hits[i].transform.gameObject.layer == LayerMask.NameToLayer("Ground"))
{
dir -= 2 * dir ;
}
}
}
이 몬스터는 시작하자마자 2개의 체크를 시작하는데, 하나는 자신이 땅에 있는지, 다른 하나는 자신이 맵의 끝에 달했는지를 체크합니다. 둘 다 레이케스트 방식을 사용하고 있고, ClifCheck는 바닥을, WallCheck는 벽을 체크합니다.
Clif의 경우는 보스 몬스터가 땅에 닿아있을 때 움직이는 걸 허용하게 해줍니다.
이 보스 몬스터의 방은 양쪽이 다 벽으로 막혀있는 방식이기에 이를 방지하기 위해 WallCheck를 이용하여 보스가 벽을 보고 계속해서 움직이는 걸 방지해줍니다.
case SHEnemyState.Idle:
{
SetAni(true, false, false, false);
StartCoroutine(IdleWait(Random.Range(3, 5)));
break;
}
해당 코드는 보스 몬스터의 enum값에 따른 행동을 보여줍니다. 만약 Idle 상태라면 애니메이션 설정과 코루틴을 동작시켜 현재 Idle 상태에 맞는 에니메이션과 행동 패턴을 실행합니다.
void SetAni(bool idle, bool atk1, bool atk2, bool move)
{
ani.SetBool("Idle", idle);
ani.SetBool("Atk1", atk1);
ani.SetBool("Atk2", atk2);
ani.SetBool("Run", move);
}
위의 SetBool은 총 4개의 동작이 반복적으로 일어나 함수화시켜 간략하게 만든 것으로 위의 Idle상태를 예로 들면 첫 번째인 Idle에 해당하는 bool idle만 true로 바꿔 사용하고 있습니다.
아래는 관리 함수를 사용할 때와 사용하지 않았을 때의 비교입니다.
// Idle 상태 설정
ani.SetBool("Idle", true);
ani.SetBool("Atk1", false);
ani.SetBool("Atk2", false);
ani.SetBool("Run", false);
...
// Run상태 설정
ani.SetBool("Idle", false);
ani.SetBool("Atk1", false);
ani.SetBool("Atk2", false);
ani.SetBool("Run", true);
// 위의 함수 사용 시
// Idle상태 설정
SetAni(true, false, false, false);
...
// Run상태 설정
SetAni(false, false, false, true);
IEnumerator IdleWait(float s)
{
if (!isWaiting)
{
isWaiting = true;
yield return new WaitForSeconds(s);
dir = Random.Range(0, 2) == 0 ? Vector2.right : Vector2.left;
state = SHEnemyState.Walk;
yield return new WaitForSeconds(0.1f);
isWaiting = false;
}
}
IdleWait는 보스 몬스터가 Idle상태일 때 잠시 대기 후 보스 몬스터가 주변을 정찰하는 것처럼 만들기 위해 짜여진 코드입니다. idle상태가 되면 isWaiting은 true가 되고 주어진 s시간만큼 기다렸다가 랜덤값을 부여받고 오른쪽이나 왼쪽으로 이동하게 됩니다. 만약 이동값이 주어지게 되면 상태는 Walk가 되고 0.1초 뒤에 isWaiting상태는 다시 false가 됩니다.
case SHEnemyState.Walk:
{
if (!moveAble)
{
dir-=2*dir;
}
Move();
SearchFront();
break;
}
위의 코드는 Walk상태일때의 보스 패턴으로 if문에서 주어지는 조건은 벽에 막혀 움직일 수 없다면 반대 방향으로 움직이게 만들어주는 코드입니다.
void Move()
{
SetAni(false, false, false, true);
transform.Translate(dir * movSpeed * Time.deltaTime);
atkCol.GetComponent<BoxCollider2D>().offset = dir;
if (dir.x <0) GetComponent<SpriteRenderer>().flipX = true;
else if(dir.x >0) GetComponent<SpriteRenderer>().flipX = false;
}
위의 코드는 Move함수의 내용입니다. 우선 애니메이션을 move를 제외하고는 모두 false로 만들어준 뒤, 보스 몬스터에게 이동할 수 있는 물리적인 힘을 주고 그 아래에 AtkCol과 플레이어의 방향값을 조건으로 건 if, else문이 있는데 이는 보스몬스터가 보는 방향에 따라 스프라이트와 박스 콜라이더를 보스가 바라보는 방향에 맞게 위치를 변경해 주거나 돌려주는 역할을 수행합니다.
콜라이더의 경우는 dir 즉, 보스 몬스터가 보는 방향에 따라 달라지고, 스프라이트의 경우는 만약 방향값이 0보다 작을 경우 스프라이트의 x를 뒤집는 flipX를 true, 그 반대라면 false화 시켜 보스 몬스터의 애니메이션의 좌우를 반전시켜 줍니다.
void SearchFront()
{
RaycastHit2D[] hits = Physics2D.RaycastAll(transform.position, dir, viewRange);
for (int i = 0; i < hits.Length; i++)
{
if (hits[i].transform.tag == "Player")
{
state = SHEnemyState.Detect;
target = hits[i].transform.gameObject;
return;
}
}
}
이 코드 또한 위의 Clif나 Wall과 비슷한 구조이지만, 다른 점이라면 위의 두 Check는 보스 몬스터의 이동에 영항을 주는 함수였다면 이번엔 플레이어를 찾아 전투를 시작하게 만드는 일종의 전투를 시작하는 트리거 역할을 수행합니다.
for문을 돌려 레이캐스트 범위에 닿는 모든걸 배열화시켜 조사한 뒤 만일 태그가 플레이어라면 상태 값을 발견 상태로 바꾸고 타깃 게임 오브젝트를 플레이어로 지정시켜 보스전을 시작하게 만들었습니다.
case SHEnemyState.Detect:
{
if(target != null)
{
dir = new Vector2(target.transform.position.x - transform.position.x,0).normalized;
float dist = Vector2.Distance(transform.position, target.transform.position);
if(dist > traceRange)
{
target = null;
state = SHEnemyState.Idle;
return;
}
if (!isSkill2&&skillAble&& Random.Range(0f,100f)<0.1f)
{
state = SHEnemyState.Skill2;
break;
}
if (!isSkill3 && dist > skill3Range&& skillAble && hp<=50)
{
state = SHEnemyState.Skill3;
break;
}
if (dist <= atkRange && !isAtk)
{
state = SHEnemyState.Attack;
break;
}
if (dist <= skill1Range && !isSkill1&& skillAble && Random.Range(0f, 100f) < 0.3f)
{
state = SHEnemyState.Skill1;
break;
}
if (dist > atkRange) Move();
else SetAni(true, false, false, false);
}
위의 코드는 플레이어와 보스 몬스터간의 거리에 따라 상태 값을 바꿔주는 구역으로 대부분의 if문이 플레이어와의 거리 차이를 구하는 조건문과 지금이 공격 상태인지를 확인하는 bool로 구성되어 있습니다.
우선 SearchFront에서 tartget으로 지정한 플레이어의 위치를 정규화시킨 뒤 보스 몬스터 자신의 거리와 비교를 하여 어떤 패턴이 나갈지 결정하게 됩니다. 만일 플레이어와의 거리가 추적 거리보다 훨씬 멀어진다면 다시 Idle상태로 돌아가지만 그렇지 않고 공격을 이어 나간다면 계속해서 공격 패턴을 반복해서 이어 나게됩니다.
맨 아래는 플레이어가 거리를 벌리면 다시 따라가고 아니라면 다시 idle상태로 바꾸게 해줍니다.
case SHEnemyState.Attack:
{
skillAble = true;
StartCoroutine(NomalAtk());
break;
}
case SHEnemyState.Skill1:
{
skillAble = false;
StartCoroutine(Skill1());
break;
}
대부분의 스킬과 공격은 코루틴으로 되어 있고 각각 공격 중인지 확인하는 skillAble을 false로 만들어 패턴 중 다른 패턴이 나가는 걸 방지해줍니다. 스킬은 총 3개가 있지만 같은 내용만 들어가다 보니 두 개만 남기고 지운 상태입니다.
IEnumerator NomalAtk()
{
if (!isAtk)
{
SetAni(false, true, false, false);
if (atkCol.isTouch)
{
Debug.Log("HitPlayer");
Managers.Object.Player.OnDamaged(atkCol, 3);
}
state = SHEnemyState.Delay;
isAtk = true;
yield return new WaitForSeconds(1f);
state = SHEnemyState.Detect;
yield return new WaitForSeconds(atkDelay - 1f);
isAtk = false;
}
}
위에는 일반공격 함수로 우선 애니메이션을 공격 애니메이션으로 전환하고 만약 공격에 맞았다면 플레이어의 체력을 깎습니다. 공격 수행 직후 delay상태가 되고 isAtk를 true로 만든 뒤 1초를 기다렸다가 다시 발견상태로 바꾸고 추척을 이어 나가게 됩니다.
IEnumerator Skill1()
{
if (!isSkill1)
{
SetAni(true, false, false, false);
isSkill1 = true;
StartCoroutine(Charge(1));
yield return new WaitForSeconds(0.7f);
SetAni(false, false, true, false);
yield return new WaitForSeconds(0.3f);
rb.AddForce(dir*250f, ForceMode2D.Impulse);
state = SHEnemyState.Delay;
yield return new WaitForSeconds(1.5f);
state = SHEnemyState.Detect;
yield return new WaitForSeconds(skill1Delay);
isSkill1 = false;
}
}
해당 스킬의 경우는 각각의 텀 마다 딜레이를 길게 주었고, 만약 플레이어가 적중당하면 뒤로 넉백을 일으키는 스킬입니다.
IEnumerator Skill2()
{
if (!isSkill2)
{
//변수, 초기값, 애니메이션 설정 및 쉴드 이미지 생성
hit = 5;
SetAni(true, false, false, false);
isSkill2 = true;
StartCoroutine(Charge(3f));
GameObject myShield = Instantiate(s2_Shield, transform.position, Quaternion.identity);
myShield.transform.parent = this.transform;
//캐스팅 0.5초마다 검사
for (int i = 0; i < 6; i++)
{
if (hit<=0)
{
StopCoroutine("Charge");
Destroy(myShield);
break;
}
yield return new WaitForSeconds(0.5f);
if (i==5)
{
//방패 제거, 이펙트 생성, 플레이어 데미지
Destroy(myShield);
GameObject effect = Instantiate(s2_Effect, transform.position, Quaternion.identity);
Destroy(effect, 2f);
Managers.Object.Player.OnDamaged(atkCol, 10);
}
}
state = SHEnemyState.Detect;
yield return new WaitForSeconds(skill2Delay);
isSkill2 = false;
}
}
위의 코드는 두 번째 스킬로 플레이어가 보스를 바라볼 때는 방어자세를 유지한 뒤 플레이의 공격을 반격하는 기술입니다. 스킬 발동 시 잠시 Idle애니메이션을 취하다 총 0.5초의 6번의 검사를 걸쳐 공격을 확인하게 됩니다. 이 동작이 실행되면 적 보스에게는 방패 이미지가 생기며 반격 패턴이 시작했음을 알립니다. 만약 5번 동안 아무런 공격도 안 들어온다면 방어 자세를 해제한 뒤 플레이어를 공격하지만(if (i==5) 부분) 그 사이에 공격이 들어온다면 보스는 바로 Charge를 멈춘 뒤 플레이어들의 공격을 반격합니다.
IEnumerator Charge(flo
at time)
{
SpriteRenderer sprite = GetComponent<SpriteRenderer>();
for (float i = 1; i >= 0; i-=0.1f)
{
sprite.color = new Color(1,i,i);
yield return new WaitForSeconds(time/10);
}
sprite.color = Color.white;
}
아래는 Charge 함수인데 시간이 지남에 따라 점점 색상을 바꿔주는 역할을 수행합니다.
case SHEnemyState.Dead:
{
GameObject.Find("BackToVillage").GetComponent<SpriteRenderer>().enabled = true;
for (int i = 0; i < QuestManager.instance.curNpc.Count; i++)
{
if (QuestManager.instance.curNpc[i] != null)
{
QuestManager.instance.curNpc[i].GetComponent<NPC>().KillMonster(id);
}
}
Destroy(this.gameObject);
break;
}
해당 부분은 보스 캐릭터가 체력을 다해 사망했을 때의 상태입니다. 우선 마을로 돌아가는 포탈을 활성화하고, 만약 퀘스트가 있었다면 처치 카운트를 추가시켜 준 뒤 보스 오브젝트를 없애는 역할을 수행합니다.
public void Hit(int dmg)
{
hit--;
if (state != SHEnemyState.Skill2)
{
hp-=dmg;
}
if (hp<=0)
{
state = SHEnemyState.Dead;
}
}
아래가 바로 공격을 받았을 때 죽었는지 아닌지를 판별해 줍니다.
Comments