![[1인개발 프로젝트] 하늘소 프로젝트 3주차](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbUtr2%2FbtsEf18cgd1%2FjPycx1Hk6ZzpkHakiDT0s0%2Fimg.png)
원래대로였다면 저번주 목요일에 이 글이 올라왔어야 했지만, 다른 프로젝트, 유니잼, 팰월드 등 조금 바빴던 탓에 개발을 많이 하지 못했다. 그리고 이번주에 일정이 조금 풀려서 이번 주까지 했던 것들을 3주차로 모았다.
사실 이중에 유니잼의 영향이 제일 컸다. 저번주 금토일을 거의 서현역에서 살다시피 밤을 새가며 1박 3일 개발을 했다. 굉장히 힘들긴 했지만 하고나니 뿌듯했다.

다른 프로젝트 얘기는 접어두고, 이번 주차는 UI를 다듬고, 세부 기획을 하고, 메인 기능 중 하나인 필드 파밍 기능을 구현했다. 이번에도 삽질은 있었지만, 그래도 금방 해결할 수 있었다.
세부 기획
원래 기획했던 것들의 디테일을 살리고 코드 구현에 막힘이 없게 하기 위해 세부 기획을 짰다.

사실 지역들은 조금 오래 전에 짜 두었지만, 설정을 약간씩 바꿔 전체적인 흐름을 조정해주었다.
지역마다 파는 물건도 지역의 컨셉에 맞게 조정했고, 지역의 인장과 건물 디자인도 대충 끝난 상태이다.
셀 필드 파밍 기능 구현 - 플레이어 이동
기획한 게임의 주요 기능 중에 셀 형태로 된 필드를 플레이어가 이동하며 상호작용을 하는 기능이 있다.
"데일리 판타지" 라는 게임에서 영감을 받았고, 플레이어가 셀 위를 이동하며 주민들과 대화하는 설정을 잡았다.
데일리 판타지 진짜 재밌게 했는데...

