
이 시스템은 단순히 AI가 플레이어의 위치를 추격하는 것이 아닌 이전에 만들어 두었던 Blackboard를 이용해 AI끼리 정보를 공유하며 포위를 하며 추격하는 시스템입니다.
// AIBlackboard.cs
private Dictionary<Transform, float> assingedAngles = new Dictionary<Transform, float>();
public void RegisterAI(Transform ai)
{
if (!aiAgents.Contains(ai))
{
aiAgents.Add(ai);
ReassignFormationAngles();
}
}
private void ReassignFormationAngles()
{
assingedAngles.Clear();
if (aiAgents.Count == 0) return;
// AI 개수에 따라 균등하게 각도 분배
float angleStep = 360f / aiAgents.Count;
for (int i = 0; i < aiAgents.Count; i++)
{
float angle = (angleStep * i) + angleOffset;
assingedAngles[aiAgents[i]] = angle;
}
}
가장 중요한 AI의 수에 따른 각도 할당입니다.
왜 AI의 수에 따라 각도를 할당 해야 할까요? 제가 구현한 포위형 추격은 플레이어의 기준으로 원형을 만들어 원형을 기준으로 포위를 하는 식으로 구현해 뒀습니다.
예를 들어
AI 3개 : 360도 / 3 = 120도 씩
AI 1 = 0도
AI 2 = 120도
AI 3 = 240도
AI 4개 : 360 / 4 = 90도 씩
AI 1 = 0도 (동쪽)
AI 2 = 90도 (북쪽)
AI 3 = 180도 (서쪽)
AI 4 = 270도 (남쪽)
이런식으로 구현이 되기 때문에 AI의 수에 따라 각도를 할당해 누구하나 빠짐없이 포위를 할 수 있게 만들어 두었습니다.
// AIBlackboard.cs
public Vector3 GetFormationPosition(Transform self, Vector3 centerPos, float radius)
{
if (!assingedAngles.ContainsKey(self))
return centerPos;
float angle = assingedAngles[self];
//원형 좌표계 변환 (극좌표 → 직교좌표)
Vector3 offset = new Vector3(
Mathf.Cos(angle * Mathf.Deg2Rad), // X축 (좌우)
0, // Y축 (높이 고정)
Mathf.Sin(angle * Mathf.Deg2Rad) // Z축 (앞뒤)
) * radius;
Vector3 formationPos = centerPos + offset;
// NavMesh 위의 유효한 위치로 조정
UnityEngine.AI.NavMeshHit hit;
if(UnityEngine.AI.NavMesh.SamplePosition(formationPos, out hit, 2f, UnityEngine.AI.NavMesh.AllAreas))
{
return hit.position;
}
return formationPos;
극좌표(반지름, 각도)를 직교좌표(x,y,z)값으로 변환을 하였습니다.
X = radius x cos(angle)
z = radius x sin(angle)
y = 0 (지면 고정)
예시로 반지름 10cm, 각도 90이라면
X = 10 x cos(90) = 0
z = 10 x sin(90) = 10
-> 위치는 (0, 0, 10)으로 됩니다.
// Ai_Script.cs
private enum ChaseMode { WideFormation, NarrowFormation, DirectChase }
private ChaseMode currentChaseMode = ChaseMode.WideFormation;
public float modeTransitionBuffer = 1.5f; //핵심
switch (currentChaseMode)
{
case ChaseMode.WideFormation:
// 10.5m 이하에서 좁은 포위로 전환
if(distanceToPlayer <= formationDistance - modeTransitionBuffer)
{
currentChaseMode = ChaseMode.NarrowFormation;
MoveToFormation(6f);
}
else
{
MoveToFormation(12f);
}
break;
case ChaseMode.NarrowFormation:
// 13.5m 초과에서 넓은 포위로
if(distanceToPlayer > formationDistance + modeTransitionBuffer)
{
currentChaseMode = ChaseMode.WideFormation;
MoveToFormation(12f);
}
// 6.5m 이하에서 직접 추격으로
else if(distanceToPlayer <= directChaseDistance - modeTransitionBuffer)
{
currentChaseMode = ChaseMode.DirectChase;
DirectChase();
}
else
{
MoveToFormation(6f);
}
break;
case ChaseMode.DirectChase:
// 9.5m 초과에서 좁은 포위로
if(distanceToPlayer > directChaseDistance + modeTransitionBuffer)
{
currentChaseMode = ChaseMode.NarrowFormation;
MoveToFormation(6f);
}
else
{
DirectChase();
}
break;
히스테리시스 원리는 간단히 위치에 따른 AI의 행동 변화 입니다. 물론 AI의 우선 순위는 플레이어를 추격하는 것 이며, 추격하는 과정에서 포위를 해야 되는 것이기 때문에 플레이어와 가까이 있다면 포위가 아닌 단순 추격을 하는게 당연합니다.
NarrowFormation 상태
DirectChase 상태
// AIBlackboard.cs
public Vector3 GetFlockingDir(Transform self)
{
Vector3 separation = Vector3.zero; // 분리
Vector3 alignment = Vector3.zero; // 정렬
Vector3 cohesion = Vector3.zero; // 응집
int neighborCount = 0;
foreach (var agent in aiAgents)
{
if (agent == null || agent == self) continue;
float distance = Vector3.Distance(agent.position, self.position);
if (distance < neighborRadius && distance > 0.01f)
{
//Separation (분리) 너무 가까우면 밀어냄
Vector3 awayDir = (self.position - agent.position).normalized;
float separationStrength = 1f / (distance * distance); // 역제곱 법칙
separation += awayDir * separationStrength;
//Alignment (정렬) 이웃의 방향과 맞춤
if (agent.GetComponent<Ai_Script>() != null)
{
alignment += agent.forward;
}
//Cohesion (응집) 그룹 중심으로 이동
cohesion += agent.position;
neighborCount++;
}
}
// 평균 계산
if (neighborCount > 0)
{
alignment = (alignment / neighborCount).normalized;
cohesion = ((cohesion / neighborCount) - self.position).normalized;
}
//가중치 적용
Vector3 flockingDir =
separation * separationWeight + // 1.5 (가장 중요)
alignment * alignmentWeight + // 1.0
cohesion * cohesionWeight; // 1.0
return flockingDir.normalized;
Flocking 시스템의 가장 중요한 점은 크레이그 레이놀즈의 Boids알 고리즘을 이용한 것 입니다.
Separation (분리) - 너무 가까운 개체는 피한다
Alignment (정렬) - 이웃의 방향에 맞춘다
Cohesion (응집) - 이웃들과 뭉친다.
이 세가지 개념을 통해 Flocking기능을 구현하였습니다만, 이 세 가지 힘의 균형이 가장 중요하기 때문에 블로그를 쓰는 지금도 계속해서 수정하면서 개발중 입니다.
//Ai_Script.cs
void MoveToFormation(float radius)
{
if (AIBlackboard.Instance == null || !AIBlackboard.Instance.playerDetect)
return;
//1. 목표 위치 계산
Vector3 formationPos = AIBlackboard.Instance.GetFormationPosition(
transform,
player.position, // 플레이어 중심
radius // 포위 반경
);
currentFormationTarget = formationPos;
float distToFormation = Vector3.Distance(transform.position, formationPos);
if (distToFormation > formationReachThreshold) // 2m 초과
{
//2. 이동 중: Flocking 효과 적용
isInFormation = false;
Vector3 flockingDir = AIBlackboard.Instance.GetFlockingDir(transform);
Vector3 toFormation = (formationPos - transform.position).normalized;
//3. 포메이션 70% + Flocking 30% 혼합
Vector3 finalDir = (toFormation * 0.7f + flockingDir * 0.3f).normalized;
agent.SetDestination(formationPos);
// 이동 방향으로 회전
if (finalDir != Vector3.zero)
{
Quaternion targetRot = Quaternion.LookRotation(finalDir);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRot, Time.deltaTime * 5f);
}
}
else
{
//도착 플레이어 주시
isInFormation = true;
Vector3 lookDir = (player.position - transform.position).normalized;
lookDir.y = 0;
if (lookDir != Vector3.zero)
{
Quaternion targetRot = Quaternion.LookRotation(lookDir);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRot, Time.deltaTime * 5f);
}
agent.SetDestination(formationPos); // 위치 유지
}
}
지금 까지 만들어둔 기능인 Formation 과 Flocking 기능을 통합한 코드입니다.
중간 코드를 보시면 toFormation 70% + Flocking 30%로 혼합을 했습니다.
포메이션의 비중을 높히고 군집의 기능의 비중을 낮춘다면 목표 지향적이지만 AI끼리 부딛힐 수 있습니다.
반대로 한다면 충돌은 잘 피하지만 플레이어를 쫓아가 포위하는 목표 도달이 느릴 것 입니다.
// Ai_Script.cs - Update() 내부
if (isInFormation)
{
formationUpdateTimer += Time.deltaTime;
if (formationUpdateTimer >= 0.2f) //0.2초마다
{
formationUpdateTimer = 0f;
// 현재 거리에 맞는 반경으로 재계산
float currentRadius = distanceToPlayer > formationDistance ? 12f : 6f;
Vector3 newFormationPos = AIBlackboard.Instance.GetFormationPosition(
transform,
player.position, //항상 최신 위치
currentRadius
);
// 새 위치가 멀리 떨어져있으면 다시 이동
if (Vector3.Distance(transform.position, newFormationPos) > formationReachThreshold)
{
isInFormation = false; //이동 모드로 전환
agent.SetDestination(newFormationPos);
}
}
}
갱신이 필요한 이유는 Ai가 플레이어를 인식한 뒤 해당 포메이션으로 이동한 뒤 도착하게 되면 멈추게 됩니다. 그런 과정에서 플레이어가 이동을 한다면 플레이어의 위치는 달라지지만 Ai는 포메이션으로 도착을 하게 된 상황이 되므로 주변에 멈춰있습니다. 물론 Update() 내부에 이루어지는 과정이기에 드라마틱하게 멈춰있거나 하진 않지만 AI가 절절거릴 수 있습니다.
현재는 0.2초로 갱신을 진행하였고 이 부분도 계속해서 최적화가 필요한 부분입니다.
도달 후에도 계속 갱신을 해야 되기 때문에 플레이어의 움직임을 최종적으로 추적할 수 있습니다.
식으로 흘러가게 됩니다.
사실 지금까지 과정중 완벽한건 없습니다. 아직 AI가 플레이어를 쫓아오다가 플레이어가 멈추고 있으면 몇몇 AI가 정지상태가 되는 버그들도 있기 때문에 계속해서 수정해야 됩니다.
수정하는 부분이 있으면 그 부분도 추가로 블로그에 작성하도록 하겠습니다. ^^