[Unity] 다수의 적을 어떻게 움직일까(5) - 적 설계

2026. 9. 17. 19:37·Unity,C#/Unity 정보
728x90

■ 적 설계 구조

[적 구조]

모든 적 객체는 Enemy 추상 클래스를 상속받아 작성한다. 적은 모두 대미지를 받을 수 있으므로 IDamageable을 구현하고, 다른 객체를 밀어내는 쪽이므로 IRepulsionSource를 구현한다.

public interface IDamageable
{
    public void TakeDamage(float dmg);
}

[IDamageable.cs]

여기서 적들의 티어를 나눠, 군중 티어의 적들은 단순하지만 다량으로 몰려오도록 구현하고 정예 티어의 적들은 보다 다채롭게 행동하도록 구현한다.

  Tier3(군중) Tier2(필드) Tier1(정예)
상태 가지지 않음. 개체가 아닌 군집이 소유. 개체가 직접 소유.
조향 FlowField의 조향을 따름. Chase일 때만 FlowField, Move일 때는 정해진 방향. 개별 조향(회전반경, 관성).
특징 군집 객체, 스폰과 동시에 플레이어를 추격. 필드에 존재. (대기, 혹은 패트롤의 상태를 가짐). 고등 티어 객체. 단순한 움직임이 아닌 사실적으로 움직인다.
  • SwarmEnemy는 웨이브 단계에서 오로지 플레이어만 추적하는 적 종류다.
  • FieldEnemy는 정지 ↔ 이동 → 추격 상태를 가지며, 추격 상태로 들어오면 SwarmEnemy와 동일하게 행동한다.

 

1. Enemy.cs

using UnityEngine;
public abstract class Enemy : MonoBehaviour, IRepulsionSource, IDamageable
{
    public Vector3 Position => transform.position;
    public float BodyRadius => bodyRadius;
    public float Mass => mass;
    public bool IsDead => _isDead;
    
    [Header("Components")]
    [SerializeField] protected Animator animator;
    
    [Header("Config")] 
    [SerializeField] protected float maxHp = 100;
    [SerializeField, Min(0.1f)] protected float damage = 50f;
    [SerializeField, Min(0.1f)] protected float contactDistance = 3f;
    [SerializeField] protected float baseMoveSpeed = 5f;
    [SerializeField] protected float rotationSpeed;
    [SerializeField] protected float bodyRadius = 0.3f;
    [SerializeField, Tooltip("반발 저항 가중치"), Min(0.1f)] private float mass;
    
    private bool _isDead = false;
    private float _curHp;
}

모든 적이 밀려나야 하는 것은 아니므로, 최상위 Enemy 클래스는 IRepulsionSource만 구현한다.

여기서 Mass는 눈에 걸린다. 이 프로퍼티를 요구하는 것은 IRepulsionReceiver인데, Enemy는 그 인터페이스를 구현하지 않기 때문이다. 그럼에도 Enemy에 있어야 하는 이유는 겹침 해소 루프가 상대의 질량까지 읽기 때문이다.

var share = other.Mass / (self.Mass + other.Mass);

self는 밀려나는 쪽이라 IRepulsionReceiver가 맞지만, other는 공간 해시에서 나온 적 전원 중 하나라 밀려나지 않는 적일 수도 있다. 즉 질량은 "밀리는 능력"이 아니라 "미는 쪽에도 필요한 값"이라 한 단계 위인 Enemy에 둔다.

나머지는 적 행동에 필요한 멤버들이다. 중요한 것만 살펴보면 다음과 같다.

이름 역할
contactDistance 적은 플레이어를 만나면 자폭하며 데미지를 주는데, 자폭하게 되는 거리를 의미한다.
bodyRadius 몸의 반경. 겹침 판정 거리는 이 값이 아니라 두 객체의 반경 합(rMin = self.BodyRadius + other.BodyRadius)이고, 서로의 거리가 그보다 가까워지면 파고든 만큼 밀어낸다.
mass 이름의 뜻은 질량이지만, 이 값은 실제 kg 단위로 들어가는 것이 아니다. 이 값이 높을수록 다른 객체에 의해 덜 밀리거나 더 민다.

 