셀 위를 움직이며 파밍을 하려면 우선 Isometric 타일맵이 필요하고, 모바일 게임이라 터치로 가고싶은 셀을 터치하여 움직여야하기 때문에 길을 찾는 기능도 구현해야했다.
Isometric 타일맵은 전에 한 번 다뤘던 적이 있어서 생성과 관리에 큰 문제는 없었지만, 진짜 문제는 길찾기였다. 사람이야 그냥 "앞에 벽이 있네? 옆으로 돌아 가야겠다." 하면 되지만 우리의 코드조각은 벽을 만나면 벽을 뚫을 기세로 비벼버리기 때문에 이를 생각하여 길을 하나하나 알려주어야 한다.
이를 구현하기 위해 A* 알고리즘을 사용하기로 했다.
A*(에이스타) 알고리즘이란?
A* 알고리즘은 다익스트라 알고리즘의 일종으로, 드론이나 로봇이 최적의 길을 찾게 하기 위해 고안된 알고리즘이다.
길찾기 알고리즘 답게 큰 틀은 시작지점에서 끝지점까지의 거리를 계산하여 가능한 최적의 경로로 가는 것이다.
이것을 유니티에 구현하기 위해 자료조사도 해보고 영상도 찾아보았다.
초기 설정
우선, 타일들의 좌표를 기준으로 움직이고, 부모 타일, 이웃 타일도 봐야하기 때문에 Node class를 생성했다. 이는 좌표를 담고 있고, 이 타일이 벽인지 나타내는 bool 변수인 isWall, 시작지점부터 현재까지의 이동거리 가중치 G, 현재 위치에서 도착 지점까지의 거리 가중치 H, 이 가중치 두개를 합친 변수 F를 담고있다. 또한 부모 노드도 포함하고있다.
그리고, 이 알고리즘에서는 직선 경로의 가중치를 10, 대각선 경로의 가중치를 14로 설정해준다.
어 왜 10이랑 14죠? 그냥 1이랑 2 때려넣으면 안되나요?
되긴 하는데, 가중치에 어긋날 뿐더러 그렇다고 소수를 넣게 되면 연산이 오래걸린다. 대각선 경로의 경우 피타고라스 정리에 의해 루트 2가 곱해지게 되는데, 10을 곱하면 14.14... 이고 이를 정수 14로 나타내게된다면 float형 변수의 연산보다 연산 속도가 빨라져 훨씬 빨리 경로를 찾을 수 있게 되어서 10과 14를 쓰는 것이다. 하지만 나는 대각선을 고려하지 않을 것이기 때문에 10만 써 주었다.
그리고 지나갈 노드, 지나온 노드를 담을 리스트 두개가 필요하여 이를 만들어주었다.
이게 모두 되었다면, 시작 지점 노드를 하나 만들어 변수들을 설정해주고, 이를 지나갈 노드 리스트에 넣는다.
이제 while을 돌며 알고리즘을 실행하면 된다.
[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;
public int x, y, G, H;
public int F { get { return G + H; } }
}
반복문을 통한 경로 탐색
반복문의 흐름을 설명하자면, 현재 노드를 하나 설정해준다. 이때 기본값은 지나갈 노드 리스트의 0번째 값이며 만약 이 리스트 안에 0번째 노드보다 F가 작은 (F가 같다면 H가 작은) 노드가 있다면 그것으로 설정한다.
그리고 현재 노드를 지나갈 노드 리스트에서 삭제하고 지나온 노드 리스트에 넣는다.
그 후, 현재 노드의 상 하 좌 우로 이웃하는 노드에 대해 다음의 조건을 판단한다.
- 노드가 설정 범위 안에 있는지
- 노드가 벽이 아닌지
- 지나온 노드 리스트에 없는지
대각선을 생각했다면 벽 사이를 통과하는지도 판단해야하지만, 아니기에 뺐다.
만약 조건에 충족하는 노드가 있다면 이동 가중치 G가 이웃 노드의 G보다 작거나 지나갈 노드 리스트에 이웃 노드가 없다면 G와 H, 부모 노드를 설정하고 지나갈 노드 리스트에 추가해준다.
이 흐름을 도착 노드가 나올 때 까지 반복하면 된다.
public void PathFinding()
{
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;
if (!tilemap.HasTile(new Vector3Int(i - 1, j - 1, 0))) { 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)
{
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);
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]))
{
Node NeighborNode = NodeArray[checkX - bottomLeft.x, checkY - bottomLeft.y];
int MoveCost = CurNode.G + 10;
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);
}
}
}
처음에는 isWall 변수를 만들지 않고 그냥 HasTile로 조건을 판단하였는데, 이렇게 하니 타일 맵의 끝 부분 한줄 씩 죽은 타일이 되어 거기로는 갈 수 없는 현상이 발생했다. 이를 해결하기 위해 isWall 변수를 만들어 사용하니 잘 작동하였다.
알고리즘이 끝난 후 처리
알고리즘을 돌아 최적의 경로를 찾았다면, 도착 노드의 부모 노드, 그 부모의 부모 노드, ... 이런 식으로 가다보면 언젠간 시작 노드가 나오게 되어있다. 이 것이 시작 노드부터 도착 노드까지 가는 최적의 경로를 나타내는 것이다. 따라서 이것을 리스트에 담은 후 뒤집으면, 경로가 완성된다.
if (CurNode == TargetNode)
{
Node TargetCurNode = TargetNode;
while (TargetCurNode != StartNode)
{
FinalNodeList.Add(TargetCurNode);
TargetCurNode = TargetCurNode.ParentNode;
}
FinalNodeList.Add(StartNode);
FinalNodeList.Reverse();
return;
}
이 알고리즘이 터치와 캐릭터 사이에서 일어나야 하고, 캐릭터가 이를 따라 이동해야 하기 때문에 그에 맞게 코드를 짜 주었다. 마우스 좌표를 받아와 이를 타일맵 좌표계로 바꾸고, 캐릭터의 좌표 또한 타일맵 좌표계로 바꿔 알고리즘을 거친 경로 리스트와 IEnumerator, DOTween을 이용해 캐릭터가 움직이도록 했다.
void Start()
{
character.transform.position = tilemap.CellToWorld(Vector3Int.zero);
touchEnabled = true;
}
void Update()
{
if (Input.GetMouseButtonDown(0) && touchEnabled)
{
touchEnabled = false;
Vector3Int mousePos = tilemap.WorldToCell(Camera.main.ScreenToWorldPoint(Input.mousePosition));
Vector3 characterPos = tilemap.WorldToCell(character.transform.position);
startPos = new Vector2Int((int)characterPos.x, (int)characterPos.y);
targetPos = new Vector2Int(mousePos.x - 4, mousePos.y - 4);
try
{
PathFinding();
Debug.Log(FinalNodeList.Count);
StartCoroutine(moveCharacter());
} catch (IndexOutOfRangeException){
Debug.Log("out");
}
finally
{
touchEnabled = true;
}
}
}
IEnumerator moveCharacter()
{
for (int i = 0; i < FinalNodeList.Count; i++)
{
Vector3 pos = tilemap.CellToWorld(new Vector3Int(FinalNodeList[i].x, FinalNodeList[i].y, 0));
character.transform.DOMove(pos, 0.2f).SetEase(Ease.Linear);
yield return new WaitForSeconds(0.2f);
}
touchEnabled = true;
}
이동하는 도중에 터치 입력을 받으면 안되기 때문에 touchEnabled라는 bool 변수를 만들어 이를 관리했고, try catch를 이용해 범위 밖을 터치할 경우를 관리했다. 특히 범위 밖의 경우는 그냥 오류를 띄우는 것이 아닌 어떠한 구문을 작성할 수 있기 때문에 이 경우에 특정 함수를 실행시켜 소소한 기능을더 만들 수 있을 것 같다.
플레이어를 따라가는 카메라 구현
사실 이 기능은 직접 짠 것은 아니고, 어디선가 받아온 소스코드가 아직 남아있길래 그것을 사용했다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CamaraController : MonoBehaviour
{
public GameObject target; // 카메라가 따라갈 대상
public float moveSpeed; // 카메라가 따라갈 속도
private Vector3 targetPosition; // 대상의 현재 위치
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
// 대상이 있는지 체크
if (target.gameObject != null)
{
// this는 카메라를 의미 (z값은 카메라값을 그대로 유지)
targetPosition.Set(target.transform.position.x, target.transform.position.y, this.transform.position.z);
// vectorA -> B까지 T의 속도로 이동
this.transform.position = Vector3.Lerp(this.transform.position, targetPosition, moveSpeed * Time.deltaTime);
}
}
}
이렇게 구현한 결과물이다.

