✨ 건물과 건설 기능 이번이 두 번째이다.
처음 만들었을 때는 가장 부족한 부분이 UI(버튼)의 재사용성과 모델들의 값이 문제였다.
UI는 함수 실행과Sprite
가 생각보다 하드 코딩된 점과 모델들의 값들은SO
로 관리되고있었지만 그 기능 자체가 상당히 불편하였다.
이런 부족한 부분들까지 채워서 이번 두 번째 도전을 시작해보자.
📌 빌딩 클래스는 최소 공약수 느낌으로 기능별로 나누고 건물 = 클래스
는 최대한 피할 것이다.
📌 최대한 Interface
를 Override
하여 관리할 것이지만 세분화된다면 언제든
전략 패턴
으로 리팩토링!
📌 MVC
패턴의 문제점인 Controller
스크립트의 거대화는 Handler
로 공통적이고
큰 기능들은 나눠 볼 것!
📌 건설 기능은 3인칭이기에 카메라의 Ray
를 활용하여 연출할 것
청사진이나 회전도 반드시 넣어볼 것
🎉 한 장 요약
✨ 그리드(Grid) : 사전적 의미는 격자, 우리는 RTS나 시뮬레이션 게임을 할 때 건물이 칸에 맞춰서 지어지는 경우가 많은데 그 때 네모 칸들이 그리드의 셀(Cell)이다.
아무튼 이 그리드 기능을 기반으로 여러가지 세부 기능들도 추가할 예정이다.
그중에서도 오늘 작성해볼 기능은...
그리드 & 스냅
: 그리드를 세팅하고 마우스 포지션으로 셀 위치 불러오기
🔨 그리드 세팅
우선 그리드는 X,Y
축을 기반으로 만들어져 있기 때문에 원하는 방식으로 표현하기 위해서는 Cell Swizzle(컴퓨터 그래픽)
을 이용하여 셀의 좌표를 재정렬할 필요가 있다.
그래서 그리드의 Y -> Z
로 바꾸기위해서는 XYZ -> XZY
로 변경 해줄 것이다.
📌 현재는 YZX
로 하여도 큰 문제는 없던데 격자 크기가 차이가 나거나 타일맵을
사용한다면 주의가 필요할듯하다.
🔨 그리드 규격에 맞게 오브젝트 배치(스냅).
private Grid buildGrid; // 오브젝트가 배치될 그리드
private LayerMask buildLayer; // 건설 가능 지역을 정해줄 Layer
private Vector3 screenPoint;
private float minDistance = 2;
private float maxDistance = 10;
private void Update() { SelectGrid(screenPoint);}
private void SelectGrid(Vector3 screenPoint)
{
Ray ray = Camera.main.ScreenPointToRay(screenPoint);
// 카메라에서 스크린 포인트 값으로 발사하는 레이
if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, buildLayer))
{ // (레이, out 맞은 대상, 최대 거리, 선별 레이어)
if (hit.distance < minDistance)
return;
Vector3Int pos = buildGrid.WorldToCell(hit.point);
// WorldToCell : 해당 Cell의 local pos를 World Pos로 리턴해준다.
currentGridPos = buildGrid.GetCellCenterWorld(pos);
// GetCellCenterWorld : 해당 Cell의 Center 값을 리턴한다.
// 셀의 크기는 언제나 1이 아니다.
}
}
📌 3인칭인 게임이기에 건설 가능한 최소, 최대 거리
를 설정해주었다.
📌 Layer
로 건설 가능한 지역을 체크하는 이유는 땅의 유무와는 별개로 지을 수 있는 곳과
아닌 곳으로 나눠야 하기 때문이다.
📌 지금은 물체에 가려져도 건설 가능 지역이다면 없는 것 처럼 작동하는데
추후에 좀 더 좋은 조작감을 선택해서 변경해야한다.
듣기만하면 어질어질한 난이도를 가지고 있을 거 같지만 컴퍼넌트
와 스크립트
가 상당히 잘 구현되어 있기 때문에 쉽게 만들 수 있다.
우리가 흔히 볼 수 있는 게임 속 기능들은 유니티에서 쉽게 만들 수 있고 비주류 기능이나 동적인 물리 처리 분야로 들어가면 진짜 지옥을 경험할 수도 있다.
🎉 한 장 요약
✨ 오늘은 건물이 만들어지기 전 청사진을 보여주는 것과 그것을 토대로 생성 및 관리를 해보자.
이번 기능에서 가장 중요한 부분은 동적인 건물 그리드 관리이다.
모든 건물은 똑같은 사이즈일리가 없으니 건설을 할 때는 그 사이즈를 토대로 청사진과 실제로 건설까지 이어지게 만들어야한다.
🔨 홀수 사이즈에 대한 처리
private void SelectGrid(Vector3 screenPoint)
{
...
currentGridCenterPos = buildGrid.GetCellCenterWorld(currentGridPos);
----- 추가되는 부분 -----
if (size.x % 2 == 0)
currentGridCenterPos.x -= 0.5f;
if (size.y % 2 == 0)
currentGridCenterPos.z -= 0.5f;
}
사이즈가 짝수일 경우 다른 셀까지 넘어가는 치명적인 결함이 있다.
이 부분은 게임의 방식에 따라서 다른 보정 방식을 사용해줘야 하는데 지금은 '마우스 포인터의 중앙에 오브젝트가 위치'하는 방식이기에 ±0.5f
를 해주는 방식으로 해결해 줄 수 있다.
📌 플레이어가 추가되면 마우스 포인터에 z : 0, x : Center
로 해주는 방식으로 수정을
해줘야 할 수도 있다.
🔨 청사진의 셀 주소 찾기
private List<Vector2Int> blueprintArea; // 청사진의 셀 주소값들
private Vector3 current; // currentGridCenterPos : 현재 오브젝트의 포지션값
private Vector2Int size; // 오브젝트의 사이즈
private void CurrentSellsToSize(Vector2Int size)
{
blueprintArea.Clear();
int wolrdX = (int)(current.x);
int wolrdZ = (int)(current.z);
if (current.x < 0 && size.x % 2 == 1)
wolrdX--;
if (current.z < 0 && size.y % 2 == 1)
wolrdZ--;
// 음수일 때 -1을 해주는 이유
// 1.5을 형변환하면 1이 되지만 -1.5를 형변환하면 -1이 된다.
// 하지만 내가 원하는 값은 -2가 나와야한다.
for (int z = 0; z < size.y; z++)
{
for (int x = 0; x < size.x; x++)
{
int _x = wolrdX + x - (int)(size.x * 0.5f);
int _y = wolrdZ + z - (int)(size.y * 0.5f);
blueprintArea.Add(new Vector2Int(_x, _y));
}
}
}
상수값들이 많이 들어가 꼴보기 싫은 것이 리팩토링 1순위인 부분이다.
📌 양수일 때는 작동하고 음수면 안 될 경우 버려지는 값들을 유심히 살펴보자!
🔨 생성과 이미 건설 불가능한 구역 체크
// 건설을 시도하는 함수, out으로 별도의 참조없이 바로 위치 값을 받을 수있다.
// 호출한 쪽에서 리턴값으로 분기점을 만들어줘야한다.
public bool TryPossibleCreation(out Vector3 gridPos)
{
if (!isCorrect)
{
gridPos = Vector3.zero; // 구조체는 null이 없기에 이거라도 줘야한다.
return false;
}
// 문제가 없다면 건설한 지역에 청사진의 셀 주소를 넣어준다.
foreach (var item in blueprintArea)
{
builtArea.Add(item);
}
gridPos = currentGridCenterPos;
return true;
}
// 건설한 지역과 청사진 지역을 비교해주고 마테리얼을 변경해준다.
private void CheckPlaceable()
{
// 비교하는 부분
foreach (var item in blueprintArea)
{
if (!builtArea.Contains(item))
continue;
isCorrect = false;
meshRenderer.material = materials[Convert.ToInt32(isCorrect)];
return;
}
isCorrect = true;
meshRenderer.material = materials[Convert.ToInt32(isCorrect)];
}
되든 안 되든 out
값은 항상 생긴다는 게 마음에 안 드는 방식이지만 그게 내 실력인데 어쩌겠어요...😔
아무튼 좀 더 간결하게 만들 수 있을 거 같은데도 최적화를 하는 주차도 따로 있으니 당장은 넘어가야 한다는 것이 마음에 걸리지만 매우 타당한 지침이라는 것에는 이의가 없다.
알고리즘을 연습할 때 썼던 C#
기능들, 조금 더 진지하게 사용해 볼 걸이라는 생각이 든다.
그리드 셀을 다루는 알고리즘은 흔해 빠진 문제였는데 기억이 안 나서 버그 잡는 데 한참 걸렸다.
🎉 한 장 요약
✨ 함수의 순서와 최적화 그리고 건설 기능을 만들면서 있었던 일을 정리해보자.
🔨 굳이 꼭 실행해야해??
private void Update()
{
IsChangeMousePos();
IsChangeCellPosition();
CurrentSellsToSize(currentGridCenterPos, size);
MoveBlueprint(currentGridCenterPos);
CheckPlaceable();
}
매 프레임 호출되는 함수 Update
, 가장 비호감인데 강력한 친구이다.
정직하게 프레임마다 호출하게 된다면 약 200줄 가까이 되는 코드를 의미도 없이 실행하게 될 것이고 성능에 상당한 악영향이 갈 것이다.
하지만!
private bool IsChangeMousePos()
{
if (mousePos == Input.mousePosition)
return false; // 마우스가 움직이지 않았다면 false!!
...
}
private bool IsChangeCellPosition()
{
Ray ray = Camera.main.ScreenPointToRay(mousePos);
if (!Physics.Raycast(ray, out RaycastHit hit, maxDistance, buildLayer)
|| hit.distance < minDistance)
return false; // Ray가 맞지 않았거나, 너무 가까운걸 맞췄을 때 false!!
Vector3Int temp = buildGrid.WorldToCell(hit.point);
if (currentGridPos == temp)
return false; // 이전 그리드 셀과 레이가 맞은 셀이 똑같으면 false!!
...
}
// 예외 처리가 이뤄지는 Update
private void Update()
{
if (IsChangeMousePos() == false)
return;
if (IsChangeCellPosition() == false)
return;
...
}
리턴 값을 적절히 사용해준다면 꼭 필요할 때만 Update
를 온전히 실행시키면서 성능을 최대한 보호할 수 있다!
📌 Update
를 써야한다면 꼭 필요한 코드만 실행시킬 수 있게 해라
🔨 프로토 타입은 거의 완성 되었지만 고쳐야 할 점
탑 뷰에서는 마우스 포인터가 청사진의 가운데 가는 것도 방법이지만, 목표는 3인칭이다.
그럴 경우 사이즈가 큰 건물일 경우 플레이어가 청사진 안에 들어갈 수도 있다.
🎲 : 플레이어 기능이 완성된다면 반드시 마우스 포인터에 대한 청사진의 위치를 옮겨주어야 한다
청사진이 다음 위치로 이동할 때 바로 이동하기 때문에 딱딱한 느낌이다.
🎲 : 코루틴으로 부드럽게 이동해주는 효과도 좋을 것 같다.
🎉 한 장 요약
1. 본격적인 최종 프로젝트 시작
이번 주부터 코드를 작성하기 시작했다.
한번은 의도치 않게 읽어는 보았던 기능(그리드)이었기에 수월하게 진행할 수 있었지만, 조금만 파고 들어가도 새로운 부분이 나왔다. 예를 들어 건물의 사이즈가 생겼다거나..
가능의 완성이 가장 중요하지만 퀄리티도 무조건 챙겨서 가겠다.
2. 컨디션 관리
분명 부담이 없이 즐기고 있다고 했는데 최면이었는 듯 하다.
숙면할 거 같은데도 새벽 일찍 일어나게 되어서 하루 종일 집중 안 되는 건 물론, 스트레스도 많이 받았다.
하지만 프로토 타입이 나오고 나서부터는 아침 먹을 시간도 부족하게 잔 걸로 보아하니 부담되긴 했나 보다 깔깔