■ 필드 몹

웨이브로 몰려오는 군중 티어(3티어) 외에, 게임이 시작될 때 이미 맵 위에 놓여 있는 적을 구현한다. 웨이브만 있으면 도시 탐험의 페널티가 없기 때문이다.
필드 몹은 상태를 개체가 아니라 군집이 소유하는 티어인데, 특징은 다음과 같다.
- 개별로 행동하지 않고, 10~30마리 안팎의 소규모 군집이 하나의 상태를 공유한다. 상태를 바꾸는 주체는 군집이고, 개체는 그 변화를 언제 반영할지만 정한다.
- 정지 ↔ 이동 전이는 목적지 도달이 아니라 타이머로 이루어진다.
- 추격 상태로의 전이는 시야가 아니라 플레이어가 내는 소음이 일으키며, 한 번 추격에 들어가면 되돌아오지 않는다.
■ EnemyGroup
군집 하나의 상태와 이동 방향을 소유하는 클래스다. 개체는 이 값을 읽어 쓸 뿐 바꾸지 않는다.
EnemyGroup은 MonoBehaviour를 상속받지 않는 POCO 클래스다. 군집은 씬에 놓인 물체가 아니라 상태 덩어리라서 Transform도 렌더러도 필요 없고, 매 프레임 해야 할 일은 EnemyRegister가 대신 돌려주기 때문이다.
private void Update()
{
_enemyHash.Clear();
FlushPending();
foreach (var group in _fieldEnemyGroup)
{
group.Tick(Time.time);
}
}
[EnemyRegister.cs]
적의 이동을 매니저가 대신 돌리듯, 군집의 시간도 매니저가 돌린다. Tick()은 지금 상태를 유지할지 다음 상태로 넘길지를 판정하는 메서드이며, 인다로 절대 시각을 넘긴다.
참조 방향은 한쪽뿐이다. 개체는 자신이 속한 군집을 알지만, 군집은 소속된 개체의 목록을 갖지 않는다. 군집이 하는 일은 "지금 이동 중이고 방향은 이쪽"이라는 값 하나를 들고 있는 것뿐이라, 그 값을 누가 읽어 가는지는 알 필요가 없다. 덕분에 개체가 죽어도 목록에서 빼 줄 일이 없다.
public class EnemyRegister : MonoBehaviour
{
private List<EnemyGroup> _fieldEnemyGroup;
private List<EnemyGroup> _pendingGroup;
private void EnsureInit()
{
if (_enemyList != null) return;
// 다른 멤버 초기화 생략..
_fieldEnemyGroup = new List<EnemyGroup>();
_pendingGroup = new List<EnemyGroup>();
}
public void RegisterFieldEnemyGroup(EnemyGroup group)
{
_fieldEnemyGroup.Add(group);
}
public void UnRegisterFieldEnemyGroup(EnemyGroup group)
{
_pendingGroup.Add(group);
}
private void FlushPending()
{
foreach (var e in _pendingRemove)
{
_enemyList.Remove(e);
if(e is IRepulsionReceiver r) _repulsionsReceivers.Remove(r);
}
foreach (var g in _pendingGroup)
{
_fieldEnemyGroup.Remove(g);
}
_pendingRemove.Clear();
_pendingGroup.Clear();
}
}
[EnemyRegister.cs]
군집의 수명은 위 두 리스트가 관리한다. 스포너가 군집을 만들면서 RegisterFieldEnemyGroup()으로 명부에 올리고, 소속된 적이 전멸하면 명부에서 내린다.
- EnemyGroup 역시 Update()에서 순회를 돌기 때문에, 순회 도중 삭제되는 것을 막기 위해 지연 삭제를 적용시킨다.
1. 상태와 전이
public enum EGroupState
{
Idle,
Move,
Chase
}
[GroupState.cs]
군집은 정지(Idle), 이동(Move), 추격(Chase) 세 상태를 가진다. 이 절에서 다루는 것은 앞의 둘이고, Chase는 타이머가 아니라 소음으로 들어오는 상태라 따로 다룬다.
public class EnemyGroup
{
public EGroupState State { get; private set; }
private readonly GroupConfig _groupConfig;
private float _transitionTime;
private float _nextTransitionAt;
// 이동 방향·군집 수명에 쓰이는 멤버 생략..
public EnemyGroup(GroupConfig groupConfig, Vector3 center, int aliveCount, float now)
{
_groupConfig = groupConfig;
_homeCenter = center; // 이동 방향을 고를 때 쓴다. 다음 절에서
_lastKnownPos = _homeCenter;
_aliveCount = aliveCount; // 군집 수명에 쓴다. 뒤 절에서
State = Random.value < 0.5f ? EGroupState.Idle : EGroupState.Move;
EnterState(now);
}
}
생성자에서는 그룹 설정값(SO)을 받아 초기화한다. 초기 상태를 Idle과 Move 중 랜덤으로 정한다. 모든 군집이 같은 상태로 시작하면 지속 시간까지 비슷하게 굴러가 맵 전체가 한 박자로 멈췄다 움직이기 때문이다. 시작점을 흩어 두면 이후로도 겹치지 않는다.
using UnityEngine;
[CreateAssetMenu(fileName = "GroupConfig", menuName = "Scriptable Objects/GroupConfig")]
public class GroupConfig : ScriptableObject
{
[SerializeField, Min(1)] private float idleDurationMinTime;
[SerializeField, Min(1.1f)] private float idleDurationMaxTime;
[SerializeField, Min(1)] private float moveDurationMinTime;
[SerializeField, Min(1.1f)] private float moveDurationMaxTime;
[SerializeField] private float minDelayTime;
[SerializeField] private float maxDelayTime;
[SerializeField, Range(0.01f, 1f)] private float moveSpeedScale = 0.4f;
public float IdleDurationMinTime => idleDurationMinTime;
public float IdleDurationMaxTime => idleDurationMaxTime;
public float MoveDurationMinTime => moveDurationMinTime;
public float MoveDurationMaxTime => moveDurationMaxTime;
public float MinDelayTime => minDelayTime;
public float MaxDelayTime => maxDelayTime;
}
[GroupConfig.cs]
그룹 설정값으로는 다음과 같은 멤버가 사용된다.
| 이름 | 설명 |
| idleDurationMinTime, idleDurationMaxTime | Idle 상태를 유지할 최소, 최대 시간 |
| moveDurationMinTime, moveDurationMaxTime | Move 상태를 유지할 최소, 최대 시간 |
| minDelayTime, maxDelayTime | 개체가 상태 변화를 반영하기까지 걸리는 최소~최대 지연 시간 |
| moveSpeedScale | 이동 상태에서의 이동 속도 가중치 |
아래 두 줄은 지금은 쓰지 않는다. 지연 시간은 릴레이를, 속도 가중치는 이동 방향을 다룰 때 각각 꺼낸다.
private void EnterState(float now)
{
var rnd = State == EGroupState.Idle ? Random.Range(_groupConfig.IdleDurationMinTime, _groupConfig.IdleDurationMaxTime) :
Random.Range(_groupConfig.MoveDurationMinTime, _groupConfig.MoveDurationMaxTime);
if(State == EGroupState.Move)
PickMoveDir();
_transitionTime = now;
_nextTransitionAt = _transitionTime + rnd;
_checkPoint = _lastKnownPos; // 진행도 검사에 쓴다. 뒤 절에서
_nextProgressCheckAt = now + _groupConfig.ProgressCheckInterval;
}
상태에 진입하면 그 상태를 유지할 시간을 랜덤으로 뽑고, 전이가 일어난 시각(_transitionTime)과 다음 전이가 일어날 시각(_nextTransitionAt)을 기록한다. 이동 상태라면 군집이 이동할 방향을 고르는데, 이는 바로 이어서 설명한다.
여기서 상태를 끝내는 조건은 “목표 지점까지의 도착”이 나닌 “시간”이다. 목적지를 정해 놓는다면, 도착하지 못했을 때의 행동도 구현해야 한다. 따라서 군집은 목적지 없이 방향과 시간만 가지고 움직인다.
public void Tick(float now)
{
// 추격 상태 처리, 진행도 검사 생략..
if (now < _nextTransitionAt) return;
_prevState = State;
State = State == EGroupState.Idle ? EGroupState.Move : EGroupState.Idle;
EnterState(now);
}
Tick()은 정해진 시각이 될 때까지 아무 일도 하지 않다가, 시각이 지나면 상태를 반대쪽으로 뒤집고 다시 EnterState()를 호출한다.
2. 이동 방향과 범위
이동 방향은 EnterState()가 Move 상태로 진입할 때 한 번 정해지고, 그 상태가 끝날 때까지 바뀌지 않는다. 매 프레임 방향을 다시 뽑으면 제자리에서 떠는 것처럼 보이기 때문이다.
private void PickMoveDir()
{
var angle = Random.Range(0f, Mathf.PI * 2f);
var randDir = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle));
// 복귀 판정 생략..
}
angle은 $0\le \theta \le 2\pi(360^{\circ})$ 범위의 라디안 값을 반환한다. 이 각도의 $\cos, \sin$을 각각 x, z 요소로 사용하면 길이가 1인 랜덤한 방향벡터가 나온다.
매번 랜덤한 방향으로 이동하면 군집은 생성된 위치에서 얼마든지 멀어질 수 있다. 따라서 스폰된 지점(_homeCenter)에서 일정 거리 이상 벌어지면 다시 그쪽으로 향하도록 만든다.
public void ReportPosition(Vector3 pos) => _lastKnownPos = pos;
private void PickMoveDir()
{
// 랜덤 방향 생략..
var toSpawn = _homeCenter - _lastKnownPos;
toSpawn.y = 0f;
var d = toSpawn.magnitude;
var returnChance = Mathf.InverseLerp
(_groupConfig.BiasStartDistance, _groupConfig.BiasFullDistance, d);
if (Random.value < returnChance)
{
var centerDir = toSpawn.normalized;
var randomAngle = Random.Range(-60f, 60f);
MoveDir = Quaternion.Euler(0f, randomAngle, 0f) * centerDir;
}
else
{
MoveDir = randDir;
}
}