public abstract void Move(float dt);
public virtual void OnHeardNoise(Vector3 pos, float now) { }
public virtual void OnPlayerContact(Vector3 pos, IDamageable target) { }

자식 클래스는 Move 메서드(어떻게 움직일지)를 필수로 구현해야 하며, 나머지 둘은 외부에서 들어온 사실을 통보받는 창구다. 소리가 났다 / 플레이어 표면이 이 지점에 있다 까지만 알려주고, 그 사실로 무엇을 할지는 각 적이 정한다.

 

SwarmEnemy는 OnPlayerContact에서 거리를 재고 contactDistance 안이면 자폭하며, OnHeardNoise는 무시한다(웨이브 적이라 이미 플레이어를 쫓고 있다). 반대로 FieldEnemy는 OnHeardNoise로 깨어난다. 관심 없는 적이 아무것도 하지 않아도 되도록 둘 다 빈 virtual로 둔다.

public void TakeDamage(float dmg)
{
    if(_isDead) return;
    
    _curHp -= dmg;
    if (_curHp > 0) return;
    
    OnDead();
}

protected virtual void OnDead()
{
    _isDead = true;
    gameObject.SetActive(false);
}

protected virtual void OnSpawned()
{
    _isDead = false; 
    _curHp = maxHp;
}

protected virtual void OnDespawned() { _isDead = true; }

private void OnEnable()
{
    EnemyRegister.Instance.RegisterEnemy(this);
    OnSpawned();
}

private void OnDisable()
{
    var register = EnemyRegister.Instance;
    
    if(register != null)
        register.UnRegisterEnemy(this);
    
    OnDespawned();
}

명부 등록 과정은 부모 클래스에서 처리한다. 어떤 적 객체든 무조건 명부에 들어가야 겹침 해소, 반발력, 사망 처리 등이 가능하기 때문이다.

 

■ 군집 객체 - SwarmEnemy

using UnityEngine;

public class SwarmEnemy : Enemy, IRepulsionReceiver
{
		private Vector3 _correction;
    private Vector3 _obstacleForce;
    
    public void ApplyRepulsion(Vector3 correction, Vector3 obsForce)
    {
        _correction = correction;
        _obstacleForce = obsForce;
    }
}

군중 티어의 적은 다른 객체에 의해 밀리므로 IRepulsionReceiver를 구현한다. Mass는 부모 클래스에 이미 있으므로, 자식에선 ApplyRepulsion()만 구현한다.

 

ApplyRepulsion()은 EnemyRegister에서 계산된 겹침 해소·장애물 조향값을 받는 창구다. 계산은 명부가 하고 적은 결과만 받아 들고 있다가 자기 Move 안에서 쓴다.

private void Awake()
{
		_moveSpeed = baseMoveSpeed * Random.Range(0.8f, 1.3f);
		animator.SetBool("IsMove", true);
}

군중 티어의 적은 스폰된 순간부터 매 프레임 방향장을 읽어 플레이어를 추격한다. 이때 전부 동일한 이동속도를 가지면 대열이 통째로 한 덩어리처럼 움직여 어색하므로, 기본 이동속도에 0.8~1.3의 랜덤한 배율을 곱해 개체마다 속도를 다르게 설정한다.

 

1. 자폭 판정

public override void OnPlayerContact(Vector3 pos, IDamageable target)
{
		if (IsDead) return;
		
		var distance = pos - Position;
		distance.y = 0;
		        
		if (distance.sqrMagnitude > contactDistance * contactDistance)
				return;
		        
		target.TakeDamage(damage);
		OnDead();
}

플레이어(선체)와 일정 거리 안이 되면 데미지를 준 뒤 자기 자신을 사망 처리한다(자폭이므로).

  • IsDead 가드 : 죽은 개체가 명부에서 실제로 빠지는 것은 프레임 끝이라, 이미 자폭한 적이 같은 프레임에 한 번 더 통보받을 수 있다. 가드가 없으면 대미지가 두 번 들어간다.
  • distance.y = 0 : 판정을 수평 평면에서 한다. 탱크에는 레이캐스트 서스펜션이 붙어 있어 차체가 상하로 출렁이는데, 3D 거리로 재면 같은 자리에 서 있는 적의 자폭 거리가 노면 상태에 따라 계속 변한다.

