반응형

이전 글에서는 Grid Cell을 터치했을 때 BFS로 목적지까지의 경로를 찾는 방식을 구현했다.

2026.09.23 - [Project/Project_P] - [Unity2D / TileMap] 캐릭터 Grid 이동 (BFS)

 

이번에는 캐릭터를 길게 누르고 직접 끌어서 이동 경로를 만드는 기능을 추가했다. 최종적으로 MovementPlan을 만들어 PlayerMover에 전달한다는 점은 이전과 같다. 차이가 있다면 목적지 하나로 경로를 찾는 것이 아니라, 포인터가 움직이는 방향에 따라 경로를 한 칸씩 추가하거나 되돌린다는 점이다.

캐릭터 이동_Grid (Pointer Drag)

 

이번 글에서는 전체 호출 흐름을 먼저 정리하고, 구현하면서 신경 썼던 cellDelta 변환, UpdateDrag()의 반복문, DragPathBuilder의 경로 수정 과정을 중심으로 기록하려고 한다.

1. 드래그 이동 호출 순서

드래그 입력에서 실행되는 흐름을 간단히 적으면 다음과 같다.

Press.started → PlayerDragController
    └─ 일정 시간 누르면 BeginDrag()
          ↓
Point.performed → UpdateDrag()
    ├─ CurrentCell 중심과 Pointer 위치 비교
    ├─ DragDirectionResolver.Resolve()  방향 판정
    ├─ DragPathBuilder.TryEnterCell()    경로 추가/되돌리기
    └─ 경로가 바뀌면 GridPreviewRenderer.RenderPath()
          ↓
Press.canceled
    └─ UpdateDrag() → EndDrag()
          ↓
MovementPlan 생성 → PlayerMover.ExecuteAsync()

PlayerDragController를 중심으로 호출하되 각 클래스의 책임은 분리했다.

클래스 역할
PlayerDragController 입력 이벤트와 상태 관리, 경로 처리 호출
DragDirectionResolver Cell 기준 포인터 변위를 8방향 중 하나로 판정
DragPathBuilder 이동 경로의 추가와 이전 경로 제거
GridPreviewRenderer 현재 경로를 Tilemap에 표시
MovementPlan / PlayerMover 확정된 이동 경로 보관 / 실제 이동

방향을 판정하는 부분과 경로 데이터를 수정하는 부분을 나눴다. 예를 들어 대각선 입력 판정만 변경할 때 DragPathBuilder의 경로 추가·삭제 규칙은 건드리지 않도록 하기 위해서다. 또한 렌더러는 경로가 어떻게 만들어졌는지 몰라도 최신 Path만 표시하면 된다.

2. UpdateDrag()에서 worldDelta와 cellDelta를 구한 이유

Vector3Int currentCell = pathBuilder.CurrentCell;
Vector2 currentCellCenter = gridMap.CellToWorld(currentCell);
Vector2 worldDelta = pointerWorld - currentCellCenter;
Vector2 cellDelta = gridMap.WorldDeltaToCellDelta(worldDelta);

CurrentCell은 실제 캐릭터가 서 있는 Cell이 아니라 드래그로 만들고 있는 Path의 마지막 Cell이다. 드래그 중에는 아직 캐릭터가 움직이지 않기 때문이다.

 

포인터의 월드 좌표만 가지고는 어느 쪽으로 드래그했는지 판단하기 어렵다. 따라서 현재 Cell의 중심을 기준으로 상대적인 방향과 거리를 구했다.

 

예를 들어 현재 Cell 중심이 (5, 3), 포인터가 (5.8, 3.6)이라면 다음과 같다.

worldDelta = pointerWorld - currentCellCenter
           = (5.8, 3.6) - (5.0, 3.0)
           = (0.8, 0.6)

즉 현재 Cell 중심에서 오른쪽으로 0.8, 위쪽으로 0.6만큼 떨어져 있다는 뜻이다. 여기까지는 World 단위의 변위다.

 

그런데 방향 판정에는 World 좌표의 거리보다 Cell 크기를 기준으로 변환한 값을 사용하는 것이 적절하다고 판단했다.

Cell의 크기가 항상 1 × 1인 것은 아니기 때문이다. 예를 들어 Cell 크기가 2 × 1이라면 X축으로 1만큼 떨어진 것과 Y축으로 1만큼 떨어진 것은 각 Cell 크기를 기준으로 서로 다른 비율에 해당한다.

 

앞서 구한 worldDelta = (0.8, 0.6)을 Cell 크기가 2 × 1인 Grid에 적용하면 다음과 같다.

