■ 객체 이동

WaveManager에서 구한 군집 분리력과 장애물 반발력, 그리고 FlowField에 저장된 이동 방향을 합쳐 최종 이동 벡터를 만들어 움직인다.
■ DummyEnemy.cs
using UnityEngine;
public class DummyEnemy : MonoBehaviour, ISpatialItem
{
public Vector3 Position => transform.position;
public float Radius => separationRadius;
[HideInInspector] public Vector3 Separation;
[HideInInspector] public Vector3 ObstacleForce;
[HideInInspector] public FlowField FlowField;
[SerializeField] private float separationRadius = 0.5f;
[SerializeField] private float maxSeparation = 0.4f;
[SerializeField] private float maxObstacleForce = 0.8f;
[SerializeField] private CharacterController cc;
[SerializeField] private float speed = 5f;
private float _radius;
private void Start()
{
_radius = cc.radius;
}
}
DummyEnemy는 ISpatialItem을 구현하므로 Position과 Radius 두 프로퍼티를 채운다. Position은 자기 transform.position을 그대로 반환하고, Radius는 separationRadius를 반환한다. 즉 WaveManager가 이웃과의 거리를 판정할 때 쓰는 반발 범위가 이 separationRadius다.
인스펙터로 조절하는 값은 세 가지다.
- separationRadius : 다른 객체를 밀치는 범위.
- maxSeparation : 분리력의 상한.
- maxObstacleForce : 장애물 반발력의 상한.
Start에서는 CharacterController의 반지름을 _radius에 캐싱해둔다. 이 값은 separationRadius와 쓰임이 전혀 다르다.
separationRadius가 "다른 적을 얼마나 멀리서부터 밀칠까"를 정하는 값이라면, _radius는 이동하려는 방향 앞을 미리 찔러볼 때 적의 몸 반지름만큼 더 내다보게 하는 값이다. 분리력에 밀려 이동 불가능한 셀 쪽으로 향할 때, 몸 중심이 아니라 몸 앞면이 셀 경계에 닿는 시점에 막힘이 감지되도록 앞을 보정한다.
private void Update()
{
var cellDir = FlowField.GetCurrentCellDirection(transform.position);
var flowVec = new Vector3(cellDir.x, 0, cellDir.y).normalized;
var step = speed * Time.deltaTime;
var pos = transform.position;
if (FlowField.IsBlocked(pos))
{
cc.Move(flowVec * step);
return;
}
var force = Vector3.ClampMagnitude(Separation, maxSeparation) +
Vector3.ClampMagnitude(ObstacleForce, maxObstacleForce);
var desired = (flowVec + force).normalized * step;
cc.Move(SlideAlongWalls(pos, desired));
}
적을 이동시키는 전체 코드인데, 천천히 살펴보자.
var cellDir = FlowField.GetCurrentCellDirection(transform.position);
var flowVec = new Vector3(cellDir.x, 0, cellDir.y).normalized;
var step = speed * Time.deltaTime;
var pos = transform.position;
FlowField에서 현재 셀에 저장된 방향(Vector2Int)을 받아, 이를 XZ 평면의 3차원 벡터로 변환한다.
받아온 방향이 대각선일 수 있으므로 .normalized로 단위 벡터로 맞춰, 뒤에서 조향력과 합성할 때 이동 방향의 비중이 왜곡되지 않게 한다. 그다음 speed * Time.deltaTime으로 이번 프레임의 이동량 step을 구하고, 현재 위치를 pos에 담아둔다. pos는 아래 IsBlocked 판정과 벽 슬라이딩에서 다시 쓰인다.
1. 객체가 이동 불가능한 셀로 나간 경우
if (FlowField.IsBlocked(pos))
{
cc.Move(flowVec * step);
return;
}
벽 슬라이딩(뒤에서 설명할 SlideAlongWalls)으로 이동하려는 방향이 이동 불가 셀을 향하면 그 축 이동을 눌러, 적이 통행 불가 구역으로 넘어가지 않도록 1차로 막아둔다. 하지만 혹시나 모를 예비 상황을 대비하여 안전 장치를 한 겹 더 걸어둔다.
현재 위치가 이동 불가 셀이면 분리력과 장애물 반발력을 전부 무시하고 오로지 FlowField에 저장된 방향(flowVec)만으로 움직여 복귀시킨다. 조향 계산까지 갈 것도 없이 즉시 복귀 이동만 하고 빠져나가므로, 이 분기에서는 조향력을 아예 만들지 않는다.