여기서 중요한 점은 "플레이어와의 거리를 누가, 어떻게 재는가"다.

각 적이 플레이어 참조를 들고 스스로 거리를 재게 만들면 플레이어에서 100m 떨어진 적까지 매 프레임 거리 계산을 한다. 또한, 적이 플레이어를 찾아 들고 있는 순간 그 결합은 객체가 살아 있는 내내 유지된다.

 

그래서 반대로 플레이어가 자기 주변을 질의해 적에게 통보한다.

public class PlayerHull : MonoBehaviour, IDamageable
{
    [SerializeField] private Collider hullCollider;
    [SerializeField] private float contactRadius = 10f;
    
    private List<Enemy> _buffer;

    private void Start()
    {
        _buffer = new List<Enemy>();
    }

    private void Update()
    {
        var pos = transform.position;
        EnemyRegister.Instance.QueryForRadius(pos, contactRadius, _buffer);
        
        foreach (var e in _buffer)
        {
            var surfacePoint = hullCollider.ClosestPoint(e.Position);
            e.OnPlayerContact(surfacePoint, this);
        }
    }
}

[PlayerHull.cs]

플레이어가 검사할 범위를 지정하고 매 프레임 질의하면, 모든 적이 아니라 범위 안으로 들어온 적에 대해서만 거리 검사가 일어난다. 그리고 통보할 때 this를 IDamageable로 넘기므로, 적은 "때릴 수 있는 무언가"를 호출이 지속되는 동안만 손에 쥔다. 적 클래스 어디에도 플레이어를 가리키는 필드는 없다.

[플레이어와 거리 체크]

여기서 넘기는 위치는 중점이 아닌, 차체 표면 위의 가장 가까운 점(ClosestPoint)이다. 차체의 중점(pos)를 넘긴다면 같은 contactDistance 여도 특정 방향에서 오는 적은 차체 콜라이더에 막힌 채 더 이상 다가가지 못해 자폭하지 못한다.

 

또한 우리는 적들이 어디 있는지 알고 있으므로, Physics.OverlapSphere 계열을 쓸 필요 없이 EnemyRegister에 범위 질의 함수 하나만 만들어 주면 된다.

public void QueryForRadius(Vector3 center, float radius, List<Enemy> buffer)
{
		_enemyHash.QueryForRadius(center, radius, buffer);   
}

[EnemyRegister.cs]

    public void QueryForRadius(Vector3 center, float radius, List<T> result)
    {
        result.Clear();
        var sqrRadius = radius * radius;
        
        var minCx = Mathf.FloorToInt((center.x - radius) / _cellSize);
        var maxCx = Mathf.FloorToInt((center.x + radius) / _cellSize);
        var minCy = Mathf.FloorToInt((center.z - radius) / _cellSize);
        var maxCy = Mathf.FloorToInt((center.z + radius) / _cellSize);

        for (var cx = minCx; cx <= maxCx; ++cx)
        {
            for (var cy = minCy; cy <= maxCy; ++cy)
            {
                if(!_buckets.TryGetValue(Key(cx, cy), out var list)) continue;

                foreach (var item in list)
                {
                    var d = item.Position - center;
                    d.y = 0;

                    if (d.sqrMagnitude <= sqrRadius)
                    {
                        result.Add(item);
                    }
                }
            }
        }
    }

[SpatialHash.cs]

[QueryForRadius()]

질의는 2단계다. 곡선인 원형 범위를 셀 단위로 정확히 잘라낼 방법은 없으니, 1차로 원을 감싸는 정사각형(경계 상자)이 덮는 셀을 전부 순회하고, 2차로 그 안의 객체를 하나씩 실제 거리로 걸러낸다.

  • 1차는 후보를 줄이는 단계이고 정답은 2차가 낸다.
var d = item.Position - center;
d.y = 0;

if (d.sqrMagnitude <= sqrRadius)

객체와 중점 사이의 거리를 재서, 이 거리가 radius 이하인 경우만 원형 범위 안에 있다고 본다.

  • 거리의 대소 관계만 비교하므로 sqrMagnitude를 쓴다. 반지름의 제곱은 루프 밖에서 한 번만 구한다.
  • 여기서도 d.y = 0으로 평면화한다. 이 판정의 소비자(자폭, HE 폭발)가 전부 평면 기준이기 때문이다.

 