Cell 크기: 2 × 1
worldDelta = (0.8, 0.6)

cellDelta.x = 0.8 / 2 = 0.4
cellDelta.y = 0.6 / 1 = 0.6

cellDelta = (0.4, 0.6)

X축으로는 Cell 너비의 40%, Y축으로는 Cell 높이의 60%만큼 떨어져 있다는 뜻이다. 이처럼 Cell 크기를 기준으로 변환하면 X축과 Y축의 크기가 다르더라도 각 축의 상대적인 이동량을 같은 기준에서 비교할 수 있다.

 

실제 코드에서는 GridMap.WorldDeltaToCellDelta()를 통해 월드 공간의 변위를 Grid의 로컬 좌표계로 변환한 뒤, 각 축의 grid.cellSize로 나눠 계산했다.

 

결국 worldDelta는 현재 Cell 중심에서 포인터까지의 월드 공간상 변위이고, cellDelta는 그 변위를 Cell 크기 기준으로 환산한 값이다. 이후 계산한 cellDelta를 DragDirectionResolver에 전달해 직선 또는 대각선 방향을 판정하도록 했다.

3. DragDirectionResolver는 다음 한 칸의 방향만 결정한다

Vector3Int direction = DragDirectionResolver.Resolve(
    cellDelta,
    diagonalStartThrehold,
    straightStartThreshold,
    diagonalRatio
);

if (direction == Vector3Int.zero) break;

Vector3Int nextCell = currentCell + direction;

Resolve()에는 cellDelta를 전달한다. 반환값은 위·아래·좌·우와 네 대각선 방향 중 하나이거나, 아직 판정하기에 이동량이 부족한 경우 (0,0,0)이다.

 

예를 들어 (1,1,0)을 받았다면 현재 Cell (5,3)의 다음 후보는 (6,4)가 된다. 방향을 계산하는 시점에는 아직 Path를 수정하지 않는다. 실제 추가 여부는 다음 단계의 TryEnterCell()에서 판단한다.

 

처음에는 대각선으로 끌었는데 직교 방향 Cell이 먼저 잡히는 문제가 있었다. 이를 개선하면서 방향 판정을 별도 DragDirectionResolver로 분리했다. 대각선·직선 Threshold와 Ratio를 이용한 판정 방식, 수정 전후 비교는 다음 글에서 따로 정리했다.

4. UpdateDrag()에서 for문을 사용하는 이유

Vector2 pointerWorld = ScreenToWorld(currentScreenPosition);
bool pathChanged = false;
const int maxStepsPerUpdate = 12;

for (int i = 0; i < maxStepsPerUpdate; i++)
{
    Vector3Int currentCell = pathBuilder.CurrentCell;
    // 현재 Cell 중심에서 포인터까지의 cellDelta 계산
    // Resolver로 direction 결정
    // TryEnterCell(nextCell)로 경로 한 칸 변경
}

 

포인터 이벤트 한 번이 Cell 한 칸 이동을 의미하는 것은 아니기 때문이다. 포인터가 빠르게 움직이면 중간 Cell의 좌표가 입력 이벤트로 모두 들어오지 않을 수 있다.

 

예를 들어 경로가 다음과 같다고 가정하자.

(0,0) → (0,1) → (0,2) → (0,3)
                          ↑ CurrentCell

포인터를 (0,3)에서 (0,0)까지 빠르게 내렸고, 이벤트가 y=2.5 → 1.5 → 0 순서로 들어온다고 가정해 보자. Cell 중심이 정수 좌표이고 straightStartThreshold = 0.60일 때의 계산이다.

전달된 포인터 Y 내부 반복 이벤트 후의 Path
2.5 현재 3에서 차이가 0.5 → zero, 종료 0 → 1 → 2 → 3
1.5 3→2로 한 칸 삭제, 2에서는 차이 0.5 → 종료 0 → 1 → 2
0 2→1→0으로 연속 삭제, 0에서 zero 0

이때 같은 UpdateDrag() 안에서는 pointerWorld가 바뀌지 않는다. 반면 CurrentCell은 매번 pathBuilder.CurrentCell을 새로 읽으므로 경로가 변경될 때마다 다른 값이 된다.

Pointer = (0,0)으로 고정

// 이벤트3 예시
1회차: CurrentCell (0,2) → (0,1) 삭제 경로 변경
2회차: CurrentCell (0,1) → (0,0) 삭제 경로 변경
3회차: CurrentCell (0,0) → 방향 zero → 반복 종료

따라서 포인터 위치 하나를 향해 여러 Cell을 순서대로 처리할 수 있다.

