금일 예정은, Monster 코드의 상속 구조 리팩토링 후, 몬스터 로밍 자동화 실패했던 내용에 대해 해결 해 보기.
2. 오늘 학습에 대해
오늘 오전에는 몬스터 종류에 관한 코드를 리팩토링 하고, 오후에는 아래와 같은 사장되었던 기능을 살려보았다.
몬스터의 자동 배치 및 로밍에 대해 Fix
문제
구현 초기에, 몬스터를 소환 할 때 몬스터 바로 아래의 바닥 콜라이더( Layer 중 'Ground', 'Passthrough' 에 대해)를 찾아 그 콜라이더의 표면으로 몬스터의 y값을 조정하고, 콜라이더의 좌우 끝값과 몬스터의 콜라이더 폭에 따라, 몬스터가 좌우로 이동할 수 있는 범위를 동적으로 지정해 주었다.
슬라임을 스폰하면, 스폰한 위치의 하단의 콜라이더를 찾아 Y위치를 조정하고, 콜라이더 폭 만큼 X범위를 갖는다
이걸 파기 한 이유는, 실제 MainScene에 팀원이 구현해 놓은 것을 보면 Ground 그리고 Passthrough가 각각 타일맵으로 한 개의 덩어리로 만들어져 있어 슬라임을 이 스테이지 안에 대충 배치해 놓는다고 해도 실제 게임을 실행 해 보면 스테이지의 y최상단에 슬라임이 올라가 있고 스테이지 전체의 좌우폭을 슬라임의 이동거리로 갖게 되는 문제가 있었기 때문이다.
MainScene에서 적용할 경우, 스테이지가 하나의 콜라이더이기였기 때문에, 최상단에 슬라임이 생겼다. X이동범위도 스테이지 폭과 같음.
아래 스크립트는, 위 알고리즘을 파기하여 주석으로 둔 후, 임시로 x의 범위를 직접 SO에서 설정하도록 하였다. y위치도 역시 SO에서 정확한 수치를 지정해주어야 한다.
private void SpawnMonstersFromList(List<SpawnInfo> spawnData)
{
foreach (var spawnInfo in spawnData)
{
string prefabPath = $"Prefabs/Monsters/{spawnInfo.monsterType}";
GameObject monsterPrefab = Resources.Load<GameObject>(prefabPath);
if (monsterPrefab != null)
{
GameObject monsterObj = Instantiate(monsterPrefab, spawnInfo.spawnPosition, Quaternion.identity);
SpawnedMonsters.Add(monsterObj);
// "Ground"와 "Passthrough" 레이어에만 있는 물체를 감지하기 위한 LayerMask 생성
int layerMask = LayerMask.GetMask("Ground", "Passthrough");
// ray 쏘기
RaycastHit2D hit = Physics2D.Raycast(spawnInfo.spawnPosition, Vector2.down, 10f, layerMask);
if (hit.collider != null)
{
// 하단 콜라이더의 경계를 기반으로 MinX와 MaxX 계산
BoxCollider2D monsterCollider = monsterObj.GetComponent<BoxCollider2D>();
float colliderHalfWidth = monsterCollider != null ? monsterCollider.size.x * monsterObj.transform.localScale.x / 2f : 0;
float minX = hit.collider.bounds.min.x + colliderHalfWidth;
float maxX = hit.collider.bounds.max.x - colliderHalfWidth;
float genY = hit.collider.bounds.max.y;
// 해결 될 때 까지 임시조치
//monsterObj.transform.position = new Vector3(monsterObj.transform.position.x, genY, monsterObj.transform.position.z);
// MonsterStat MinX, MaxX 설정, bool 관련 설정
MonsterStat monsterStat = monsterObj.GetComponent<MonsterStat>();
if (monsterStat != null)
{
//monsterStat.MinX = minX; // 해결 될 때 까지 임시조치
//monsterStat.MaxX = maxX;
// 임시조치
monsterStat.MinX = spawnInfo.spawnPosition.x - spawnInfo.tempMinX;
monsterStat.MaxX = spawnInfo.spawnPosition.x + spawnInfo.tempMaxX;
monsterStat.IsStopOnIdle = spawnInfo.isStopOnIdle;
monsterStat.IsStopOnTrack = spawnInfo.isStopOnTrack;
}
}
}
else
{
Debug.LogError($"NotFound : MonsterPrefab {spawnInfo.monsterType} | path {prefabPath}");
}
}
}
또한, 위와 같은 몬스터 배치 스크립트는 하단의 바닥 콜라이더가 가로로 평평한 직사각형이 아닐 경우 몬스터가 엉뚱한 위치에 배치되거나 엉뚱한 이동범위를 가질 수 있다. 예로 아래와 같은 느낌의 계단 모양의 콜라이더에 슬라임을 배치 할 경우 아래와 같은 모양을 하게 된다.
그림. 기대한 슬라임의 위치와 범위
■■■ □□■ ← slime → □□■■■■■■■ □□□□□□□□□ ㅇ
그림. 실제 스크립트를 적용한 슬라임의 y위치와 이동범위
← slime → ■■■ □□■ □□■■■■■■■ □□□□□□□□□
이러한 계단이나 언덕 모양에서도 잘 작동하도록 하기 위해, 스크립트를 다시 구성할 필요가 있다.
해결
위 두 가지의 문제를 동시에 해결하기 위한 솔루션으로 아래와 같은 방법을 사용하였다.
몬스터의 하단에 Raycast를 사용하는 방법은 동일하다.
단, Raycast가 인식되는 지점을 y위치로 지정한다. 이전 스크립트에서는, 콜라이더의 최상단 지점이 몬스터의 y좌표가 되었었다.
몬스터를 좌우로 이동시키며, 상단 및 하단으로 Ray를 쏴 동일한 플랫폼인지 확인한다.
2번 과정으로 몬스터가 스폰되는 y위치의 조정을 해결하고, 3번 과정을 통해 계단 및 언덕 모양 등 높이가 다른 바닥으로는 몬스터가 이동하지 않도록 할 수 있게 된다.
3번 과정의 구현에서 꽤 시간이 걸렸는데, 자세히 작성하면 아래와 같다.
좌측 범위에 대한 탐색을 먼저 시작한다. 상단(몬스터의 하단을 기준으로 레이를 발사하여, 몬스터의 상단 지점까지)은 플랫폼 Collider가 탐색되지 않아야 하고, 하단은 아주 짧은 거리에 플랫폼 Collider가 탐색되어야 하며, 이 두 조건을 만족할 경우, 이동 가능한 X 범위에 포함시키며 반복문을 통해 계속 확장해 나간다. 두 조건 중 하나라도 만족하지 않게 된다면 확장을 멈추고 좌측으로의 X범위를 확정한다. 이를 우측 범위에 대해서도 똑같이 탐색한다.
자세한 스크립트는 아래와 같으며, 주석으로 각 구문에 대한 설명을 하였다.
FindBoundary 메서드에서 Ray를 통한 X범위 탐색을 구현하였다.
private void SpawnMonstersFromList(List<SpawnInfo> spawnData)
{
foreach (var spawnInfo in spawnData)
{
string prefabPath = $"Prefabs/Monsters/{spawnInfo.monsterType}";
GameObject monsterPrefab = Resources.Load<GameObject>(prefabPath);
if (monsterPrefab != null)
{
GameObject monsterObj = Instantiate(monsterPrefab, spawnInfo.spawnPosition, Quaternion.identity);
SpawnedMonsters.Add(monsterObj);
// 현재 몬스터 Stat의 MinX, MaxX 등을 재설정하기 위해 준비
MonsterStat monsterStat = monsterObj.GetComponent<MonsterStat>();
// 스폰 시 Y값 및, X범위를 바로 아래 플랫폼의 모양에 따라 재할당
if (spawnInfo.autoAdjustPosition)
{
// "Ground"와 "Passthrough" 레이어에만 있는 물체를 감지하기 위한 LayerMask 생성
int layerMask = LayerMask.GetMask("Ground", "Passthrough");
// ray 쏘기
RaycastHit2D hit = Physics2D.Raycast(spawnInfo.spawnPosition, Vector2.down, Mathf.Infinity, layerMask);
if (hit.collider != null)
{
Vector3 monsterPosition = spawnInfo.spawnPosition;
// 몬스터의 Y 위치 설정
monsterPosition.y = hit.point.y;
monsterObj.transform.position = monsterPosition;
// 이동 범위 설정
float leftBoundary = FindBoundary(monsterObj, Vector2.left, layerMask);
float rightBoundary = FindBoundary(monsterObj, Vector2.right, layerMask);
BoxCollider2D monsterCollider = monsterObj.GetComponent<BoxCollider2D>();
float colliderHalfWidth = monsterCollider != null ? monsterCollider.size.x * monsterObj.transform.localScale.x / 2f : 0;
monsterStat.MinX = Mathf.Min(spawnInfo.spawnPosition.x, leftBoundary + colliderHalfWidth);
monsterStat.MaxX = Mathf.Max(rightBoundary - colliderHalfWidth, spawnInfo.spawnPosition.x);
}
}
// 스폰 시 위치 및 X범위를 수동으로 할당
else
{
// x 이동 범위 수동 지정
monsterStat.MinX = spawnInfo.spawnPosition.x - spawnInfo.tempMinX;
monsterStat.MaxX = spawnInfo.spawnPosition.x + spawnInfo.tempMaxX;
}
monsterStat.IsStopOnIdle = spawnInfo.isStopOnIdle;
monsterStat.IsStopOnTrack = spawnInfo.isStopOnTrack;
}
else
{
Debug.LogError($"프리팹({spawnInfo.monsterType})을 경로({prefabPath})에서 찾을 수 없음");
}
}
}
// 몬스터의 X이동범위 자동으로 할당할 경우 탐색
float FindBoundary(GameObject monster, Vector2 direction, int layerMask)
{
float step = 0.05f; // Raycast를 발사할 간격
float maxDistance = 20f; // 최대 탐색 거리
float rayLength = monster.GetComponent<BoxCollider2D>().size.y; // 몬스터 콜라이더의 높이
// 몬스터 하단에서 시작
Vector2 basePosition = new Vector2(monster.transform.position.x, monster.transform.position.y);
float goalPositionX = basePosition.x;
for (float distance = 0; distance <= maxDistance; distance += step)
{
Vector2 origin = basePosition + (direction * distance);
Vector2 rayStartBelow = origin + new Vector2(0, 0.01f); // 아래쪽 Ray 시작점
Vector2 rayStartAbove = origin + new Vector2(0, 0.1f); // 위쪽 Ray 시작점
// 시각화 FOR DEBUG
Debug.DrawRay(rayStartBelow, Vector2.down * 0.05f, Color.red, 60f);
Debug.DrawRay(rayStartAbove, Vector2.up * rayLength, Color.blue, 60f);
// 아랫방향으로 짧은 Raycast 발사
RaycastHit2D hitBelow = Physics2D.Raycast(rayStartBelow, Vector2.down, 0.05f, layerMask);
// 몬스터 위치보다 약간 위에서 위쪽으로 Raycast 발사
RaycastHit2D hitAbove = Physics2D.Raycast(rayStartAbove + new Vector2(0, 0.05f), Vector2.up, rayLength, layerMask);
// 아래에는 플랫폼이 있고, 위에는 플랫폼이 없는 경우
if (hitBelow.collider != null && !hitAbove.collider)
{
// 이동 가능한 경계를 찾음
goalPositionX = basePosition.x + direction.x * distance;
}
else break;
}
return goalPositionX;
}
상단(파랑)과 하단(빨강)의 탐색 시각화
X탐색에 관한 Ray를 시각화하면 위와 같다.
3. 과제에 대해
이번 프로젝트에 대한 과제는 일단 더 없고, 생각했던 리팩토링도 끝냈기 때문에, 발표에 쓰일 트러블슈팅(개발 중 문제 해결 경험)에 대한 영상을 찍어 발표자에게 전해주는 것, 기타 프로젝트에 관한 정리를 하면 되겠다.
팀 프로젝트로 2D 횡스크롤 게임을 제작중이다. 스펠렁키라는 게임이 크게 모티브가 되어있는데, 모르는 게임이라 살짝 찾아본 게 끝. 스펠렁키를 모른다면 슈퍼마리오 정도라고 생각해도 괜찮다. 현재까지의 완성도는 아래와 같다.
씬 뷰시작 지점에서의 게임 뷰
사실 위 스크린샷에 내가 담당한 부분은 없고, 나는 몬스터 파트를 맡았기 때문에 그 부분은 아래에서 정리한다.
2. 몬스터 파트에서의 개발
첫 2일간은 감을 잡기가 힘들었다. 그렇다고 지금도 매끄럽게 되고있는 건 아니다.
이틀 간 감을 잡는다고 다른 강의를 찾아보며 베이스를 구축하기는 했지만, 전혀 다른 타입의 게임을 예로 든 강의였기 때문에 프로젝트에 도움이 거의 되지 않았다. 많은 시간을 사용했지만 얻은 건 거의 없는 느낌
팀 프로젝트이고 남은 시간이 충분치 않아, 그 정도만 하고 개발에 착수하여 일단 제시된 필수구현이 돌아가도록 하는 것만을 목표로 삼아 깡으로 코드를 작성하였다.
업계 스탠다드의 구조를 가진 코드를 작성하고 싶었는데, 그걸 찾는다고 시간을 허비하는 게 상당한 민폐인지라 어쩔 수가 없는 상황이다.
현재까지 구현 한 내용을 큰 부분만 정리하면 아래와 같다.
몬스터의 구현. 스폰되어 이동을 하고, 플레이어에게 공격을 한다.
상태(Idle, Move(Track to Player), Attack, Die)에 따른 여러가지 처리.
데미지 계산. 플레이어에게 데미지를 주고, 플레이어에게 공격받는다.
공격주기의 반영 등 은근 까다로운 요소.
몬스터의 스폰. 미리 설정한 지점에 몬스터를 소환한다. 한 스테이지에 여러 구역이 있어, 다른 구역으로 넘어가면 이전 구역의 몬스터를 모두 지우고, 새 구역에 풀피의 몬스터를 새로 생성한다.
여러가지 몬스터. 근접공격을 하는 몬스터와 원거리 공격을 하는 몬스터를 마련한다.
위 내용에 따른 발사체 구현.
아이템 드랍. 체력 회복을 하는 포션을 일정 확률로 드랍한다.
현재로써는 몬스터 종류의 확장과, 개발중 지속적으로 고쳐주는 상태 처리이다. 상태처리에 관해서는 뼈대가 잘 잡히지 않은 채로 구현을 시작하다 보니 그런 듯 하다.
의외로 가장 쉬웠던 것이 아이템 드랍이다. 따로 아이템을 카테고리화하여 관리 할 정도가 아닌 게임이기 때문에, 몬스터가 죽는 시점에, 일정 확률로 포션을 던지고, 포션은 플레이어와 충돌이 일어나면 플레이어에게 준비되어 있는 회복 메서드를 사용한다. 사실 플레이어에게 회복 기능이 구현되어있지 않아, 본인이 작성하기는 했다.
더 상세한 내용은 아래와 같다.
가장 먼저 만든, 근접 공격 타입의 몬스터.
범위 내에 플레이어가 감지되면 플레이어를 추적하고, 공격을 한다.
플레이어에게 공격당하면 일정거리 넉백이 되며, 체력이 0이 될 경우 DIE 상태가 되어 애니메이션을 보여주며 사라진다.
플레이어에의 피격을 발생시키는 시점의 경우, 애니메이션 중에 이벤트를 추가할 수 있어 정확한 시점에 메서드를 실행 할 수 있다.
애니메이션 뷰에서 우클릭으로 이벤트를 추가할 수 있다. 피격시점과 애니메이션 종료 시점 사용할 이벤트를 마련했다.
새로운 몬스터인 원거리 공격형을 만드는 데에는 많은 시간을 소비했다.
상속구조에 대해 아직 익숙해지지 않아 MonsterMelee와 MonsterRange 클래스는 거의 비어있고, 두 클래스가 상속받는 MonsterBasController라는 클래스에 모든 것을 작성 해 놓은 상태이다.
완전히 빈 Melee와 Range. Init도 만약의 경우를 위한 코드이다.몬스터에 대한 거의 모든 코드가 몰려있는 부모 클래스
처음에 Melee와 Range를 구분할 여유가 없이 힘들게 코드를 작성했기 때문에, Range의 작성을 시작하며 MonsterController라는 클래스의 이름을 Melee로 바꾸고, 그의 부모였던 MonsterBaseController에 모든 코드를 옮겼으며, Range의 코드를 생성했기 때문에 벌어진 일이다.
이 부분에 대해서는 현 프로젝트의 내 파트에 있어 가장 리팩토링이 필요한 부분이다.
몬스터에게 필요한 여러 수치들은 MonsterStat에 모아 프리팹의 컴포넌트로 사용하였다
몬스터에게 필요한 여러 능력치들을 저장하는 MonsterStat이다. MonsterManager에서 몬스터를 생성할 때 설정하는 내용도 있고, MonsterBaseController에서 hp 등을 변화시켜주기도 한다.
원거리 몬스터는 투사체를 발사한다.
원거리 몬스터의 투사체는 플레이어의 콜라이더와 접촉 시, 데미지를 준다. 플레이어측에서 데미지를 받는 메서드를 마련 해 두었기 때문에, 그것을 사용하였다. 접촉데미지의 경우도 마찬가지이다.
일정 확률로 포션 아이템을 드랍한다.
몬스터는 Rigidbody를 사용하지 않고, Collider도 트리거로서만 작동하도록 하였는데, 드랍되는 포션은 물리법칙을 받도록 작성했다.
현재 포션이 얌전히 몬스터의 위치에 떨어지는데, 스폰 시 랜덤한 방향으로 아주 살짝 velocity를 주면 좋을까 생각하고 있다.
드랍된 HP포션
HP 포션을 먹으면 체력을 채운다.
유용한 기능이었지만 지워버린 기능
개발 초기의 기능 중 하나로 몬스터의 좌표를 대충 설정하고 스폰하게 되면, 몬스터의 바로 아래에 있는 플랫폼의 콜라이더를 확인하여 바로 위에 안착하고(y좌표), 콜라이더의 좌우 끝 위치를 확인하여, 몬스터가 그 범위만을 순찰하도록 하였다(x범위).
테스트 시에는 가로로 긴 직사각형 플랫폼을 여럿 사용하여 개발을 하였는데, 실제 개발중인 메인 씬에서 확인해보니 이걸 활용할 수가 없는 환경이었다.
한 덩어리로 이루어진 Ground 콜라이더모든 스테이지의 정점에 소환된 슬라임
플랫폼이 각자의 콜라이더를 가진 상황이 아니라, 모든 스테이지가 하나의 덩어리로 이루어져 있어 스테이지의 상단에 슬라임이 소환되며 위와 같이 스테이지의 폭만큼 X범위를 가지게 된 것.
아쉽지만 직접 몬스터의 좌표를 정확히 지정하고, 좌우로 이동할 거리를 수동으로 입력 해 주게 되었다.
MonsterSpawnData(SO)
위의 문제를 해결할 때 까지 사용한다는 의미로 Temp 를 붙여놓았지만, 아무래도 해결이 어렵지 않을까 생각한다.
3. 앞으로 구현할 내용
몬스터를 두 종류 더 만든다. 둘 다 근접공격이고, 하나는 빠르고 약한 '생쥐', 하나는 느리고 단단한 '골렘'이다. 또한 기존 슬라임의 크기를 키워 한 종류를 더 만든다는 듯 하는데, 이는 밸런스 담당에서 건드릴 것 같다.
몬스터 스폰 시 y와 x정찰범위 기능의 수리는 도전과제로.
투사체 모양의 구체화. 현재는 흰색의 circle과 square만을 합쳐놓은 모양이다. 사실 이렇게 만들어도 위화감은 별로 없었다는 게 킬포인트.
코드 자체의 리팩토링. 상기 작성한 내용처럼 코드가 무척 정리되지 않았다.
개발 기간은 현재 3일을 진행했으며, 목요일~월요일의 휴일 포함 5일가량이 남았고, QA를 할 시간이 충분하다. 세세한 부분도 부족하다면 차차 수리하거나 추가개발을 진행해도 될 정도이다. 다른 팀원의 코드도 확인 해 나가며 배울 점을 찾을 수 있을 것 같다.
2024.02.20 현재 부분을 작성하는 시점, 전문 DAW를 사용하는 방법 외에 다른 수단을 찾아보지 않았습니다.
인터넷에서 여러가지 음악과 노래의 midi파일을 구할 수 있고, 이를 바탕으로 다른 악기로의 연주, 여러가지 이펙트를 넣어 사용할 수 있지만, 그 전에 원본 midi를 높낮이 반전 또는 shift pitch만을 간단하게 하여 새로운 느낌의 소스로 선행작업을 해 줄 수 있다고 생각하였습니다.(물론 DAW에서는 정말 간단하게 두 가지 작업을 할 수 있으나, 여기서는 DAW를 사용하지 않고 선행작업을 하는 방법을 생각합니다.)
팀 프로젝트 3/5(발표날 포함)일째이다. 3D플랫폼 퍼즐 게임을 진행중이고, UI 및 씬 관리 전반을 하고있다.
굉장히 시간이 모자라다. 스크립트와 UI의 기본적인 프리팹은 모두 준비되었는데, 메인 브런치에 Merge를 해가며 진행할 요소가 너무 많아 시간에 맞출 수 없을 것 같은 기분이 들고, UI가 아니더라도 퍼즐 쪽도 어떻게 진행이 될지, 내일 하루만에 완성을 해야할텐데 정말 모르겠다.
Todo 리스트를 열심히 지워가며 진행중인데도 아직 할 게 많이 남았고 많이 또 생길 것 같다.
Todo
현재 Data에 따라 Stage 해금되도록 하기, 스테이지 선택 할 수 있도록 하기
애니메이션이나 Fade in out 효과 넣기
전체적인 디자인 손보기
Merge 후 손보기
ESC키로 UI_Pause 띄우게 하기 : 제대로 메서드 써서 씌워야 Popup 오더 관리 됨
Player가 상호작용 가능 할 때, 어떻게 UI를 보여줄지 생각
Player의 상태를 보여줄 때, 어떻게 UI를 보여줄지 생각
위 두 가지 사항에 대해, 화면 내 어느 위치에 UI를 배치하고 구현할지 생각
Player가 분신을 만들었을 때, 어떻게 UI를 보여줄지 생각
게임을 클리어 했을 때, 어떻게 UI를 보여줄지 생각
결과 데이터 저장
일단 생각나는 것만 해도 이 정도. 에셋 찾아와서 UI를 꾸미는 게 정말 시간이 오래 걸릴 것 같은데 다른 것도 만만치 않다.
2. 오늘 학습에 대해
Unity의 생명주기때문에 코드가 꼬이는 일이 종종 있다. 매번 난감해서 생소한 코딩을 하며 해결을 하였는데, 오늘은 여러 스크립트가 꼬여 그렇게도 할 수가 없어, UI의 Start() 메서드에 사용하던 모든 코드를 통째로 다른곳으로 옮겨 작업을 해야만했다. 오브젝트를 만들고 컴포넌트를 붙여주는 작업이 그 오브젝트의 Start 메서드보다 빨리 실행되어 Null을 갖게 된 게 이유이다. 조금 더 경험이 쌓여있었다면 더 나은 방법으로 해결 할 수 있었을까 생각한다.