■ 군집 객체의 이동 코드

적이 플레이어를 추격할 때 쓰는 방향장 외에도, EnemyRegister에서 계산되어 ApplyRepulsion으로 넘어온 겹침 해소와 장애물 조향력을 함께 처리해야 한다.

 

"조향"과 "겹침 해소"는 성질이 다른 두 이동이다. 이 둘을 한 번에 더해 처리하지 않고 순차적으로 적용해, 두 성질이 서로를 지우지 않고 모두 반영되게 구현한다.

 

1. 막힌 셀에서의 탈출

using UnityEngine;

public class SwarmEnemy : Enemy, IRepulsionReceiver
{
    public override void Move(float dt)
    {
        var f = FlowField.Instance;
        if (f == null)
            return;

        var pos = transform.position;
        var step = _moveSpeed * dt;

        // 내가 있는 공간이 막힌 공간이라면, 방향장에 따라 이동.
        if (f.IsBlocked(pos))
        {
            transform.position += ToFlowVector(f.GetCurrentCellDirection(pos)) * step;
            return;
        }
    }
    
    private Vector3 ToFlowVector(Vector2Int vec) => 
        new Vector3(vec.x, 0, vec.y).normalized;
}

적이 통행 불가능한 셀 안에 있는 경우(IsBlocked(pos) == true)는 겹침 해소도 조향력도 적용하지 않고, 오직 셀의 방향만 따라간다. 그래야 막힌 공간을 탈출할 수 있기 때문이다.

  • 겹침 해소는 겹친 몸을 떼어 놓는 것만 보고 지형은 보지 않기 때문에, 밀도가 높은 순간 보정이 막힌 곳으로 밀어 넣을 수 있다.

ToFlowVector()는 방향장 벡터(Vector2Int)를 3차원 벡터로 바꾼다. 격자가 XZ 평면에 깔려 있으므로 vec.y가 z축으로 간다. 대각 방향이 (1, 1)처럼 들어오므로 .normalized로 길이를 1로 맞춘다.

 

한 프레임에 움직일 이동량(step)과 현재 위치(pos)는 이후에도 계속 쓰이므로 미리 지역 변수로 캐싱해 둔다. 특히 transform.position은 접근할 때마다 엔진 쪽을 타므로 한 번만 읽는다.

 

2. 조향 방향 만들기

using UnityEngine;

public class SwarmEnemy : Enemy, IRepulsionReceiver
{
    [SerializeField, Range(0.1f, 0.95f)] private float maxObsForce = 0.9f;

    public override void Move(float dt)
    {
        // 셀이 막힌 경우 코드 생략...
        
        var (dir, speedScale) = GetDesiredMove(pos, f);
        var clampedObsForce = Vector3.ClampMagnitude(_obstacleForce, maxObsForce);
    }
    
    /// dir = 단위벡터 또는 영벡터(크기를 태우지 말 것), speedScale = 0~1
    protected virtual (Vector3 dir, float speedScale) GetDesiredMove(Vector3 pos, 
        FlowField f)
    {
        return (ToFlowVector(f.GetCurrentCellDirection(pos)), 1f);
    }
}

GetDesiredMove는 템플릿 메서드로, SwarmEnemy에서는 현재 셀의 방향장과 정배속(speedScale = 1)을 돌려준다.

 

GetDesiredMove 템플릿 메서드로 만든 이유

FieldEnemy는 SwarmEnemy를 상속하지만 "추격" 외의 상태를 더 가진다. 그런데 Move()는 추격에 맞춰 짜여 있으므로, 그대로 물려받으면 "이동" 상태를 위해 같은 일을 하는 메서드를 하나 더 만들어야 한다.

  • 같은 과정을 한번 더 반복하는 메서드를 만들어야 한다.

상위 클래스가 뼈대를 쥐고 "이번 프레임에 어디로 갈 것인가"라는 한 단계만 재정의하게 열어 두면, FieldEnemy는 Move() 하나로 동일한 처리를 받는다. 실제로 FieldEnemy가 오버라이드하는 것은 이 메서드 하나뿐이다.

 