롤 마우스포인터는 못본걸로
이 알고리즘을 이용한 기능을 구현하는데만 거의 8시간을 쏟았다. 스터디 팀이랑 열품타를 켜고 내 특대 업적을 공유했어야하는데 까먹고 못 킨게 좀 아쉽다.
이제 큰거 하나 지나갔으니 필드 파밍 및 상호작용 요소만 구현하면 메인 기능 하나가 끝나게 된다!
이번 주차는 외부 행사가 많았어서 쉬엄쉬엄 했던 것 같다. 물론 마지막에 스퍼트를 내긴 했지만. 알고리즘을 실제 게임에 적용하고 나니 진짜 정통 게임을 개발하고있는 느낌이 들어 뿌듯하다. 그리고 스트레칭의 필요성을 느끼고있다... 무릎이 아파요
이제 곧 다른 팀 프로젝트도 시작할 참이니 이 페이스를 유지하면 될 것 같다.

'1인 개발 > 하늘소 프로젝트' 카테고리의 다른 글
[1인개발 프로젝트] 하늘소 프로젝트 6주차 (0) | 2024.02.25 |
---|---|
[1인개발 프로젝트] 하늘소 프로젝트 5주차 (1) | 2024.02.15 |
[1인개발 프로젝트] 하늘소 프로젝트 4주차 (1) | 2024.02.09 |
[1인개발 프로젝트] 하늘소 프로젝트 2주차 (1) | 2024.01.19 |
[1인개발 프로젝트] 하늘소 프로젝트 1주차 (4) | 2024.01.12 |
안녕하세요! 코드 짜는 농부입니다! 경희대학교 소프트웨어융합학과 23학번 재학중입니다. 문의 : dsblue_jun@khu.ac.kr
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!