Unity2D
Unity) [유니티 2D] A* 알고리즘 커스텀 - 1
HSH12345
2023. 3. 27. 02:33
/*
* 커스텀 Node 클래스 입니다.
*
* Grid 안에 들어갈 Node입니다.
*/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Node
{
public bool walkable;
public Vector2 worldPosition;
public int gridX;
public int gridY;
public int gCost;
public int hCost;
// 길 되추적을 위한 parent변수.
public Node parent;
// Node 생성자.
public Node(bool _walkable, Vector2 _worldPos, int _gridX, int _gridY)
{
walkable = _walkable;
worldPosition = _worldPos;
gridX = _gridX;
gridY = _gridY;
}
// F cost 계산 속성.
public int fCost
{
get
{
return gCost + hCost;
}
}
}
/*
* 커스텀 Grid 클래스 입니다.
*
* 스크린을 Grid - Node로 분할 합니다.
*/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Grid : MonoBehaviour
{
public bool displayGridGizmos;
// 플레이어의 위치
public Transform monster;
// 장애물 레이어
public LayerMask OBSTACLE;
// 화면의 크기
public Vector2 gridWorldSize;
// 반지름
public float nodeRadius;
Node[,] grid;
// 격자의 지름
float nodeDiameter;
// x,y축 사이즈
int gridSizeX, gridSizeY;
private void Awake()
{
nodeDiameter = nodeRadius * 2;
gridSizeX = Mathf.RoundToInt(gridWorldSize.x / nodeDiameter);
gridSizeY = Mathf.RoundToInt(gridWorldSize.y / nodeDiameter);
// 격자 생성
CreateGrid();
}
// A*에서 사용할 PATH.
[SerializeField]
public List<Node> path;
// Scene view 출력용 기즈모.
private void OnDrawGizmos()
{
Gizmos.DrawWireCube(transform.position, new Vector2(gridWorldSize.x, gridWorldSize.y));
if(grid != null )
{
Node playerNode = NodeFromWorldPoint(monster.position);
foreach (Node n in grid)
{
Gizmos.color = (n.walkable) ? Color.white : Color.red;
if(n.walkable == false)
if(path != null)
{
if (path.Contains(n))
{
Gizmos.color = Color.black;
Debug.Log("?");
}
}
if (playerNode == n) Gizmos.color = Color.cyan;
Gizmos.DrawCube(n.worldPosition, Vector2.one * (nodeDiameter - 0.1f));
}
}
}
// 격자 생성 함수
void CreateGrid()
{
grid = new Node[gridSizeX, gridSizeY];
// 격자 생성은 좌측 최하단부터 시작. transform은 월드 중앙에 위치한다.
// 이에 x와 y좌표를 반반 씩 왼쪽, 아래쪽으로 옮겨준다.
Vector2 worldBottomLeft = (Vector2)transform.position - Vector2.right * gridWorldSize.x / 2 - Vector2.up * gridWorldSize.y / 2;
for (int x = 0; x < gridSizeX; x++)
{
for (int y = 0; y < gridSizeY; y++)
{
Vector2 worldPoint = worldBottomLeft + Vector2.right * (x * nodeDiameter + nodeRadius) + Vector2.up * (y * nodeDiameter + nodeRadius);
// 해당 격자가 Walkable한지 아닌지 판단.
bool walkable = !(Physics2D.OverlapCircle(worldPoint, nodeRadius, OBSTACLE));
// 노드 할당.
grid[x, y] = new Node(walkable, worldPoint, x, y);
}
}
}
// node 상하 좌우 노드를 반환하는 함수.
public List<Node> GetNeighbours(Node node)
{
List<Node> neighbours = new List<Node>();
for(int x = -1; x <= 1; x++)
{
for (int y = -1; y <= 1; y++)
{
if(x == 0 && y == 0) continue;
int checkX = node.gridX + x;
int checkY = node.gridY + y;
if(checkX >= 0 && checkX < gridSizeX && checkY >= 0 && checkY < gridSizeY)
{
neighbours.Add(grid[checkX, checkY]);
}
}
}
return neighbours;
}
// 입력으로 들어온 월드좌표를 node좌표계로 변환.
public Node NodeFromWorldPoint(Vector2 worldPosition)
{
float percentX = (worldPosition.x + gridWorldSize.x / 2) / gridWorldSize.x;
float percentY = (worldPosition.y + gridWorldSize.y / 2) / gridWorldSize.y;
percentX = Mathf.Clamp01(percentX);
percentY = Mathf.Clamp01(percentY);
int x = Mathf.RoundToInt((gridSizeX - 1) * percentX);
int y = Mathf.RoundToInt((gridSizeY - 1) * percentY);
return grid[x, y];
}
}
/*
* A* 알고리즘 커스텀 클래스 입니다.
* 이 알고리즘은, 터치로 입력받은 장애물을 피해 target위치로 플레이어(seeker)를 가장 최적의 루트로 이동시키는 스크립트 입니다.
*
* 이 클래스는 화면을 grids로 나누어야 합니다. 이를 위해 커스텀클래스 Grid와 Node class를 사용합니다.
*
* 사용시, Astar prefab을 Hierarchy view에 옮긴 후 position을 0,0,0으로 설정, 그리고
* seeker에 Player를 등록하면 됩니다.
*
* 알고리즘 개요
* OPEN SET : 평가되어야 할 노드 집합
* CLOSED SET : 이미 평가된 노드 집합
*
* 1. OPEN SET에서 가장 낮은 F코스트를 가진 노드 획득 후 CLOSED SET 삽입
* 2. 이 노드가 목적지라면, 반복문 탈출
* 3. 이 노드의 주변 노드들을 CLOSED SET에 넣고, 주변노드의 F값 계산. 주변노드의 G값보다 작다면 F값으로 G값 최신화
* 4. 1번 반복.
*
*/
using System.Collections;
using UnityEngine.EventSystems;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class PathFinding : MonoBehaviour
{
[Header("Path Finding")]
public Transform seeker;
public Vector2 target;
// 맵을 격자로 분할한다.
Grid grid;
// 남은거리를 넣을 큐 생성.
public Queue<Vector2> wayQueue = new Queue<Vector2>();
[Header("Player Ctrl")]
// 뭔가와 상호작용 하고 있을때는 walkable이 false 가 됨.
public static bool walkable = true;
// 플레이어 이동/회전 속도 등 저장할 변수
public float moveSpeed;
public float rotSpeed;
// 장애물/NPC 판단시 멈추게 할 범위
public float range;
public bool isWalk;
public bool isWalking;
private void Awake()
{
// 격자 생성
grid = GetComponent<Grid>();
walkable = true;
}
private void Start()
{
// 초깃값 초기화.
isWalking = false;
moveSpeed = 20f;
rotSpeed = 5f;
range = 4f;
}
private void FixedUpdate()
{
StartFindPath(seeker.position, target);
}
// start to target 이동.
public void StartFindPath(Vector2 startPos, Vector2 targetPos)
{
StopAllCoroutines();
StartCoroutine(FindPath(startPos, targetPos));
}
// 길찾기 로직.
IEnumerator FindPath(Vector2 startPos, Vector2 targetPos)
{
// start, target의 좌표를 grid로 분할한 좌표로 지정.
Node startNode = grid.NodeFromWorldPoint(startPos);
Node targetNode = grid.NodeFromWorldPoint(targetPos);
// target에 도착했는지 확인하는 변수.
bool pathSuccess = false;
if (!startNode.walkable)
Debug.Log("Unwalkable StartNode 입니다.");
// walkable한 targetNode인 경우 길찾기 시작.
if(targetNode.walkable)
{
// openSet, closedSet 생성.
// closedSet은 이미 계산 고려한 노드들.
// openSet은 계산할 가치가 있는 노드들.
List<Node> openSet = new List<Node>();
HashSet<Node> closedSet = new HashSet<Node>();
openSet.Add(startNode);
// closedSet에서 가장 최저의 F를 가지는 노드를 빼낸다.
while (openSet.Count > 0)
{
// currentNode를 계산 후 openSet에서 빼야 한다.
Node currentNode = openSet[0];
// 모든 openSet에 대해, current보다 f값이 작거나, h(휴리스틱)값이 작으면 그것을 current로 지정.
for (int i = 1; i < openSet.Count; i++)
{
if (openSet[i].fCost < currentNode.fCost || openSet[i].fCost == currentNode.fCost && openSet[i].hCost < currentNode.hCost)
{
currentNode = openSet[i];
}
}
// openSet에서 current를 뺀 후, closedSet에 추가.
openSet.Remove(currentNode);
closedSet.Add(currentNode);
// 방금 들어온 노드가 목적지 인 경우
if (currentNode == targetNode)
{
// seeker가 위치한 지점이 target이 아닌 경우
if(pathSuccess == false)
{
// wayQueue에 PATH를 넣어준다.
PushWay( RetracePath(startNode, targetNode) ) ;
}
pathSuccess = true;
break;
}
// current의 상하좌우 노드들에 대하여 g,h cost를 고려한다.
foreach (Node neighbour in grid.GetNeighbours(currentNode))
{
if (!neighbour.walkable || closedSet.Contains(neighbour))
continue;
// F cost 생성.
int newMovementCostToNeighbour = currentNode.gCost + GetDistance(currentNode, neighbour);
// 이웃으로 가는 F cost가 이웃의 G보다 짧거나, 방문해볼 Openset에 그 값이 없다면,
if (newMovementCostToNeighbour < neighbour.gCost || !openSet.Contains(neighbour))
{
neighbour.gCost = newMovementCostToNeighbour;
neighbour.hCost = GetDistance(neighbour, targetNode);
neighbour.parent = currentNode;
// openSet에 추가.
if (!openSet.Contains(neighbour))
{
openSet.Add(neighbour);
}
}
}
}
}
yield return null;
// 길을 찾았을 경우(계산 다 끝난경우) 이동시킴.
if(pathSuccess == true)
{
// 이동중이라는 변수 ON
isWalking = true;
// wayQueue를 따라 이동시킨다.
while (wayQueue.Count > 0)
{
//seeker.position = Vector2.MoveTowards(seeker.position, wayQueue.First(), moveSpeed * Time.deltaTime);
//Debug.LogFormat("seeker: {0}", this.seeker.position);
//Debug.LogFormat("wayQueue: {0}", wayQueue.First());
//if ((Vector2)seeker.position == wayQueue.First())
//{
// wayQueue.Dequeue();
//}
Debug.LogFormat("dist: {0}", Vector2.Distance((Vector2)seeker.position, wayQueue.First()));
var dir = this.wayQueue.First() - (Vector2)this.seeker.transform.position;
this.seeker.gameObject.GetComponent<Rigidbody2D>().velocity = dir.normalized * moveSpeed * 5 * Time.deltaTime;
if (Vector2.Distance((Vector2)seeker.position, wayQueue.First()) <= 1.05f)
{
wayQueue.Dequeue();
}
yield return new WaitForSeconds(0.02f);
}
// 이동중이라는 변수 OFF
isWalking = false;
}
}
// WayQueue에 새로운 PATH를 넣어준다.
void PushWay(Vector2[] array)
{
wayQueue.Clear();
foreach (Vector2 item in array)
{
wayQueue.Enqueue(item);
}
}
// 현재 큐에 거꾸로 저장되어있으므로, 역순으로 wayQueue를 뒤집어준다.
Vector2[] RetracePath(Node startNode, Node endNode)
{
List<Node> path = new List<Node>();
Node currentNode = endNode;
while(currentNode != startNode)
{
path.Add(currentNode);
currentNode = currentNode.parent;
}
path.Reverse();
// Grid의 path에 찾은 길을 등록한다.
grid.path = path;
Vector2[] wayPoints = SimplifyPath(path);
return wayPoints;
}
// Node에서 Vector 정보만 빼낸다.
Vector2[] SimplifyPath(List<Node> path)
{
List<Vector2> wayPoints = new List<Vector2>();
Vector2 directionOld = Vector2.zero;
for(int i = 0; i < path.Count; i++)
{
wayPoints.Add(path[i].worldPosition);
}
return wayPoints.ToArray();
}
// custom g cost 또는 휴리스틱 추정치를 계산하는 함수.
// 매개변수로 들어오는 값에 따라 기능이 바뀝니다.
int GetDistance(Node nodeA, Node nodeB)
{
int dstX = Mathf.Abs(nodeA.gridX - nodeB.gridX);
int dstY = Mathf.Abs(nodeA.gridY - nodeB.gridY);
// 대각선 - 14, 상하좌우 - 10.
if (dstX > dstY)
return 14 * dstY + 10 * (dstX - dstY);
return 14 * dstX + 10 * (dstY - dstX);
}
}
위 스크립트는 진행중인 프로젝트와 맞는 기능을 갖고 있지만 A* 경로 탐색 중 코너 탐색을 할 수 없기 때문에 자연스러운 움직임을 만들 수 없다. 반면 아래 스크립트는 현재 노드에서 모든 면으로 경로 탐색을 하고 코너일 경우 자연스럽게 돌아가는 길을 탐색해 주기 때문에 위 스크립트와 아래 스크립트의 기능을 합쳐서 우리 프로젝트에 맞는 기능을 구현해야 함
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[System.Serializable]
public class Node
{
public Node(bool _isWall, int _x, int _y) { isWall = _isWall; x = _x; y = _y; }
public bool isWall;
public Node ParentNode;
// G : 시작으로부터 이동했던 거리, H : |가로|+|세로| 장애물 무시하여 목표까지의 거리, F : G + H
public int x, y, G, H;
public int F { get { return G + H; } }
}
public class GameManager : MonoBehaviour
{
public Vector2Int bottomLeft, topRight, startPos, targetPos;
public List<Node> FinalNodeList;
public bool allowDiagonal, dontCrossCorner;
int sizeX, sizeY;
Node[,] NodeArray;
Node StartNode, TargetNode, CurNode;
List<Node> OpenList, ClosedList;
public void PathFinding()
{
// NodeArray의 크기 정해주고, isWall, x, y 대입
sizeX = topRight.x - bottomLeft.x + 1;
sizeY = topRight.y - bottomLeft.y + 1;
NodeArray = new Node[sizeX, sizeY];
for (int i = 0; i < sizeX; i++)
{
for (int j = 0; j < sizeY; j++)
{
bool isWall = false;
foreach (Collider2D col in Physics2D.OverlapCircleAll(new Vector2(i + bottomLeft.x, j + bottomLeft.y), 0.4f))
if (col.gameObject.layer == LayerMask.NameToLayer("Wall")) isWall = true;
NodeArray[i, j] = new Node(isWall, i + bottomLeft.x, j + bottomLeft.y);
}
}
// 시작과 끝 노드, 열린리스트와 닫힌리스트, 마지막리스트 초기화
StartNode = NodeArray[startPos.x - bottomLeft.x, startPos.y - bottomLeft.y];
TargetNode = NodeArray[targetPos.x - bottomLeft.x, targetPos.y - bottomLeft.y];
OpenList = new List<Node>() { StartNode };
ClosedList = new List<Node>();
FinalNodeList = new List<Node>();
while (OpenList.Count > 0)
{
// 열린리스트 중 가장 F가 작고 F가 같다면 H가 작은 걸 현재노드로 하고 열린리스트에서 닫힌리스트로 옮기기
CurNode = OpenList[0];
for (int i = 1; i < OpenList.Count; i++)
if (OpenList[i].F <= CurNode.F && OpenList[i].H < CurNode.H) CurNode = OpenList[i];
OpenList.Remove(CurNode);
ClosedList.Add(CurNode);
// 마지막
if (CurNode == TargetNode)
{
Node TargetCurNode = TargetNode;
while (TargetCurNode != StartNode)
{
FinalNodeList.Add(TargetCurNode);
TargetCurNode = TargetCurNode.ParentNode;
}
FinalNodeList.Add(StartNode);
FinalNodeList.Reverse();
for (int i = 0; i < FinalNodeList.Count; i++) print(i + "번째는 " + FinalNodeList[i].x + ", " + FinalNodeList[i].y);
return;
}
// ↗↖↙↘
if (allowDiagonal)
{
OpenListAdd(CurNode.x + 1, CurNode.y + 1);
OpenListAdd(CurNode.x - 1, CurNode.y + 1);
OpenListAdd(CurNode.x - 1, CurNode.y - 1);
OpenListAdd(CurNode.x + 1, CurNode.y - 1);
}
// ↑ → ↓ ←
OpenListAdd(CurNode.x, CurNode.y + 1);
OpenListAdd(CurNode.x + 1, CurNode.y);
OpenListAdd(CurNode.x, CurNode.y - 1);
OpenListAdd(CurNode.x - 1, CurNode.y);
}
}
void OpenListAdd(int checkX, int checkY)
{
// 상하좌우 범위를 벗어나지 않고, 벽이 아니면서, 닫힌리스트에 없다면
if (checkX >= bottomLeft.x && checkX < topRight.x + 1 && checkY >= bottomLeft.y && checkY < topRight.y + 1 && !NodeArray[checkX - bottomLeft.x, checkY - bottomLeft.y].isWall && !ClosedList.Contains(NodeArray[checkX - bottomLeft.x, checkY - bottomLeft.y]))
{
// 대각선 허용시, 벽 사이로 통과 안됨
if (allowDiagonal) if (NodeArray[CurNode.x - bottomLeft.x, checkY - bottomLeft.y].isWall && NodeArray[checkX - bottomLeft.x, CurNode.y - bottomLeft.y].isWall) return;
// 코너를 가로질러 가지 않을시, 이동 중에 수직수평 장애물이 있으면 안됨
if (dontCrossCorner) if (NodeArray[CurNode.x - bottomLeft.x, checkY - bottomLeft.y].isWall || NodeArray[checkX - bottomLeft.x, CurNode.y - bottomLeft.y].isWall) return;
// 이웃노드에 넣고, 직선은 10, 대각선은 14비용
Node NeighborNode = NodeArray[checkX - bottomLeft.x, checkY - bottomLeft.y];
int MoveCost = CurNode.G + (CurNode.x - checkX == 0 || CurNode.y - checkY == 0 ? 10 : 14);
// 이동비용이 이웃노드G보다 작거나 또는 열린리스트에 이웃노드가 없다면 G, H, ParentNode를 설정 후 열린리스트에 추가
if (MoveCost < NeighborNode.G || !OpenList.Contains(NeighborNode))
{
NeighborNode.G = MoveCost;
NeighborNode.H = (Mathf.Abs(NeighborNode.x - TargetNode.x) + Mathf.Abs(NeighborNode.y - TargetNode.y)) * 10;
NeighborNode.ParentNode = CurNode;
OpenList.Add(NeighborNode);
}
}
}
void OnDrawGizmos()
{
if (FinalNodeList.Count != 0) for (int i = 0; i < FinalNodeList.Count - 1; i++)
Gizmos.DrawLine(new Vector2(FinalNodeList[i].x, FinalNodeList[i].y), new Vector2(FinalNodeList[i + 1].x, FinalNodeList[i + 1].y));
}
}
출처
https://gpfatp.blogspot.com/2019/01/unity-2d-path-finding-algorithm.html
[유니티 엔진] Unity 2D에서 네비게이션(길찾기) 구현하기(Path Finding, A* Algorithm)
Game Programming Info
gpfatp.blogspot.com
http://goraniunity2d.blogspot.com/2019/10/2d-140.html
2D 타일맵을 위한 A* 길찾기 알고리즘
GameManager.Cs 소스입니다 using System.Collections; using System.Collections.Generic; using UnityEngine; [System.Serializable] ...
goraniunity2d.blogspot.com