반환값이 방향과 배속의 쌍인 이유는 정지 상태를 “짧은 dir”로 표현하면 안되기 때문이다. 크기를 태우는 순간 후에 계산되는 장애물 힘과의 비율이 깨지고 회전도 흔들린다.

  • 정지는 speedScale = 0 으로 표현.

 

3. 조향 (dt 있음)

public override void Move(float dt)
{
		// 셀이 막힌 경우 코드 생략...
		// 조향 방향 만들기 생략...
		
		// 1단계 : 조향 - dt 있음
		var steerDir = (dir + clampedObsForce).normalized;
		transform.position += SlideAlongWalls(pos,steerDir * (step * speedScale),f);
}

두 벡터를 더한 뒤 정규화하므로, 장애물 조향력은 크기에 관여하지 않고 방향만 튼다. 장애물 옆을 지난다고 적이 빨라지지는 않는다. maxObsForce는 이때 틀 수 있는 각도의 상한이 된다.

 

▶ maxObsForce 의 상한

dir은 항상 크기가 1인 벡터이다. dir + clampedObsForce는 dir의 끝점 $P_1$에서 clampedObsForce 만큼 이동한 벡터이다.

이 벡터의 끝점을 $P_2$라고 하면, $P_2$가 놓일 수 있는 범위는 $P_1$을 중심으로 하는 반지름 maxObsForce인 원 안이다.

  • ClampMagnitude가 clampedObsForce의 크기를 maxObsForce 이하로 자르므로.

이 벡터를 정규화하면 결국 원점에서 $P_2$를 향하는, 크기가 1인 벡터(steerDir)가 된다. 즉 조향으로 방향이 얼마나 틀어지는지는 원점에서 이 원을 볼 때 벌어지는 각도와 같다.

[최대로 벌어지는 지점]

원점에서 원에 접선을 하나 그어 보자. 접점에서 중심 $P_1$까지 그은 반지름은 접선과 항상 직각이다. 그래서 원점 · 중심$P_1$ · 접점 세 점이 직각삼각형을 이루고, 직각은 접점에 있다. 각 변의 길이는 다음과 같다.

  • 빗변 = 원점에서 중심 $P_1$까지의 거리 $= |dir| = 1$
  • 원점에서의 각 $\theta$의 대변 = 반지름 = maxObsForce = 0.9

여기서 $\theta$는 dir과 steerDir 사이의 각, 즉 조향으로 틀어진 각도다. 대변과 빗변을 알고 있으니

이고, 각도를 구하면 $\theta = \arcsin(0.9) \approx 64.16^\circ$가 된다. 이 값이 한 프레임에 조향이 방향을 틀 수 있는 최대치다.

 

반지름이 1에 가까워질수록 이 각도는 급격히 커지고, 1에 도달하면 원점이 원 위에 올라온다.

이때 $\sin\theta = 1$이라 각도 상한은 90°가 되고, 조향력이 dir의 정확히 반대 방향이면 합이 영벡터가 된다. 반지름이 1을 넘으면 원점이 원 안으로 들어가므로 접선 자체가 존재하지 않는다. 각도 상한이 사라져, 합 벡터가 dir의 반대쪽을 포함한 어느 방향이든 될 수 있다.

  • 합이 영벡터가 되어도 오류가 없다. Unity의 .normalized는 영벡터를 영벡터 그대로 돌려주므로, 적은 경고 하나 없이 조용히 굳는다. 상한을 1 미만으로 두는 것은 이 무증상 정지를 구조적으로 불가능하게 만드는 장치다.

다만 이렇게 구한 이동량을 바로 쓰지 않고, 그대로 움직였을 때 통행 불가 셀로 들어가는지 한 번 더 검사한다.

private Vector3 SlideAlongWalls(Vector3 pos, Vector3 desired, FlowField f)
{
    var probeX = pos + ProbeOffset(desired.x, 0);
    var probeZ = pos + ProbeOffset(0, desired.z);
    
    var blockX = f.IsBlocked(probeX);
    var blockZ = f.IsBlocked(probeZ);

    if (blockX) desired.x = 0;
    if (blockZ) desired.z = 0;

    return desired;
}