var returnChance = Mathf.InverseLerp
(_groupConfig.BiasStartDistance, _groupConfig.BiasFullDistance, d);
if (Random.value < returnChance)
{
var centerDir = toSpawn.normalized;
var randomAngle = Random.Range(-60f, 60f);
MoveDir = Quaternion.Euler(0f, randomAngle, 0f) * centerDir;
}
군집은 멤버 목록이 없으니 위치를 스스로 알 수 없다. 그래서 개체가 이동할 때마다 자기 위치를 군집에 보고하는데, 배열이 아닌 단일 변수 하나라 마지막 보고만 남는다.
그 위치(_lastKnownPos)에서 스폰 지점까지의 거리 d가 biasStartDistance 이하면 복귀 확률은 0으로 자유롭게 배회하고, biasFullDistance에 이르면 1이 된다.
- InverseLerp(a, b, v)는 v가 a~b 구간의 몇 % 인지를 0~1로 돌려주는 함수라, 두 거리 사이에서 확률이 선형으로 올라간다.

복귀로 결정돼도 스폰 지점을 정확히 향하지는 않고 -60~60도로 튼 방향으로 간다. 매번 같은 선을 왕복하는 궤적은 피하면서, 60도 안쪽이라 복귀 성분은 항상 남아 여러 스텝에 걸쳐 조금씩 안쪽으로 끌려온다.
using UnityEngine;
[CreateAssetMenu(fileName = "GroupConfig", menuName = "Scriptable Objects/GroupConfig")]
public class GroupConfig : ScriptableObject
{
// 상태 지속 시간 설정 생략..
[Header("Cluster movement settings")]
[SerializeField, Min(0.1f)] private float biasStartDistance = 10f;
[SerializeField, Min(0.1f)] private float biasFullDistance = 30f;
public float BiasStartDistance => biasStartDistance;
public float BiasFullDistance => biasFullDistance;
}
| 이름 | 설명 |
| biasStartDistance | 복귀 확률이 0인 거리. 이 안에서는 완전히 자유롭게 배회한다 |
| biasFullDistance | 복귀 확률이 1이 되는 거리 |
public Vector3 MoveDir { get; private set; }
public float MoveSpeedScale => _groupConfig.MoveSpeedScale;
마지막으로, 군집이 개체에게 알려주는 값은 방향과 속도 가중치 둘이다. 개체는 이 속도와 가중치를 받아 이동하게 된다.
3. 릴레이 전환
군집의 상태가 바뀌면 개체는 그 값을 읽어 다르게 행동한다. 그런데 모두가 정확히 같은 순간에 반영해 버리면 여러 마리가 아니라 한 덩어리가 통째로 움직이는 것처럼 보인다.
그래서 개체마다 고유한 지연 시간을 줘서, 상태는 군집이 바꾸되 개체는 그것을 언제 반영할지만 정하도록 했다.
public class FieldEnemy : SwarmEnemy
{
private EnemyGroup _enemyGroup;
private float _delayTime;
private readonly int _isMoveAnim = Animator.StringToHash("IsMove");
public void Init(EnemyGroup enemyGroup, float delayTime)
{
_enemyGroup = enemyGroup;
_delayTime = delayTime;
}
}
[FieldEnemy.cs]
FieldEnemy는 생성될 때 자신이 속한 군집과 고유한 지연 시간을 군집으로부터 주입받는다.
public float GetRelayDelay(int index, int count)
{
return count > 1 ? Mathf.Lerp(_groupConfig.MinDelayTime,
_groupConfig.MaxDelayTime, (float)index / (count - 1)) : 0f;
}
[EnemyGroup.cs]
지연 시간은 랜덤이 아니라 인덱스에 따라 최소~최대 구간에 고르게 퍼진 값이다. 첫 번째 개체가 minDelayTime, 마지막 개체가 maxDelayTime을 받는다. 랜덤으로 뽑으면 반영 시점이 산발적으로 흩어져 그냥 제각각으로 보이지만, 등간격이면 앞쪽부터 순서대로 움직이기 시작해 물결처럼 번지는 모양이 나온다.
- 한 마리뿐인 군집이면 0으로 나누게 되므로, 지연 없이 0을 준다.
protected override (Vector3 dir, float speedScale) GetDesiredMove
(Vector3 pos, FlowField f)
{
_enemyGroup.ReportPosition(pos);
var result = (Vector3.zero, 0f);
var isMove = false;
switch (_enemyGroup.EffectiveStateFor(_delayTime, Time.time))
{
case EGroupState.Idle:
break;
case EGroupState.Move:
isMove = true;
result = (_enemyGroup.MoveDir, _enemyGroup.MoveSpeedScale);
break;
case EGroupState.Chase:
isMove = true;
result = base.GetDesiredMove(pos, f);
break;
}
animator.SetBool(_isMoveAnim, isMove);
return result;
}
[FieldEnemy.cs]
앞에서 만든 GetDesiredMove() 메서드를 오버라이드 한다. 이동 코드는 그대로 두고 방향을 정하는 부분만 군집의 상태에 따라 갈라진다.
- Idle은 아무것도 하지 않는다. 반환값의 초기값이 (Vector3.zero, 0f)라서, 속도 가중치 0이 곧 정지가 된다.
- Move는 군집이 정해 둔 방향과 속도 가중치를 그대로 쓴다.
- Chase는 부모 구현을 호출한다. 즉 추격 중일 때만 3티어와 같은 방향장 조향을 따른다.
상태를 읽는 시점이 곧 반영 시점이므로, 판정은 EffectiveStateFor()가 한다.
public class EnemyGroup
{
public EGroupState EffectiveStateFor(float delay, float now)
=> now >= _transitionTime + delay ? State : _prevState;
}
전이가 일어난 시각(_transitionTime)에 자신의 지연을 더한 값이 지났으면 새 상태를, 아직이면 직전 상태를 돌려준다. GetDesiredMove()는 매 프레임 불리니 별도의 타이머 없이 이 한 줄로 각 개체의 반영 시점이 갈린다.
Tick()에서 _prevState를 남겨둔 이유도, 군집의 state는 이미 바뀌어 있어서, 직전 상태를 따로 들고 있지 않으면 지연이 남은 객체가 읽을 값이 새 상태밖에 없다.
4. 진행도 체크
이동 방향은 방향만 정할 뿐 목적지가 없기 때문에, 그 방향이 벽으로 막혀 있는지를 군집은 알 수 없다. 막힌 방향이 뽑히면 상태가 끝날 때까지 벽에 붙어 비비게 된다.
그래서 주기적으로 얼마나 나아갔는지를 재고, 충분히 나아가지 못했으면 상태를 일찍 끝낸다.
public void Tick(float now)
{
if (State == EGroupState.Chase)
return;
if (State == EGroupState.Move && now >= _nextProgressCheckAt)
{
var progress = Vector3.Dot(_lastKnownPos - _checkPoint, MoveDir);
if (progress < _groupConfig.MinProgressPerCheck)
{
_nextTransitionAt = now;
}
else
{
_checkPoint = _lastKnownPos;
_nextProgressCheckAt = now + _groupConfig.ProgressCheckInterval;
}
}
// 상태 전이 생략..
}
맨 위의 조기 반환은 추격 상태에 대한 것이다. 추격 중에는 진행도 검사도, 타이머에 의한 전이도 하지 않는다. 이 부분은 마지막 절에서 다룬다.

