누가봐도 최적화 문제 + 왠지 씬에 남아있는 투명한 탄막 + 뭔가 시간이 지날수록 점점 무거워지는 게 무엇인가가 메모리에 누적되어가는 것 같은 문제
0.3 FPS (3035.8ms)를 보이며 도저히 게임을 진행할 수 없는 상태로 보인다. (20만 배치수가 무척 심각한 상태라는 것은 이슈를 해결하고 나서야 알았다)
영상으로 보면 아래와 같다.
이후 탄막의 렌더러를 없애봐도 비슷한 타이밍에 fps가 나락가는 걸 보면, 정말 단순하게 연산쪽이 무거워서 그런 것으로 보인다(코드가 지저분해서 그럴 만 하다고 생각했다)
버그 픽스를 위해
오브젝트 풀에서 초기화 또는 반환 도중 에러가 난 아이들
원점에 돌아갔지만, 움직이지 않고 Update문을 반복하며 오류를 뿜는다.
초기화 오류인가, 반환 오류인가?
수동으로 반환 메서드를 실행하면 잘 되며, 여러가지 초기화 해 주어야 할 값이 모두 null인 것으로 보아, 초기화가 제대로 이루어지지 않은 것으로 보인다. 즉 DanmakuGenerator에서 미스가 있다.
var danmakuGo = DanmakuPoolManager.instance.GetGo(settings.danmakuPrefab.name);
try
{
if (danmakuGo != null)
{
Vector3 initPosition = masterTransform.position; // 마스터의 위치를 기본값으로
// ... 기타 로직 및 탄막 파라미터들 초기화
}
}
catch
{
Pool.Release(danmakuGo);
}
아무래도 로직 도중 masterObject가 소실되어 Pool에서의 탄막 생성 후 이후 로직이 모두 무산되고 danmakuController.Initialize도 실행되지 않은 것으로 보인다. 해당 로직을 try문으로 감싸, 초기화가 제대로 이루어지지 않을 경우 바로 Pool에 반환을 할 수 있도록 해야겠다.
var danmakuGo = DanmakuPoolManager.instance.GetGo(settings.danmakuPrefab.name);
try
{
if (danmakuGo != null)
{
Vector3 initPosition = masterTransform.position; // 마스터의 위치를 기본값으로
// ... 기타 로직 및 탄막 파라미터들 초기화
}
}
catch
{
Pool.Release(danmakuGo);
}
해결되지 않았다. 아직 활성화 직전인 오브젝트를 반환 할 수 없는 게 이유인 것으로 보인다. 새로운 탄막 오브젝트를 Pool에서 꺼내오기 전에 masterObject의 상태를 체크하여 코루틴을 종료 할 수 있도록 해 보았다.
// masterObject의 상태 확인
if (masterObject == null || !masterObject.activeInHierarchy)
{
yield break; // masterObject가 비활성화되거나 파괴되면 코루틴 중단
}
var danmakuGo = DanmakuPoolManager.instance.GetGo(settings.danmakuPrefab.name);
해결 된 듯 보여 탄막 오브젝트를 1.6^4 = 6.6배정도 증량하여 더 부하를 줘 보았다.
FPS가 1.0 이하로 떨어지고 탄막의 반환이 살짝 늦을지언정 더 이상 Pool 버그는 일어나지 않았다.
참고로 위 영상에서 최대 탄막수는 4096개였다. Pool의 크기는 100였기 때문에, 사실상 Instantiate와 Destroy가 커버를 친 셈.
번외로 Pool의 크기를 4100으로 하여 FPS를 확인 해 보자. 버그를 찾으며 프로파일러를 확인하면서, Destory 등이 CPU성능이 90프로 이상인 시점이 있었던 것도 생각하면 왠지 FPS 저하가 플레이에 지장이 없을 정도가 될 것이라고도 생각된다.
워메… 아니었다. 프로파일러를 켜고 다시 확인 해 보자.
일단 랜더러쪽은 전혀 문제가 되지 않았고, 정말 그냥 Pool 반환을 위한 Deactive 량이 너무 많은 것이 원인이었다.(1.6초에 4000회) 그런데 이 반환 처리가 생성파괴와 성능차이가 크게 나지 않는다는 건 생각보다 쇼크.
생명주기 시간을 좀 늘려 반환이 이루어지지 않는 시점에도 FPS가 많이 떨어지는지 확인 해 보아야겠다. 실제 게임에서는 반환이 이렇게 짧은 시간에 대량으로 몰려있지는 않을 것으로 예상되기 때문에.
생성이 갑작스럽게 한꺼번에 이루어져 7.7 FPS를 가지긴 했지만, 이후 약 5000개의 탄막 오브젝트가 이동하는 데에는 11.5 FPS부터 시작하여 약 20 FPS까지 올라간다.
생각 해 보니 하이에라키 등 게임 씬 이외의 요소가 성능저하의 문제가 될 가능성이 있어, Play Maximized 모드에서 추가로 확인을 해 보았다.
생성순간이 13 FPS, 이후 18 FPS부터 시작하여 카메라에서 탄막이 사라지면서(낮아지는 Batches 수) 30 FPS 이상까지 회복한다. Play Maximized 모드에서 대략 1.5배정도의 성능을 보였다.
보스몬스터가 탄막을 무척 남발하도록 하고 싶은데, 이런 상태라면 좀 어려울 것으로 생각된다. 최적화가 필요한 부분이다.
다만, 현재 최우선순위는 패턴의 다양화이고, 이후 탄막의 네온 효과, 카메라에 포스트 프로세서등을 달아 어느 정도의 최적화가 더 필요한지 확인하여 견적을 잡아야겠다.
3. 과제에 대해
탄막의 네온효과, 카메라의 포스트 프로세싱 적용, 실제 게임 중 탄막 사용 예상량 등을 근거로 프레임 저하가 어느 정도 더 일어나는지 확인한다. 이후 견적을 내어 필요한 만큼의 최적화를 진행. 다만 후순위 작업이 될 것이고, 패턴의 다양화를 위한 추가 스크립팅을 하는 것을 우선으로 한다.
Q 1. 자기소개 해주세요 Q 2. 게임개발을 하게 된 이유는 Q 3. 유니티 생명주기에 대해 설명해주세요 Q 4. 객체지향에 대해 설명해주세요 Q 5. 상속에 대해 설명해주세요
대답
대학 졸업하고 이것저것 프밍공부 하고 게임개발 공부중.. 어버버
게임을 하는 것도 좋아하기도 하고, 비쥬얼적으로 뭔갈 만들어서 보여주는 것에 특화되어있고 게임 뿐 아니라 여러 시뮬레이션을 만들어 보는 것에 대해서도 유니티가 적합하다고 생각하여 열심히 배우고 있음. 여기도 어버버
게임 시작 시점에 실행되는 무언가. [] 대괄호로 둘러싼 키워드와 함께 하는 그거. 이후에 Awake, OnEnable, Start 등 오브젝트 컴포넌트의 활성화 단계에서 한 번 메서드를 실행하여 여러 초기화 역할을 하며, Update 메서드 등에서 프레임 당 필요한 작업을 한다. (FixedUpdate나 콜백 등에 대해서는 빼먹음) 오브젝트의 파괴 시 OnDistroy 메서드에서 필요한 장치를 마련하여 마무리.
순차적으로 진행되는 절차지향과는 다른 결의 프로그래밍 방식으로, 모듈화를 중심으로 프로그래밍을 하는 방식을 객체지향 프로그래밍이라고 한다.
특정 클래스에 어떠한 기능을 만들어 이를 다른 클래스에서 이어받아 추가적인 메서드나 변수를 작성하여 사용 할 수 있는 구조. 몬스터를 예로 들면, 몬스터 클래스에 몬스터가 가지는 기능을 구현한 후, 보스 몬스터에서 몬스터 클래스를 상속받고 보스몬스터만의 추가적인 메서드 등을 구현하여 사용. 쫄 몬스터에서도 마찬가지로 몬스터 클래스를 상속받아 쫄 몬스터에 대한 메서드 등을 추가로 작성하여 사용한다.
피드백
나에 대한 소개 : 내가 어떤 사람이고, 내가 왜 게임개발을 하려고 하는지. 나라는 사람이 어떠한 사람인지 표현할 수 있도록 정리를 해 보자.
전반적으로 부족한 자신감 : 내가 부족하다고 생각하는 부분을 스스로 인지하게 되면 자신감이 떨어지는 경향이 있음.
본인소개부터 막혀서 거기에서 자신감이 떨어지고, 기술적인 부분에서도 설명을 하다가 막히기 시작하면 떨어지는 자신감.
대답을 하다가 흐리는 경향. "~게 한다고.....합니다.". "~라고 생각합니다" 는 지양하도록. "~게 됩니다."라고 깔끔하게 끝내기.
시선처리 : 흔들리는 시선
기술에 대한 대답도 정리가 필요.
AtoZ를 모두 말하는 것 보다 AtoZ를 모두 알고있다는 것을 요약.(예:생명주기)
요약 : 자신감 / 마인드(걔네는 면접하는 게 일) / 내가 생각하는 걸 잘 말할 수 있도록 연습
추후 실사용에서는 tag:Player를 사용하지는 않겠지만, 테스트를 위해서 플레이어 오브젝트를 만들고 플레이어 태그를 통해 위치정보를 알아내도록 세팅하였다.
1차적으로 선형 발사를 위한 PatternSO 변수 준비를 마쳤다.
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "Patterns", menuName = "DanmakuSO/Pattern", order = 0)]
public class PatternSO : ScriptableObject
{
public List<DanmakuPatternData> patternDatas;
// 패턴 이름을 통해 패턴 정보를 찾기 -> 추후 최적화를 위한 수정 필요. 어떻게 쓰일지도 살짝 애매모호한 상태.
// ▲ 현재 상태 : PhaseSO에서 patternName을 입력하여 저장, Enemy에서 'GetSpawnInfoByPatternName' 이용하여 탄막패턴 사용. 일단 OK
// TODO.
// But, 커스텀 고려해서 수정 필요.
// 그리고 GetSpawnInfoByPatternName 자체에 대해서도 연산 더 낮은 방식으로 바꾸어야 함.
public DanmakuPatternData GetSpawnInfoByPatternName(string patternName)
{
foreach (var patternData in patternDatas)
{
if (patternData.patternName == patternName)
{
return patternData;
}
}
return null;
}
}
[System.Serializable]
public class DanmakuPatternData
{
public string patternName;
public DanmakuSettings danmakuSettings;
}
[System.Serializable]
public struct DanmakuSettings // 추가 할 게 진짜 많다.. 트리 이미지로 {1.생성-2.이동-3.하층생성-4.반환} 명심하여 작성
{
// 아래의 내용은 전-부 PhaseSO 작성 시 커스텀이 가능. 다만 선택적 커스텀을 어떻게 해야할지 떠오르지가 않음. 커스텀 값을 기본적으로 모두 null로 둘 수 있을까?
// 1. 생성 ---------------------------------//------------------------------------------------------------------
// 1-1. 탄막의 모양
public GameObject danmakuPrefab; // 탄막 기본 프리팹
// 1-2. 생성 시간과 횟수에 관련된 정보
public float initDelay; // 첫 탄막 생성까지의 지연
public int numOfSet; // 총 세트 수
public float setDelay; // 세트 사이의 지연
public int shotPerSet; // 한 세트에서 탄막을 몇 차례 생성할지
public float shotDelay; // 탄막 생성 사이의 지연
// 1-3. 생성 모양에 관한 정보
// Memo. 여기서 할게 꽤 많음. 차차 작성. 03월 11일 목표는 여기! SO 정보를 읽어 구 모양으로 발사하기. 필수요소만 준비하여 시연을 해 보자.
// a. 어느 방향을 기준으로 생성을 시작할 것인지
public PosDirection posDirection; // 주체 기준으로 생성될 방향
public Vector3 customPosDirection; // > posDirection이 World일 경우 지정
// a-plug. 기준 방향에 랜덤성을 줄 것인지.
// public bool posDirectionHasRandomness; // 랜덤성 부여
// public PosDirectionRandomType posDirectionRandomType; // > 랜덤성이 직선인지, 평면인지. 이후에 고려할 사항도 다수
// >a> 랜덤성이 직선일 경우, 그 직선의 형태
// >a> 랜덤성이 직선일 경우, 그 직선의 범위 또는 양 방향 각각의 범위(각도가 될 듯)
// >b> 랜덤성이 평면일 경우, 그 평면의 형태. 능력이 안된다면, 아래의 조건만 보아 콘 형태부터.
// >b> 랜덤성이 평면일 경우, 허용 범위. 즉 기준점으로부터의 허용 각도
// b. 기준방향을 중심으로 어떤 형태의 방사를 사용할지. 거리와 방향을 포함.
// 간단한 선형 단일 발사부터, 정육면체 모양으로 속도를 달리 한 발사, 특별한 모양으로 생성되어 각각이 랜덤한 타이밍에 발사 등 다양한 형태.
// b-1. 형태에 관해. 기본적인 프리셋을 제공하되, 유저가 Vector3를 직접 작성하여 입력할 수 있도록도 하자.
public DanmakuShape danmakuShape; // 탄막 모양의 타입
// b-2. 거의 모든 모양에서 사용할 변수들
public float initDistance; // 모든 탄막에 대한 생성거리의 기준
// 기준 거리에 대한 랜덤성 부여. 여유가 되면 작성.
// 이 랜덤성을, 모든 탄막에 동일부여할지, 각 탄막에 따로 부여할지의 여부.
public int numPerShot; // 한번 발사에 사용되는 탄막 갯수.
// 참고: 일부 Shape들()에 대해서는 numPerShot으로 해결이 되기 때문에 이러한 형태들은 b-3항목 불필요.
// b-3. 탄막 모양에 따라 선택적 변수들(이후, 조건부로 Inspector에 보여주는 것이 과제)
public float shotVerticalDistance; // Circle: 원의 면과 보스의 수직거리
public int shotVerticalNum; // Sphere: 구의 '단' 갯수
// Cone: 허용각도. 얘는 자료형을 뭘로 해야할지 모르겠음.
// 전체 모양의 회전을 틀어버릴 요소(정해진 값)
// 전체 모양의 회전을 틀어버릴 값의 랜덤 여부. true라면 위 값을 범위로 사용. // 이 두 랜덤변수는 a-plug에서 커버 가능한 부분으로 보임. 삭제 예정
// b-99. 유저 커스텀 입력
// 유저입력1. 원하는 범위에 a.N개를 균일배치(어려울듯), b.N개를 랜덤배치
// 원하는 범위는... 일단 x, y, z의 범위? 이것만으로는 마음엔 들진 않을 듯.(이 방식으론 직육면체 밖에 불가능)
// 그래프의 형태로 입력받아 활용할 수 있을 것 같지만 난이도가 있을 듯 하다.
// 유저입력2. 완전한 위치들의 리스트를 전달받기
// 탄막의 방향 : 일단 주체기준으로 밖으로 퍼지도록 Outer로 설정하여 테스트
public DanmakuToDirection danmakuToDirection;
// 2. 이동 ---------------------------------//------------------------------------------------------------------
// 탄막 자체의 세팅
public float initSpeed; // 시작속도. 일단은 정속으로 테스트, 추후 수정.
public Vector3 initDirection; // 시작방향. 일단 보는방향으로 테스트, 추후 수정.
// 변속정보.
// 변향정보. 플레이어에 유도 등 여러가지 요인으로 변수 추가 가능성 높음.
// 변속정보(불연속) 리스트
// 변향정보(불연속) 리스트
// 나중에 필요하면 이어서 추가
// 3. 클론 ---------------------------------//------------------------------------------------------------------
public NextPatternMethod nextPatternMethod; // 하위 탄막을 생성의 조건.
// 타이머일 경우, 그 시간
// UserTrigger일 경우, 이벤트 구독
// 마찬가지로 테스트 이후 필요한만큼 추가
// 4. 반환 ---------------------------------//------------------------------------------------------------------
public ReleaseMethod releaseMethod; // Pool 반환의 조건.
public float releaseTimer; // 방법1. 반환까지의 타이머. 일단 이거로 테스트.
// 방법2. 충돌체크. Ground를 만날 시 반환 여부. 이벤트 감지 로직은 각 탄막에서보다 Ground에서 작성하는 것이 자원을 아낄 수 있을 것으로 보임.
// 방법3. 하위 탄막의 모든 세트 생성을 끝마친 경우
// 방법4. 주체의 트리거(구독)
// 그리고 이 모든것을 하나의 뭉치로하여, 하위 탄막에 전해주거나 할 것으로 일단 보임.
// 하위 탄막에 전해줄 내용 : 뭉탱이.
// 하위 탄막이 뭉탱이를 언패킹하여, 위의 내용을 모두 적용, 하위 뭉탱이가 있으면 이를 반복.
}
public enum PosDirection
{
World, // 주체 또는 플레이어의 방향과 무관계한
Look, // 주체가 바라보는
LookPlayer, // 주체가 플레이어를 바라볼 경우
CompletelyRandom, // 완전히 랜덤한 방향으로
}
public enum PosDirectionRandomType
{
Line, // 직선 범위에서 랜덤
Plane // 평면 범위에서 랜덤
}
public enum DanmakuToDirection
{
World, // 탄막의 방향과 무관계한
Outer, // 주체와 반대방향
LookPlayer, // 탄막이 플레이어를 바라보도록
CompletelyRandom, // 완전히 랜덤한 방향으로
}
public enum NextPatternMethod
{
Timer, // 특정 시간 뒤 터뜨리기
WithRelease, // 반환과 함께 터뜨리기(삭제예정)
UserTrigger, // Manager에서 트리거 관리. 트리거 작동 시, 구독한 탄막들 터뜨리기.
}
public enum ReleaseMethod
{
Timer, // 특정 시간 뒤 터뜨리기
WithRelease, // 반환과 함께 터뜨리기(삭제예정)
UserTrigger, // Manager에서 트리거 관리. 트리거 작동 시, 구독한 탄막들 터뜨리기.
}
public enum DanmakuShape
{
Linear, // 가장 단순한 선형 사출
Circle, // 원형 (참고: 플레이어를 본 방향으로 원형으로 만들어 Enemy->Player 벡터로 방향을 주거나, 무작위 방향으로 원형 바깥방향으로 사출하면 자연스러울 듯.)
Sphere, // 구형
Cube, // 큐브형태. (참고: 레퍼런스 있음)
Custom, // 유저 입력을 받아 모양을 커스텀. Vector3리스트의 깡 입력으로 여러가지 모양을 만들 수도 있도록.
}
해당 SO 정보를 읽어, Enemy가 SO세팅에 맞춘 공격을 할 수 있도록 해야겠다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "Phases", menuName = "DanmakuSO/Phase", order = 1)]
public class PhaseSO : ScriptableObject
{
[System.Serializable]
public class PatternHierarchy
{
public PatternSO patternSO; // 참조할 PatternSO
public string patternName; // 실행할 패턴의 이름. PatternSO 에서
public float startTime; // 사이클 중 패턴이 시작할 시간
public float cycleTime; // 하위 패턴들을 굴릴 사이클의 전체 시간
public List<PatternHierarchy> subPatterns; // 하위 패턴 목록
}
public float cycleTime; // 하위 패턴들을 굴릴 사이클의 전체 시간
public List<PatternHierarchy> hierarchicalPatterns; // 계층 구조
// Todo.
// 여기 구조 다시 한 번 생각 해 봐야함. PatternSO를 참조하기보다 긁어와서 커스텀 할 수 있어야 할 것.
// PatternSO는 프리셋 모음이라는 것을 다시 한 번 상기하기
}
PhaseSO에서 PatternSO의 요소를 재귀적으로 아이템을 가질 수 있도록 구성하였다.
현재까지의 진행상황에 더불어, 파라미터를 제대로 전달하기 위한 장치를 구현해야한다.
Pool에 등록된 탄막의 DanmakuController.cs에서 OnEnable사용에 인해 예견된 문제 발생
아직 관련된 파라미터도 갖고있지 않으면서, 게임시작 시 한 차례 실행을 시도하여 오류가 발생한다. 추후 작업을 통해 해결을 해야 할 듯 하다.
3D 탄막게임을 최종 과제의 주제로 두어, 나는 업적 시스템과 탄막 시스템의 개발을 맡았다.
먼저 탄막 시스템을 개발하여 게임의 스타일을 잡은 후, 업적 시스템을 개발하는 것으로 정했다.
2. 현재까지 진행 한 작업
다이어그램
도전과제와 탄막 매니저의 모습에 대한 구상은 아래와 같다.
도전과제 확인 화면의 와이어 프레임탄막 매니저가 탄막을 생성하고 관리하는 대략적인 로직Todo 대략적인 다이어그램
탄막 매니저 기술적 요소
탄막 매니저를 구성하기 위한 기술적인 내용은 아래와 같다.
1. Pool Manager
탄막 오브젝트 풀을 이용하여 Instantiate와 Destroy의 사용을 줄여 성능을 최적화
2. Coroutine
탄막 로직 중 Delay가 필요한 곳에 사용. 불필요한 연산을 줄여 성능 최적화에 좋을 것으로 판단하여 사용.
3. Scriptable Object
몹들의 공격 페이즈 별 한 개의 파일로 저장. Gen() 호출 시, 경과 시간 별 호출할 탄막 패턴과 그 상세 내용에 대해 모두 작성. 단순한 몹은 하나의 파일을, 보스몹은 페이즈 당 하나의 파일을 가지게 될 것. (추가내용) 탄막 패턴별로 각 디폴트값을 저장할 SO도 구성한다.
기타 메모한 팁
Pool에 이미 반환한 오브젝트를 또 반환하려고 하면 에러를 표시한다.
// 탄막위치가 y > 5가 될 경우 삭제
if (this.transform.position.y > 5)
{
// 오브젝트 풀에 반환
Pool.Release(this.gameObject);
Pool.Release(this.gameObject); // 이미 없는 오브젝트를 반환하려고 할 시, 에러 발생.
Pool.Release(this.gameObject);
Pool.Release(this.gameObject);
Pool.Release(this.gameObject);
}
setActive로 재사용할 경우에도, Active 될 때 마다 구문을 사용하도록 할 수 있음
탄막 여러가지 정보를 초기화한다.
랜덤 Vector
// 반지름이 1인 원 안에서 위치를 Vector2로 가져옴
Vector2 randCirclePos = Random.insideUnitCircle;
// 반지름이 1인 구 안에서 위치를 Vector3로 가져옴
Vector3 randSpherePos = Random.insideUnitSphere;
// 반지름이 1인 구 위의 점 위치를 Vector3로 가져옴
Vector3 randOnSpherePos = Random.onUnitSphere;
팀 과제를 마치고, 금일 2시부터 과제발표 세션을 시작하였다. 이번 팀은 다른 팀원이 발표를 맡았다.
2. 오늘자 발표에 대해
발표에 대해
발표는 팀당 13분이 주어지며
게임의 소개
게임시연
각 팀원당 개발 중 문제 해결에 관한 영상(트러블슈팅)
회고 등
과 같은 구성을 갖는다.
피해야 할 점
발표 13분 전체를 영상으로 송출
발표와 동시에 재생되는 BGM. 발표 집중도에 방해가 된다.
문제해결 영상에 본인의 목소리가 아닌 TTS를 사용
기술 발표 목적의 세션에서 과한 감성적 요소와 인트로. 이후 회고 파트에서도 영양가 없는 내용은 피하도록.
괜찮았던 점
각자 맡은 역할에 대한 흥미로울 정도로 심도있는 문제 해결
개발 도중 이에 대한 메모를 차근차근 쌓아놓았다면 자연스레 가능할 것
그리고 문제해결 영상에 자막을 넣어 보기 좋게 하는 점
일부러 발표 시간을 살짝 남겨 가볍게 게임 플레이를 보여주는 진행방식
3. 회고
이번 팀 프로젝트에 대해
매번 느끼지만, 무척 짧은 팀 프로젝트의 기간은 매 번 느끼지만 장단점이 극명하다. 밀도있게 개발의 모든 단계를 진행하여 경험치를 쌓을 수 있지만, 심도있게 개발하기는 힘든 점.
하지만 이번 팀 프로젝트는 기획 초반에 게임의 볼륨을 크게 잡지 않아 완성도를 높이고 코드 리뷰와 함께 정제를 할 수 있었다. 되는대로 기능을 때려박고 기억에도 남지 않던 이전 프로젝트들과 비교하면 무척 생산적이었던 시간이었다.
개인적으로는 몬스터 파트를 하며 상속과 애니메이터, 그리고 몬스터 스폰에 관하여 ScriptableObject를 사용했는데, 아직도 잘 모르겠으면서도 나름 경험치를 쌓았다.
내일배움캠프 중간 만족도 평가
매 도중 과정을 마칠 때마다, 캠프에서 만족도 평가를 한다.
캠프 커리큘럼 자체가 아침 9시부터 밤 9시까지 매일 진행하는 일정 등 호불호가 갈릴 요소가 굉장히 많기 때문에, 타인에게 추천할 것인지에 대해서 매번 8/10점 정도를 매겼었는데, 오늘은 조금 볼륨있게 상세 내용을 작성했기 때문에 살짝 적어본다.
힘들었던 점만 적어보자면 /
너무 자주 바뀌는 팀원, 짧은 개발기간, 호불호가 심하게 갈릴 듯한 일부 즉흥적으로 보이는 세션 구성, 알고리즘의 대비가 되어있다는 초반 설명에 비해 문제풀이에 대한 심화적인 알고리즘 강의 세션이 없음.(프로젝트 활동만으로도 바쁘기 때문에 사실상 알고리즘까지 신경 쓸 여유가 없기는 하지만, 그렇다면 이에 대해 캠프 초반에 설명이 있었어야 했다고 생각한다)
개인학습 시간이 압도적으로 부족한 데에 비해, 제공되는 강의는 갑자기 난이도가 상승하여 네다섯번을 봐도 이해하기 어려운 내용으로 꽉 차 있는 경우도 있어 시간이 낭비된다.
현재의 유니티 3기 캠프는 내가 이렇다고 느끼고 있지만, 앞으로 더 나은 캠프를 위한 방향성을 위한 설문이기 때문에 나아질 것으로 생각된다.
그리고 교육과 이에 관한 관리를 하는 기관이라는 이미지에 비해 튜터진과 매니저들의 마이크 이슈(마이크 볼륨, 음질. 특히 오늘자 선배와의 만남 세션은 이어폰 마이크를 사용하시던데 말하는 내용이 식별이 안 될 정도로 음질이 끔찍했다)가 개인적으로는 무척 신경쓰이는데 회사 차원에서 질 좋은 마이크를 1인 1개씩 지원해주면 좋겠다고 잠깐 생각했다.
4. 앞으로의 과제
내일부터 최종프로젝트의 시작이다. 3D 탄막게임에서 나는 도전과제와 탄막패턴(+최적화)를 메인으로 개발을 진행한다. 일단 아래의 강의를 학습하며 도전과제 구현에 대한 아이디어를 얻을 예정이고, 탄막의 패턴에 관해서는 이전에 받아뒀던 탄막 패턴 에셋으로부터 작동 과정과 최적화 방식을 확인하여 참고가 된다면 할 생각이다. 탄막패턴 에셋은 2D 기반으로 작성되어있기 때문에 이를 참고한다고 하더라도 난항을 겪지 않을까 예상된다.
금일 예정은, 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를 할 시간이 충분하다. 세세한 부분도 부족하다면 차차 수리하거나 추가개발을 진행해도 될 정도이다. 다른 팀원의 코드도 확인 해 나가며 배울 점을 찾을 수 있을 것 같다.