private Vector3 ProbeOffset(float dx, float dz)
{
    return new Vector3(
        dx != 0 ? Mathf.Sign(dx) * (Mathf.Abs(dx) + bodyRadius) : 0,
        0,
        dz != 0 ? Mathf.Sign(dz) * (Mathf.Abs(dz) + bodyRadius) : 0);
}

검사는 X축과 Z축을 따로 한다. X축을 검사할 땐 ProbeOffset(desired.x, 0)처럼 dz에 0을, Z축을 검사할 땐 dx에 0을 넣어, 각 축의 이동만 반영한 지점을 따로 만든다.

dx != 0 ? Mathf.Sign(dx) * (Mathf.Abs(dx) + bodyRadius) : 0,

[해당 축으로 이동한 방향 검사]

ProbeOffset은 그 축의 진행 방향으로 얼마나 앞을 찔러볼지를 계산한다.

  1. Mathf.Sign(dx) : 이동 방향의 부호로, 양수면 오른쪽, 음수면 왼쪽처럼 진행 방향 앞쪽을 향하게 한다.
  2. Mathf.Abs(dx) : 이번 프레임에 그 축으로 실제 이동할 거리이고 여기에 적의 몸 반지름 _radius를 더한다.

bodyRadius를 더하지 않으면 몸 중심이 셀 경계에 닿는 시점, 즉 몸 절반이 이미 통행 불가 셀에 파묻힌 뒤에야 막힘으로 판단한다. bodyRadius를 더해 몸 앞면 기준으로 앞당겨야 몸이 셀에 닿는 순간에 막힘이 걸린다.

 

마지막으로 dx != 0 ? ... : 0 삼항 조건은 그 축으로 이동이 없으면 오프셋을 0으로 둬, 움직이지 않는 축은 찔러보지 않게 한다.

var blockX = FlowField.IsBlocked(probeX);
var blockZ = FlowField.IsBlocked(probeZ);
		
if (blockX) desired.x = 0;
if (blockZ) desired.z = 0;

찔러본 지점이 이동 불가 셀이면 그 축의 이동을 0으로 만들어, 막힌 방향으로는 더 이상 나아가지 못하게 한다. 두 축을 독립적으로 처리하므로, X는 막혔지만 Z는 열려 있으면 X만 멈추고 Z 이동은 살아남는다. 벽에 정면으로 부딪혀도 완전히 멈추지 않고 벽을 따라 옆으로 미끄러지는 움직임이 나온다.

 

4. 겹침 해소 (dt 없음)

var pos2 = transform.position;
transform.position += SlideAlongWalls(pos2, _correction * correctionStiffens, f);

_correction에는 dt가 곱해지지 않는다. 조향은 "이 방향으로 초속 몇 미터"라는 속도지만, 겹침 해소는 "지금 이만큼 파고들어 있으니 그만큼 빼낸다"는 변위이기 때문이다. 프레임이 길든 짧든 빼내야 할 양은 같다.

  • correctionStiffens : 계산된 보정을 한 번에 얼마나 반영할지 정하는 완화 계수. (현재 1)

위치를 pos2로 다시 읽는 이유는 1단계에서 transform.position이 이미 움직였기 때문이다. 옛 pos로 벽을 검사하면 한 프레임 전 자리를 기준으로 판정하게 된다.

 

보정에도 SlideAlongWalls가 붙는다. 겹침 해소는 옆 적에게서 멀어지는 변위일 뿐 벽을 고려한 값이 아니라서, 그대로 더하면 옆 적에게 밀려 건물 안으로 들어간다. 조향에만 벽 검사를 걸면 절반만 막는 셈이다.

 

5. 회전 처리

public override void Move(float dt)
{
		// 코드 생략...
		
		if(dir.sqrMagnitude > 0.0001f)
				RotationMoveDir(dir, dt);
}

private void RotationMoveDir(Vector3 dir, float dt)
{
		var target = Quaternion.LookRotation(dir);
				transform.rotation = Quaternion.RotateTowards
				(transform.rotation, target, dt * rotationSpeed);
}      

이동이 끝난 뒤 마지막으로 몸의 방향을 돌린다. Quaternion.RotateTowards의 세 번째 인자는 이번 프레임에 돌 수 있는 최대 각도(도)이므로, dt * rotationSpeed는 "초당 rotationSpeed도"라는 각속도 제한이 된다.

 