여기서 빈 공간은 건물이 배치되어 실제로는 적이 지날 수 없는 이동 불가 셀이다. 그런데 이 flowVec이 제 역할을 하려면, 이동 불가 셀에도 "가장 가까운 도로 쪽 방향"이 미리 심어져 있어야 한다. 이 작업을 FlowField의 SetupFlowField가 맡는다.
private void Start()
{
// 생략..
SetupFlowField();
_originCellPos = _layout.ConvertCellPosToWorld(0,0);
}
private void SetupFlowField()
{
for (var x = 0; x < _width; ++x)
{
for (var y = 0; y < _height; ++y)
{
if(_layout.Cells[x, y] is ECellType.CatWalk or ECellType.Road) continue;
for (var i = 0; i < 4; ++i)
{
var dx = _searchDir[i].x;
var dy = _searchDir[i].y;
var nx = dx + x;
var ny = dy + y;
if (nx < 0 || nx >= _width || ny < 0 || ny >= _height) continue;
if (_layout.Cells[nx, ny] is ECellType.CatWalk or ECellType.Road)
{
_flowField[x, y].Direction = _searchDir[i];
break;
}
}
}
}
}
[FlowField.cs]
SetupFlowField는 모든 셀을 순회하되, 도로·캣워크 같은 통행 가능 셀은 continue로 건너뛰고 이동 불가 셀만 처리한다.
각 이동 불가 셀에서 상하좌우 4방향 이웃을 살펴, 도로나 캣워크인 이웃을 처음 발견하면 그 방향을 해당 셀의 Direction에 저장하고 멈춘다. 그 결과 도로에 바로 맞닿은 이동 불가 셀, 즉 통행 불가 구역의 가장 바깥 한 겹만 도로를 향하는 복귀 방향을 갖게 된다.
한 겹만 심어도 다음에 설명할 벽 슬라이딩이 1차로 진입을 막아주기 때문이다. 방어가 뚫려 적이 밀려나더라도 기껏해야 도로 경계 바로 안쪽 한 칸 정도라, 건물 깊숙한 셀까지 복귀 방향을 심어둘 필요가 없다.
2. 조향 합성
var force = Vector3.ClampMagnitude(Separation, maxSeparation) +
Vector3.ClampMagnitude(ObstacleForce, maxObstacleForce);
var desired = (flowVec + force).normalized * step;
cc.Move(SlideAlongWalls(pos, desired));
WaveManager에서 넘겨받은 분리력과 장애물 반발력을 각각 상한으로 잘라낸 뒤 더해 하나의 조향력 force로 합친다. 이때 두 힘은 서로 다른 상한을 쓴다.
- 분리력은 maxSeparation, 장애물 반발력은 maxObstacleForce로 잘린다.
- ClampMagnitude는 벡터의 방향은 유지하고 크기만 상한 이하로 제한하여 힘이 아무리 세게 누적돼도 이동을 지배할 만큼 커지지는 않는다.
다음으로 이동 방향인 flowVec에 이 조향력을 더한다. flowVec과 force를 합치면 크기가 제각각인 벡터가 나오는데, 이를 .normalized로 단위 벡터로 만든 뒤 step을 곱한다.
다만 이렇게 구한 이동량 desired대로 그냥 움직이면 안 되고, 이대로 이동했을 때 이동 불가 셀로 들어가는지 한 번 더 검사해야 한다. 그 검사가 SlideAlongWalls다.
private Vector3 SlideAlongWalls(Vector3 pos, Vector3 desired)
{
var probeX = pos + ProbeOffset(desired.x, 0);
var probeZ = pos + ProbeOffset(0, desired.z);
var blockX = FlowField.IsBlocked(probeX);
var blockZ = FlowField.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) + _radius) : 0,
0,
dz != 0 ? Mathf.Sign(dz) * (Mathf.Abs(dz) + _radius) : 0);
}
검사는 X축과 Z축을 따로 한다. X축을 검사할 땐 ProbeOffset(desired.x, 0)처럼 dz에 0을, Z축을 검사할 땐 dx에 0을 넣어, 각 축의 이동만 반영한 지점을 따로 만든다.
dx != 0 ? Mathf.Sign(dx) * (Mathf.Abs(dx) + _radius) : 0,

ProbeOffset은 그 축의 진행 방향으로 얼마나 앞을 찔러볼지를 계산한다.
- Mathf.Sign(dx) : 이동 방향의 부호로, 양수면 오른쪽, 음수면 왼쪽처럼 진행 방향 앞쪽을 향하게 한다.
- Mathf.Abs(dx) : 이번 프레임에 그 축으로 실제 이동할 거리이고 여기에 적의 몸 반지름 _radius를 더한다.
_radius를 더하지 않으면 몸 중심이 셀 경계에 닿는 시점, 즉 몸 절반이 이미 이동 불가 셀에 파묻힌 뒤에야 막힘으로 판단한다. _radius를 더해 몸 앞면 기준으로 앞당겨야 몸이 셀에 닿는 순간에 막힘이 걸린다.
마지막으로 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 이동은 살아남는다. 벽에 정면으로 부딪혀도 완전히 멈추지 않고 벽을 따라 옆으로 미끄러지는 움직임이 나온다.