maxStepsPerUpdate = 12는 한 번의 UpdateDrag()에서 최대 12번까지만 처리하도록 둔 상한이다. 매번 12번을 도는 것은 아니고, 방향이 zero이거나 경로를 변경할 수 없으면 break한다.

 

이 상한에 도달했을 때 아직 처리하지 못한 거리가 남으면, 이후 포인터 이벤트가 들어와야 나머지가 처리될 수 있다는 점도 고려할 부분이다.

5. TryEnterCell(): 새로운 Cell 추가와 기존 경로 되돌리기

방향을 계산한 뒤에는 TryEnterCell(nextCell)에서 Path를 변경한다.

public bool TryEnterCell(Vector3Int cell)
{
    if (path.Count == 0) return false;

    Vector3Int currentCell = path[path.Count - 1];
    if (cell == currentCell) return false;
    if (!gridMap.IsAdjacent(currentCell, cell)) return false;

    int existingIndex = path.IndexOf(cell);
    if (existingIndex >= 0)
    {
        int removeCount = path.Count - existingIndex - 1;
        if (removeCount > 0)
            path.RemoveRange(existingIndex + 1, removeCount);
        return true;
    }

    if (!gridMap.CanMove(currentCell, cell)) return false;
    path.Add(cell);
    return true;
}

가장 먼저 IsAdjacent()로 현재 Cell의 바로 옆인지 확인한다. CanMove() 내부에서도 인접 여부를 확인하지만, 기존 경로로 돌아가는 처리는 CanMove() 호출 전에 끝날 수 있으므로 Builder 진입 부분에 인접 검사를 따로 두었다.

 

IndexOf(cell)은 Path에 해당 Cell이 있으면 인덱스를, 없다면 -1을 반환한다. -1이라면 새 Cell이므로 CanMove()로 이동 가능 여부를 검사한 뒤 추가한다.

 

반대로 이미 경로에 존재하는 Cell이라면 그 Cell 이후의 경로를 삭제한다.

인덱스:     0   1   2   3
기존 Path:  A → B → C → D
                ↑ B로 되돌리기

existingIndex = 1
path.Count = 4
removeCount = 4 - 1 - 1 = 2
RemoveRange(2, 2)

결과: A → B

-1을 하는 이유는 되돌아갈 Cell인 B는 남기고 그 뒤의 C, D만 삭제하기 위해서다.

 

그래서 TryEnterCell()이 true를 반환한다는 것은 새로운 Cell이 추가되었거나 이전 경로가 삭제되어, 실제 Path가 변경되었다는 뜻이다.

6. 변경된 경로 표시와 이동 실행

UpdateDrag()는 반복문 안에서 한 번이라도 Path가 바뀌면 pathChanged = true로 기록한다. 렌더러 호출은 반복문 밖에 있다.

if (!pathChanged) return;
previewRenderer.RenderPath(pathBuilder.Path);

따라서 한 번의 포인터 이벤트에서 경로를 여러 칸 추가하거나 지워도 Preview는 마지막에 한 번만 갱신한다. RenderPath()는 기존 Preview 타일을 비우고 최신 Path의 중간 경로와 마지막 목적지 타일을 다시 표시한다.

 

손을 놓으면 OnPressCanceled()에서 포인터의 마지막 위치를 다시 읽고 UpdateDrag()를 한 번 더 호출한 뒤 EndDrag()로 경로를 확정한다.

 

MovementPlan plan = new MovementPlan(pathBuilder.Path);
pathBuilder.Clear();
StartMoveAsync(plan, 0f).Forget();

MovementPlan은 전달받은 Cell을 새로운 List로 복사한다. 그러므로 Builder의 임시 Path를 Clear()해도 확정된 이동 계획은 남고, PlayerMover.ExecuteAsync()에서 이를 사용한다.


정리

이번 드래그 이동에서는 포인터가 움직인 좌표를 그대로 경로에 넣지 않고, 현재 Path의 마지막 Cell → 포인터 변위 계산 → 방향 판정 → 후보 Cell 검증 → 경로 수정의 순서로 처리했다.

 

특히 빠른 드래그를 한 번의 입력에서 여러 Cell로 처리하기 위한 반복문과, 되돌아간 Cell 뒤쪽만 삭제하는 Backtracking이 중요했다. 실제 이동은 기존 BFS 이동에서 사용하던 MovementPlan과 PlayerMover를 그대로 활용했다.

 

다음 글에서는 드래그 방향을 판단하는 과정에서 발생했던 대각선 입력이 직선 두 칸으로 분리되는 문제와 그 개선 과정을 정리하려고 한다.

반응형