회전의 기준은 steerDir가 아니라 dir인데, steerDir에는 장애물 조향력이 섞여 있어서, 그걸 그대로 보게 하면 장애물 옆을 스칠 때마다 몸이 휙휙 꺾인다.

  • 겹침 해소도 마찬가지이며, 적은 항상 자기가 가려는 곳만 바라본다.

dir.sqrMagnitude > 0.0001f 가드는 정지 상태를 위한 것이다. dir이 영벡터일 때 LookRotation은 경고를 뱉고 회전을 만들지 못한다.

 

■ 전체 코드 및 결과

[적 스웜 결과]

using UnityEngine;

public abstract class Enemy : MonoBehaviour, IRepulsionSource, IDamageable
{
    public Vector3 Position => transform.position;
    public float BodyRadius => bodyRadius;
    public float Mass => mass;
    public bool IsDead => _isDead;
    
    [Header("Components")]
    [SerializeField] protected Animator animator;

    [Header("Config")] 
    [SerializeField] protected float maxHp = 100;
    [SerializeField, Min(0.1f)] protected float damage = 50f;
    [SerializeField, Min(0.1f)] protected float contactDistance = 3f;
    [SerializeField] protected float baseMoveSpeed = 5f;
    [SerializeField] protected float rotationSpeed;
    [SerializeField] protected float bodyRadius = 0.3f;
    [SerializeField, Tooltip("반발 저항 가중치"), Min(0.1f)] private float mass;
    
    private bool _isDead = false;
    private float _curHp;

    public void TakeDamage(float dmg)
    {
        if(_isDead) return;
        
        _curHp -= dmg;
        if (_curHp > 0) return;
        
        OnDead();
    }

    public abstract void Move(float dt);

    public virtual void OnHeardNoise(Vector3 pos, float now) { }

    public virtual void OnPlayerContact(Vector3 pos, IDamageable target) { }

    protected virtual void OnDead()
    {
        _isDead = true;
        gameObject.SetActive(false);
    }

    protected virtual void OnSpawned()
    {
        _isDead = false; 
        _curHp = maxHp;
    }
    
    protected virtual void OnDespawned() { _isDead = true; }

    private void OnEnable()
    {
        EnemyRegister.Instance.RegisterEnemy(this);
        OnSpawned();
    }

    private void OnDisable()
    {
        var register = EnemyRegister.Instance;
        
        if(register != null)
            register.UnRegisterEnemy(this);
        
        OnDespawned();
    }
}

[Enemy.cs]

using System;
using Unity.Profiling;
using UnityEngine;
using Random = UnityEngine.Random;

public class SwarmEnemy : Enemy, IRepulsionReceiver
{
    [SerializeField, Range(0.1f, 0.95f)] private float maxObsForce = 0.9f;
    [SerializeField] private float correctionStiffens = 1f;
    
    private float _moveSpeed;
    private Vector3 _correction;
    private Vector3 _obstacleForce;
 
    public void ApplyRepulsion(Vector3 correction, Vector3 obsForce)
    {
        _correction = correction;
        _obstacleForce = obsForce;
    }

    private void Awake()
    {
        _moveSpeed = baseMoveSpeed * Random.Range(0.8f, 1.3f);
    }

    public override void OnPlayerContact(Vector3 pos, IDamageable target)
    {
        if (IsDead) return;

        var distance = pos - Position;
        distance.y = 0;
        
        if (distance.sqrMagnitude > contactDistance * contactDistance)
            return;
        
        target.TakeDamage(damage);
        OnDead();
    }

    public override void Move(float dt)
    {
        var f = FlowField.Instance;
        if (f == null)
            return;
        
        var pos = transform.position;
        var step = _moveSpeed * dt;

        if (f.IsBlocked(pos))
        {
            transform.position += ToFlowVector(f.GetCurrentCellDirection(pos)) * step;
            return;
        }
        
        var (dir, speedScale) = GetDesiredMove(pos, f);
        var clampedObsForce = Vector3.ClampMagnitude(_obstacleForce, maxObsForce);
        
        // 1단계 : 조향 - dt 있음
        var steerDir = (dir + clampedObsForce).normalized;
        transform.position += SlideAlongWalls(pos, steerDir * (step * speedScale), f);
        
        // 2단계 : 겹침 해소 - dt 없음
        var pos2 = transform.position;
        transform.position += SlideAlongWalls(pos2, _correction * correctionStiffens, f);
        
        if(dir.sqrMagnitude > 0.0001f)
            RotationMoveDir(dir, dt);
    }