MoveDir이 단위벡터이므로, Vector3.Dot(변위, MoveDir)의 결과는 변위를 MoveDir 축에 정사영한 부호 있는 길이가 된다. 즉, _checkPoint에서 MoveDir 방향으로 "얼마큼 이동했냐?"를 구할 수 있다. 옆으로 샌 성분은 버려지고, 뒤로 밀렸다면 음수가 된다.
이동한 거리의 크기(magnitude)로 재면 안 되는 이유가 있는데, 벽에 막힌 적은 멈춰 서는 것이 아니라 벽을 따라 미끄러지므로, 실제로는 계속 움직이고 있기 때문이다.
반면 정사영은 정면으로 막힌 경우 구조적으로 정확히 0이 된다.

progress가 minProgressPerCheck 보다 작다면, 충분히 나아가지 못했다는 뜻이므로 _nextTransitionAt에 현재 시각을 넣는다.
그러면 아래 전이 블록의 if (now < _nextTransitionAt) return;이 이번 프레임에 바로 통과되어, 진행 중이던 이동 상태가 그 자리에서 끝난다. 상태는 Idle로 넘어가고, 다음에 다시 Move로 진입할 때 EnterState()가 새 방향을 뽑는다.
충분히 나아갔다면 _checkPoint를 현재 위치로 옮기고 다음 검사 시각을 잡는다. 검사 기준점이 계속 앞으로 따라오는 셈이라, 검사는 직전 구간에서 나아간 거리만 본다.
private Vector3 _lastKnownPos;
private Vector3 _checkPoint;
public EnemyGroup(GroupConfig groupConfig, Vector3 center, int aliveCount, float now)
{
_groupConfig = groupConfig;
_homeCenter = center;
_lastKnownPos = _homeCenter;
_aliveCount = aliveCount;
State = Random.value < 0.5f ? EGroupState.Idle : EGroupState.Move;
EnterState(now);
}
- 이 두 값은 EnterState()에서도 초기화된다
using UnityEngine;
[CreateAssetMenu(fileName = "GroupConfig", menuName = "Scriptable Objects/GroupConfig")]
public class GroupConfig : ScriptableObject
{
// 상태 지속 시간·배회 범위 설정 생략..
[SerializeField, Min(0.1f)] private float progressCheckInterval = 4f;
[SerializeField, Min(0.1f)] private float minProgressPerCheck = 2.4f;
public float ProgressCheckInterval => progressCheckInterval;
public float MinProgressPerCheck => minProgressPerCheck;
}
[GroupConfig.cs]
| 이름 | 설명 |
| progressCheckInterval | 진행도를 검사하는 주기 |
| minProgressPerCheck | 한 주기 동안 나아가야 하는 최소 거리 |
minProgressPerCheck의 기본값 2.4는 임의로 정한 값이 아니라, 4초 동안 아무 방해 없이 갔을 때의 거리에서 역산한 것이다. 이동 속도 5에 속도 가중치 0.4를 곱하고 주기 4초를 곱하면 8m가 나오는데, 그 30%를 기준으로 삼았다.
■ 추격 상태
FieldEnemy는 플레이어가 내는 소음을 들으면 추격 상태로 전환된다.
public class FieldEnemy : SwarmEnemy
{
public override void OnHeardNoise(Vector3 pos, float now)
{
if (IsDead) return;
_enemyGroup.OnHeardNoise(now);
}
}
[FieldEnemy.cs]
소리를 듣는 것은 개체지만 상태를 바꾸는 것은 군집이다. 그래서 개체는 들었다는 사실만 넘기고 끝낸다. 한 마리만 들어도 군집 전체가 추격에 들어가는 것이 여기서 따라온다.
public void OnHeardNoise(float now)
{
if (State == EGroupState.Chase) return;
_prevState = State;
State = EGroupState.Chase;
_transitionTime = now;
}
[EnemyGroup.cs]
첫 줄의 조기 반환과 Tick() 맨 위의 조기 반환이 짝을 이룬다. 추격으로 들어가면 타이머 전이도, 진행도 검사도, 다시 듣는 것도 하지 않는다. 한 번 들어가면 되돌아오는 경로가 없는 단방향 전환이고, 그것이 두 개의 return으로 강제된다.
_prevState와 _transitionTime을 앞의 전이와 똑같이 갱신하므로, 추격도 릴레이를 탄다. 소리를 들은 군집은 전부가 동시에 뛰어나가는 것이 아니라 앞쪽부터 차례로 방향을 튼다.
using System.Collections.Generic;
using UnityEngine;
public class NoiseSystem
{
public static NoiseSystem Instance { get; } = new();
private Queue<(Vector3, float)> _pending;
private List<Enemy> _buffer;
private bool _isEmitting = false;
private NoiseSystem()
{
_buffer = new List<Enemy>();
_pending = new Queue<(Vector3, float)>();
_isEmitting = false;
}
public void Emit(Vector3 pos, float radius)
{
if (_isEmitting)
{
_pending.Enqueue((pos, radius));
return;
}
_isEmitting = true;
try
{
Dispatch((pos, radius));
while (_pending.Count > 0)
Dispatch(_pending.Dequeue());
}
finally
{
_isEmitting = false;
}
}
private void Dispatch((Vector3 pos, float radius) item)
{
EnemyRegister.Instance.QueryForRadius(item.pos, item.radius, _buffer);
foreach (var e in _buffer)
{
e.OnHeardNoise(item.pos, Time.time);
}
}
}
[NoiseSystem.cs]
소리를 내는 쪽과 듣는 쪽 사이에는 NoiseSystem 하나만 있다. 소리를 내는 쪽은 위치와 반경만 넘기고, 누가 그 소리를 어떻게 해석할지는 전혀 모른다. Dispatch()는 공간 해시에 반경 질의를 던져 범위 안의 적을 뽑고 OnHeardNoise()를 호출할 뿐이다. 발신자는 사실만 알리고 해석은 수신자가 한다.
_isEmitting과 큐는 재진입을 막기 위한 것이다. _buffer를 하나만 두고 재사용하는데, 소리를 들은 쪽이 그 자리에서 또 소리를 내면 순회 중인 버퍼가 덮여 버린다. 그래서 이미 발신 중이면 큐에 넣어 두고, 진행 중인 발신이 끝난 뒤에 이어서 처리한다.
■ 필드 적 스포너
군집은 게임이 시작될 때 도시 위에 흩뿌려진다. 어디에 몇 마리를 놓을지 정하는 것이 FieldEnemySpawner 이다.
public class FieldEnemySpawner : MonoBehaviour
{
[Header("Spawn Field Enemy")]
[SerializeField] private List<FieldEnemy> fieldEnemies;
[Header("Field Spawn Config")]
[SerializeField] private int clusterCount;
[SerializeField] private float minDistanceClusters;
[SerializeField, Min(1)] private int minFieldSpawnCount;
[SerializeField, Min(2)] private int maxFieldSpawnCount;
[SerializeField] private float radiusScale = 0.4f;
[SerializeField, Min(1)] private int maxAttemptsCount;
[Header("Enemy Group Transition Config")]
[SerializeField] private GroupConfig groupConfig;
private CityLayout _layout;
private EnemyRegister _enemyRegister;
private List<Vector2Int> _spawnPoints = new List<Vector2Int>();
private const float GoldenAngleRad = 2.39996f;
private void Start()
{
var cityGenerator = FindAnyObjectByType<CityGenerator>();
_enemyRegister = FindAnyObjectByType<EnemyRegister>();
// null 검사 생략..
_layout = cityGenerator.CityLayout;
if(_layout != null)
SpawnFieldEnemy();
}
}
| 이름 | 설명 |
| fieldEnemies | 스폰할 필드 몹 프리팹 목록. 이 중에서 랜덤으로 뽑아 생성한다 |
| clusterCount | 맵에 만들 군집의 개수 |
| minDistanceClusters | 군집끼리 떨어져야 하는 최소 거리(셀 단위) |
| minFieldSpawnCount, maxFieldSpawnCount | 군집 하나에 들어갈 최소, 최대 마리 수 |
| radiusScale | 군집 내부 배치가 퍼지는 정도 |
| maxAttemptsCount | 스폰 지점 탐색 재시도 횟수 |
Awake()가 아니라 Start()에서 시작하는 것이 중요하다. 스폰 지점을 고르려면 어느 칸이 도로인지 알아야 하는데, 그 격자는 도시 생성기가 만들어 준다. 두 스크립트의 Awake() 순서는 보장되지 않으므로, 모든 Awake()가 끝난 뒤에 불리는 Start()에서 격자를 읽는다.
1. 스폰 지점 고르기
private void SpawnFieldEnemy()
{
for (var i = 0; i < clusterCount; i++)
{
var sp = new Vector2Int();
var isSuccess = false;
for (var t = 0; t < maxAttemptsCount; t++)
{
if (!TryGetSpawnCellIndex(out sp))
continue;
if (CheckValidSpawnPoint(sp))
{
isSuccess = true;
break;
}
}
if (!isSuccess)
{
Debug.LogWarning($"클러스터 {i} 배치 실패");
continue;
}
_spawnPoints.Add(sp);
CreateClusters(sp);
}
}
[FieldEnemy.cs]
군집 하나를 놓을 때마다 후보 칸을 뽑아 조건에 맞는지 확인하고, 맞지 않으면 다시 뽑는다. 여기서 재시도 상한을 while 조건이 아니라 for 루프와 플래그로 만들었다.
이유는 "조건에 맞을 때까지 반복"으로 쓰면 조건에 맞는 칸이 하나도 없는 맵에서 게임이 그대로 멈춘다. 반복 횟수를 루프 구조가 쥐고 있으면 실패는 무한 대기가 아니라 경고 한 줄과 함께 다음 군집으로 넘어가는 일이 된다.
private bool TryGetSpawnCellIndex(out Vector2Int spawnPoint)
{
for (var i = 0; i < maxAttemptsCount; i++)
{
var x = Random.Range(0, _layout.Width);
var y = Random.Range(0, _layout.Height);
if (_layout.Cells[x, y] == ECellType.Road)
{
spawnPoint = new Vector2Int(x, y);
return true;
}
}
spawnPoint = default;
return false;
}
private bool CheckValidSpawnPoint(Vector2Int spawnPoint)
{
if (_spawnPoints.Contains(spawnPoint)) return false;
foreach (var s in _spawnPoints)
{
var d = (s - spawnPoint).magnitude;
if (d <= minDistanceClusters)
return false;
}
return true;
}
[FieldEnemySpawner.cs]
조건은 두 가지다. 먼저 도로 칸이어야 한다. 건물 칸에 놓으면 적이 벽 안에서 시작하고, 앞 장에서 만든 복귀 방향에 기대 빠져나오게 되는데 그건 사고 처리용이지 정상 경로가 아니다.
다음으로 이미 놓은 군집들과 충분히 떨어져 있어야 한다. 군집이 붙어서 생성되면 배회 중에 서로 섞여 하나의 큰 무리처럼 보이고, 소리 한 번에 두 군집이 동시에 깨어난다. 거리 비교는 셀 인덱스끼리 하므로 minDistanceClusters의 단위는 미터가 아니라 칸 수다.
2. 군집 만들기
private void CreateClusters(Vector2Int spawnPoint)
{
var parent = new GameObject($"Cluster[{spawnPoint.x},{spawnPoint.y}]");
parent.transform.SetParent(transform);
var pos = CityLayout.ConvertCellPosToWorld(spawnPoint.x, spawnPoint.y, _layout.CellSize);
var spawnCount = Random.Range(minFieldSpawnCount, maxFieldSpawnCount + 1);
var group = new EnemyGroup(groupConfig, pos, spawnCount, Time.time);
_enemyRegister.RegisterFieldEnemyGroup(group);
for (var i = 0; i < spawnCount; i++)
{
var obj = Instantiate(fieldEnemies[Random.Range(0, fieldEnemies.Count)], parent.transform);
obj.Init(group, group.GetRelayDelay(i, spawnCount));
var angle = i * GoldenAngleRad;
var radius = Mathf.Sqrt(i) * radiusScale;
var x = Mathf.Cos(angle) * radius;
var z = Mathf.Sin(angle) * radius;
obj.transform.position = new Vector3(pos.x + x, 0, pos.z + z);
obj.gameObject.SetActive(true);
}
}
[FieldEnemySpawner.cs]
칸 인덱스를 월드 좌표로 바꾸고, 그 자리에 군집 하나를 만들어 명부에 올린다. 이때 넘기는 spawnCount가 앞서 본 _aliveCount의 초기값이 되고, 이 수만큼 죽으면 군집이 명부에서 내려간다.
개체를 만드는 순서는 생성 → 주입 → 위치 → 활성화다. 활성화가 마지막인 이유는, 적이 켜지는 순간 OnEnable()에서 명부에 등록되어 그 프레임부터 이동 처리를 받기 때문이다. 소속 군집과 위치가 그전에 확정돼 있지 않으면 첫 프레임에 엉뚱한 곳에서 움직이거나 참조가 비어 있는 채로 갱신된다.
for (var i = 0; i < spawnCount; i++)
{
var obj = Instantiate(fieldEnemies[Random.Range(0, fieldEnemies.Count)],
parent.transform);
obj.Init(group, group.GetRelayDelay(i, spawnCount));
// 황금각 나선
var angle = i * GoldenAngleRad;
var radius = Mathf.Sqrt(i) * radiusScale;
var x = Mathf.Cos(angle) * radius;
var z = Mathf.Sin(angle) * radius;
obj.transform.position = new Vector3(pos.x + x, 0, pos.z + z);
obj.gameObject.SetActive(true);
}
군집 내부 배치에는 황금각 나선을 썼다. i번째 개체를 중심에서 √i × radiusScale만큼 떨어뜨리고, 각도는 매번 황금각(약 137.5°)씩 돌린다. 반지름을 √i로 늘리면 바깥으로 갈수록 둘레가 길어지는 만큼 개체 수도 늘어나 밀도가 고르게 유지되고, 황금각은 어떤 두 개체도 같은 각도에 놓이지 않게 만든다.
다만 이 배치는 마리 수의 상한을 만든다. 마지막 개체의 반지름이 √(n-1) × radiusScale이므로 마리 수를 늘릴수록 군집이 넓게 퍼지고, 어느 지점을 넘으면 도로 폭을 벗어나 건물 안에 개체가 생긴다.
그래서 더 큰 무리를 원할 때 한 군집의 마리 수를 올리는 것이 아니라, clusterCount를 늘려 군집의 수를 늘린다.
■ 전체 코드
using UnityEngine;
public class EnemyGroup
{
public EGroupState State { get; private set; }
public EGroupState EffectiveStateFor(float delay, float now)
=> now >= _transitionTime + delay ? State : _prevState;
public void ReportPosition(Vector3 pos) => _lastKnownPos = pos;
public Vector3 MoveDir { get; private set; }
public float MoveSpeedScale => _groupConfig.MoveSpeedScale;
private readonly GroupConfig _groupConfig;
private int _aliveCount;
private readonly Vector3 _homeCenter;
private Vector3 _lastKnownPos;
private Vector3 _checkPoint;
private float _nextProgressCheckAt;
private float _transitionTime;
private float _nextTransitionAt;
private EGroupState _prevState;
public EnemyGroup(GroupConfig groupConfig, Vector3 center, int aliveCount, float now)
{
_groupConfig = groupConfig;
_homeCenter = center;
_lastKnownPos = _homeCenter;
_aliveCount = aliveCount;
State = Random.value < 0.5f ? EGroupState.Idle : EGroupState.Move;
EnterState(now);
}
public void Tick(float now)
{
if (State == EGroupState.Chase)
return;
if (State == EGroupState.Move && now >= _nextProgressCheckAt)
{
var progress = Vector3.Dot(_lastKnownPos - _checkPoint, MoveDir);
if (progress < _groupConfig.MinProgressPerCheck)
{
_nextTransitionAt = now;
}
else
{
_checkPoint = _lastKnownPos;
_nextProgressCheckAt = now + _groupConfig.ProgressCheckInterval;
}
}
if (now < _nextTransitionAt) return;
_prevState = State;
State = State == EGroupState.Idle ? EGroupState.Move : EGroupState.Idle;
EnterState(now);
}
public float GetRelayDelay(int index, int count)
{
return count > 1 ? Mathf.Lerp(_groupConfig.MinDelayTime, _groupConfig.MaxDelayTime, (float)index / (count - 1))
: 0f;
}
public void OnHeardNoise(float now)
{
if (State == EGroupState.Chase) return;
_prevState = State;
State = EGroupState.Chase;
_transitionTime = now;
}
public void OnFieldEnemyDead()
{
_aliveCount--;
if (_aliveCount <= 0)
EnemyRegister.Instance.UnRegisterFieldEnemyGroup(this);
}
private void PickMoveDir()
{
var angle = Random.Range(0f, Mathf.PI * 2f);
var randDir = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle));
var toSpawn = _homeCenter - _lastKnownPos;
toSpawn.y = 0f;
var d = toSpawn.magnitude;
var returnChance = Mathf.InverseLerp(_groupConfig.BiasStartDistance, _groupConfig.BiasFullDistance, d);
if (Random.value < returnChance)
{
var centerDir = toSpawn.normalized;
var randomAngle = Random.Range(-60f, 60f);
MoveDir = Quaternion.Euler(0f, randomAngle, 0f) * centerDir;
}
else
{
MoveDir = randDir;
}
}
private void EnterState(float now)
{
var rnd = State == EGroupState.Idle ? Random.Range(_groupConfig.IdleDurationMinTime, _groupConfig.IdleDurationMaxTime) :
Random.Range(_groupConfig.MoveDurationMinTime, _groupConfig.MoveDurationMaxTime);
if(State == EGroupState.Move)
PickMoveDir();
_transitionTime = now;
_nextTransitionAt = _transitionTime + rnd;
_checkPoint = _lastKnownPos;
_nextProgressCheckAt = now + _groupConfig.ProgressCheckInterval;
}
}
using UnityEngine;
[CreateAssetMenu(fileName = "GroupConfig", menuName = "Scriptable Objects/GroupConfig")]
public class GroupConfig : ScriptableObject
{
[SerializeField, Min(1)] private float idleDurationMinTime;
[SerializeField, Min(1.1f)] private float idleDurationMaxTime;
[SerializeField, Min(1)] private float moveDurationMinTime;
[SerializeField, Min(1.1f)] private float moveDurationMaxTime;
[SerializeField] private float minDelayTime;
[SerializeField] private float maxDelayTime;
[SerializeField, Range(0.01f, 1f)] private float moveSpeedScale = 0.4f;
[Header("Cluster movement settings")]
[SerializeField, Min(0.1f)] private float biasStartDistance = 10f;
[SerializeField, Min(0.1f)] private float biasFullDistance = 30f;
[SerializeField, Min(0.1f)] private float progressCheckInterval = 4f;
[SerializeField, Min(0.1f)] private float minProgressPerCheck = 2.4f; // 5(speed) × 0.4(scale) × 4(interval) × 0.3
public float IdleDurationMinTime => idleDurationMinTime;
public float IdleDurationMaxTime => idleDurationMaxTime;
public float MoveDurationMinTime => moveDurationMinTime;
public float MoveDurationMaxTime => moveDurationMaxTime;
public float MinDelayTime => minDelayTime;
public float MaxDelayTime => maxDelayTime;
public float MoveSpeedScale => moveSpeedScale;
public float BiasStartDistance => biasStartDistance;
public float BiasFullDistance => biasFullDistance;
public float ProgressCheckInterval => progressCheckInterval;
public float MinProgressPerCheck => minProgressPerCheck;
}
using UnityEngine;
public class FieldEnemy : SwarmEnemy
{
private EnemyGroup _enemyGroup;
private float _delayTime;
private readonly int _isMoveAnim = Animator.StringToHash("IsMove");
public void Init(EnemyGroup enemyGroup, float delayTime)
{
_enemyGroup = enemyGroup;
_delayTime = delayTime;
}
public override void OnHeardNoise(Vector3 pos, float now)
{
if (IsDead) return;
_enemyGroup.OnHeardNoise(now);
}
protected override (Vector3 dir, float speedScale) GetDesiredMove(Vector3 pos, FlowField f)
{
_enemyGroup.ReportPosition(pos);
var result = (Vector3.zero, 0f);
var isMove = false;
switch (_enemyGroup.EffectiveStateFor(_delayTime, Time.time))
{
case EGroupState.Idle:
break;
case EGroupState.Move:
isMove = true;
result = (_enemyGroup.MoveDir, _enemyGroup.MoveSpeedScale);
break;
case EGroupState.Chase:
isMove = true;
result = base.GetDesiredMove(pos, f);
break;
}
animator.SetBool(_isMoveAnim, isMove);
return result;
}
protected override void OnDead()
{
base.OnDead();
_enemyGroup.OnFieldEnemyDead();
}
}
using System.Collections.Generic;
using UnityEngine;
public class FieldEnemySpawner : MonoBehaviour
{
[Header("Spawn Field Enemy")]
[SerializeField] private List<FieldEnemy> fieldEnemies;
[Header("Field Spawn Config")]
[SerializeField] private int clusterCount;
[SerializeField] private float minDistanceClusters;
[SerializeField, Min(1)] private int minFieldSpawnCount;
[SerializeField, Min(2)] private int maxFieldSpawnCount;
[SerializeField] private float radiusScale = 0.4f;
[SerializeField, Min(1)] private int maxAttemptsCount;
[Header("Enemy Group Transition Config")]
[SerializeField] private GroupConfig groupConfig;
private CityLayout _layout;
private EnemyRegister _enemyRegister;
private List<Vector2Int> _spawnPoints = new List<Vector2Int>();
private const float GoldenAngleRad = 2.39996f;
private void Start()
{
var cityGenerator = FindAnyObjectByType<CityGenerator>();
_enemyRegister = FindAnyObjectByType<EnemyRegister>();
if (cityGenerator == null)
{
Debug.LogError($"{nameof(CityGenerator)} could not be found.");
return;
}
if (_enemyRegister == null)
{
Debug.LogError($"{nameof(EnemyRegister)} could not be found.");
return;
}
_layout = cityGenerator.CityLayout;
if(_layout != null)
SpawnFieldEnemy();
}
private void SpawnFieldEnemy()
{
for (var i = 0; i < clusterCount; i++)
{
var sp = new Vector2Int();
var isSuccess = false;
for (var t = 0; t < maxAttemptsCount; t++)
{
if (!TryGetSpawnCellIndex(out sp))
{
Debug.LogWarning("Failed to find spawn point within the specified count");
continue;
}
if (CheckValidSpawnPoint(sp))
{
isSuccess = true;
break;
}
}
if (!isSuccess)
{
Debug.LogWarning($"클러스터 {i} 배치 실패");
continue;
}
_spawnPoints.Add(sp);
CreateClusters(sp);
}
}
private void CreateClusters(Vector2Int spawnPoint)
{
var parent = new GameObject($"Cluster[{spawnPoint.x},{spawnPoint.y}]");
parent.transform.SetParent(transform);
var pos = CityLayout.ConvertCellPosToWorld(spawnPoint.x, spawnPoint.y, _layout.CellSize);
var spawnCount = Random.Range(minFieldSpawnCount, maxFieldSpawnCount + 1);
var group = new EnemyGroup(groupConfig, pos, spawnCount, Time.time);
_enemyRegister.RegisterFieldEnemyGroup(group);
for (var i = 0; i < spawnCount; i++)
{
var obj = Instantiate(fieldEnemies[Random.Range(0, fieldEnemies.Count)], parent.transform);
obj.Init(group, group.GetRelayDelay(i, spawnCount));
var angle = i * GoldenAngleRad;
var radius = Mathf.Sqrt(i) * radiusScale;
var x = Mathf.Cos(angle) * radius;
var z = Mathf.Sin(angle) * radius;
obj.transform.position = new Vector3(pos.x + x, 0, pos.z + z);
obj.gameObject.SetActive(true);
}
}
private bool TryGetSpawnCellIndex(out Vector2Int spawnPoint)
{
for (var i = 0; i < maxAttemptsCount; i++)
{
var x = Random.Range(0, _layout.Width);
var y = Random.Range(0, _layout.Height);
if (_layout.Cells[x, y] == ECellType.Road)
{
spawnPoint = new Vector2Int(x, y);
return true;
}
}
spawnPoint = default;
return false;
}
private bool CheckValidSpawnPoint(Vector2Int spawnPoint)
{
if (_spawnPoints.Contains(spawnPoint)) return false;
foreach (var s in _spawnPoints)
{
var d = (s - spawnPoint).magnitude;
if (d <= minDistanceClusters)
return false;
}
return true;
}
}
'Unity,C# > Unity 정보' 카테고리의 다른 글
| [Unity] 다수의 적을 어떻게 움직일까(7) - 방향장 보간 처리 (0) | 2026.09.18 |
|---|---|
| [Unity] 다수의 적을 어떻게 움직일까(5) - 적 설계 (0) | 2026.09.17 |
| [Unity] 다수의 적을 어떻게 움직일까(4) - 겹침 해소 · 장애물 조향 (0) | 2026.09.17 |
| [Unity] 다수의 적을 어떻게 움직일까(3) - 적 관리 시스템 (0) | 2026.08.03 |
| [Unity] 다수의 적을 어떻게 움직일까(2) - 공간 해시 그리드 (0) | 2026.08.03 |