    /// dir = 단위벡터 또는 영벡터(크기를 태우지 말 것), speedScale = 0~1
    protected virtual (Vector3 dir, float speedScale) GetDesiredMove(Vector3 pos, FlowField f)
    {
        return (ToFlowVector(f.GetCurrentCellDirection(pos)), 1f);
    }

    private Vector3 SlideAlongWalls(Vector3 pos, Vector3 desired, FlowField f)
    {
        var probeX = pos + ProbeOffset(desired.x, 0);
        var probeZ = pos + ProbeOffset(0, desired.z);
        
        var blockX = f.IsBlocked(probeX);
        var blockZ = f.IsBlocked(probeZ);

        if (blockX) desired.x = 0;
        if (blockZ) desired.z = 0;

        return desired;
    }

    private void RotationMoveDir(Vector3 dir, float dt)
    {
        var target = Quaternion.LookRotation(dir);
        transform.rotation = Quaternion.RotateTowards
            (transform.rotation, target, dt * rotationSpeed);
    }
    
    private Vector3 ProbeOffset(float dx, float dz)
    {
        return new Vector3(
            dx != 0 ? Mathf.Sign(dx) * (Mathf.Abs(dx) + bodyRadius) : 0,
            0,
            dz != 0 ? Mathf.Sign(dz) * (Mathf.Abs(dz) + bodyRadius) : 0);
    }
    
    private Vector3 ToFlowVector(Vector2Int vec) => 
		    new Vector3(vec.x, 0, vec.y).normalized;
}

[SwarmEnemy.cs]

728x90

'Unity,C# > Unity 정보' 카테고리의 다른 글

[Unity] 다수의 적을 어떻게 움직일까(7) - 방향장 보간 처리  (0) 2026.09.18
[Unity] 다수의 적을 어떻게 움직일까(6) - 필드 몹  (0) 2026.09.18
[Unity] 다수의 적을 어떻게 움직일까(4) - 겹침 해소 · 장애물 조향  (0) 2026.09.17
[Unity] 다수의 적을 어떻게 움직일까(3) - 적 관리 시스템  (0) 2026.08.03
[Unity] 다수의 적을 어떻게 움직일까(2) - 공간 해시 그리드  (0) 2026.08.03
'Unity,C#/Unity 정보' 카테고리의 다른 글
  • [Unity] 다수의 적을 어떻게 움직일까(7) - 방향장 보간 처리
  • [Unity] 다수의 적을 어떻게 움직일까(6) - 필드 몹
  • [Unity] 다수의 적을 어떻게 움직일까(4) - 겹침 해소 · 장애물 조향
  • [Unity] 다수의 적을 어떻게 움직일까(3) - 적 관리 시스템
브라더스톤
브라더스톤
유티니, C#과 관련한 여러 정보를 끄적여둔 블로그입니다. Email : dkavmdk98@gmail.com
  • 브라더스톤
    젊은 프로그래머의 슬픔
    브라더스톤
  • 전체
    오늘
    어제
    • 개발 노트 (69)
      • Unity,C# (44)
        • Unity 정보 (17)
        • 알고리즘 (11)
        • 자료구조 (3)
        • 절차적생성(PCG) (13)
      • 게임수학 (16)
      • C++ (8)
        • 자료구조 (8)
      • 게임 (1)
        • 리치마작 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    벡터
    정렬알고리즘
    BSP
    커스텀 윈도우
    최단경로찾기
    이진공간분할
    PerlinNoise
    pcg
    외적
    게임수학
    절차적지형생성
    c++
    C#
    CustomWindow
    알고리즘
    스택
    절차적생성
    절차적던전생성
    unity
    자료구조
  • 최근 댓글

  • 최근 글

  • 250x250
  • hELLO· Designed By정상우.v4.10.3
브라더스톤
[Unity] 다수의 적을 어떻게 움직일까(5) - 적 설계
상단으로

티스토리툴바