멈추지 않고 끈질기게
[Unity][포트폴리오] 신규 스테이지 추가 본문
※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.
※ 해당 포스팅은 Unity 2021.3.15f1 버전을 기준으로 작성되었습니다.
※ 개발 중인 유니티 3D 포트폴리오의 소스 코드를 포함하고 있습니다.
0. 서문
폴가이즈의 '점프 쇼다운' 맵을 모방한 스테이지를 제작하는 과정에 대한 내용입니다. 폴가이즈처럼 정확한 원형 궤도에 맞는 발판으로 만들고 싶었는데, 아쉽게도 사용중인 에셋에 그런 발판이 없어서 일반 사각형 발판을 사용했습니다.
1. 회전 막대
맵 중앙의 회전 막대는 기존의 RotatingObstacle 클래스를 사용하여 구현했습니다.
public class RotatingObstacle : MonoBehaviour
{
Rigidbody rigid;
[SerializeField] float rotateDegree;
Quaternion deltaRotation;
Vector3 rotateVelocity;
public enum RotateDirection
{
Clockwise,
Counter_Clockwise
}
[SerializeField] RotateDirection myDir;
void Awake()
{
rigid = GetComponent<Rigidbody>();
}
void Start()
{
// 방향 벡터 초기화
switch (myDir)
{
case RotateDirection.Clockwise:
rotateVelocity = new Vector3(0, rotateDegree, 0);
break;
case RotateDirection.Counter_Clockwise:
rotateVelocity = new Vector3(0, -rotateDegree, 0);
break;
}
}
void FixedUpdate()
{
deltaRotation = Quaternion.Euler(rotateVelocity * Time.fixedDeltaTime);
rigid.MoveRotation(rigid.rotation * deltaRotation);
}
회전을 Rigidbody로 구현할 예정이므로 Rigidbody 변수를 하나 선언하고, Awake()에서 초기화하였습니다. 초당 몇도 회전시킬지는 인스펙터 창에서 입력할 수 있도록 SerializeField로 선언하였고, 회전 방향 벡터 rotationVelocity와 회전 값을 저장하기 위한 Quarternion 변수를 선언하였습니다.
회전 방향은 인스펙터 창에서 시계방향/반시계방향 중 고를 수 있도록 열거형을 선언하고, 해당 열거형 타입의 변수를 SerializeField로 선언하였습니다. 해당 변수에 따라 Start()에서 rotationVelocity를 초기화하도록 했습니다. 회전은 FixedUpdate()에서 rotationVelocity와 fixedDeltaTime을 곱한 값으로 Quarternion 값을 설정한 뒤, Rigidbody의 MoveRotation()에 사용하여 구현했습니다.
#region 외부 호출용
public void RotationStop()
{
isRotating = false;
}
public void RotationStart()
{
isRotating = true;
}
public void Accelerate(float value)
{
switch (myDir)
{
case RotateDirection.Clockwise:
rotateVelocity.y += value;
break;
case RotateDirection.Counter_Clockwise:
rotateVelocity.y -= value;
break;
}
}
#endregion
}
추가로 외부 호출용 함수들을 작성하였습니다. RotationStart() 및 RotationStop() 함수를 통해 외부(StageController)에서 회전을 시작, 정지시킬 수 있도록 하였고, Acclerate() 함수를 통해 외부에서 해당 회전형 장애물의 속도를 조절할 수 있도록 했습니다.
2. DelayFallPlatform
폴가이즈의 '점프 쇼다운'에서는 시간 경과에 따라 임의의 발판이 선택되고, 곧 떨어질 것을 알리기 위해 발판이 어느정도 흔들리고 나서 떨어집니다. 기존의 FallPlatform 클래스를 사용하기에는 1) 추락 전 몇초동안 흔들림 노출, 2) 특정 타이밍에 추락 로직을 시작하는 외부 호출용 함수를 추가할 필요가 있어 별도의 클래스(DelayFallPlatform)를 작성했습니다.
public class DelayFallPlatform : MonoBehaviour
{
Rigidbody rigid;
[Header("추락")]
[SerializeField] float fallDelay;
bool fallReady;
[Header("흔들림")]
[SerializeField] float speed;
[SerializeField] float amount;
Vector3 posVec;
float initialY;
void Awake()
{
rigid = GetComponent<Rigidbody>();
}
void Start()
{
initialY = transform.position.y;
}
void Update()
{
if (!fallReady) return;
posVec = transform.position;
posVec.y = initialY + Mathf.Sin(Time.time * speed) * amount;
transform.position = posVec;
}
public void StartFall()
{
StartCoroutine(Fall());
}
IEnumerator Fall()
{
fallReady = true;
yield return WfsManager.Instance.GetWaitForSeconds(fallDelay);
// 대기 후 추락
fallReady = false;
rigid.isKinematic = false;
}
// 아웃 지점에 닿으면 비활성화
void OnTriggerEnter(Collider other)
{
if (!other.CompareTag(Tags.OutArea))
return;
gameObject.SetActive(false);
}
}
추락을 구현하기 위한 Rigidbody 변수를 선언하고 Awake()에서 초기화했습니다. 추락 요청 시 추락까지 걸리는 시간은 인스펙터 창에서 설정할 수 있도록 SerailizeFiled로 선언하였고, 추락 요청을 받았는지 여부를 구분하기 위한 bool 변수를 선언하였습니다.
외부 호출용 StartFall() 함수를 작성하여 StageController에서 추락 코루틴 Fall()을 실행시킬 수 있도록 했습니다. 해당 코루틴에서는 fallDelay만큼 대기하는 동안 Update()에서 흔들림 연출을 실행하게 하고, fallDelay 이후 발판이 중력 영향을 받아 바로 추락하도록 했습니다.
흔들림 연출 부분은 유니티 포럼의 한 글을 참조하였습니다.
https://forum.unity.com/threads/shake-an-object-from-script.138382/
-1에서 1사이의 값을 갖는 Sin 함수의 특징과 시간에 따라 변하는 Time.time을 변수로 이용하여 좌표값에 더할 값을 정하는 방식입니다. speed 값으로 반복 주기를, amount 값으로 진동 폭을 조절할 수 있습니다. 저는 최초 y좌표에서 너무 벗어나지 않도록 initialY 변수에 더하는 방식으로 사용했고, 덜덜 떨리는 느낌을 주기 위해 speed는 50으로 크게, amount는 0.03으로 작게 주었습니다. 결과는 다음과 같습니다.
3. StageController 수정
기존의 StageController 클래스를 그대로 사용하기에는 스테이지마다 추가해야할 내용이 있어, 해당 클래스를 베이스로 스테이지마다 고유의 클래스를 작성하고 StageController를 상속받는 식으로 구현 방식을 변경했습니다.
public class StageController : MonoBehaviour
{
void Awake()
{
isSingleGame = GameManager.Instance.IsSingleGame;
curState = State.NotBegin;
PV = GetComponent<PhotonView>();
// 싱글게임/멀티게임 초기화 구분
if (NetworkManager.Instance.InRoom)
Initialize_Multi();
else
Initialize_Single();
}
#region 가상 함수
// 상속받는 클래스에서 구현
// 싱글 플레이용 초기화 함수
protected virtual void Initialize_Single() { }
// 멀티 플레이용 초기화 함수
protected virtual void Initialize_Multi() { }
// 게임 시작 시점에 호출하는 함수
protected virtual void OnGameStart() { }
// 게임 종료 시점에 호출하는 함수
protected virtual void OnGameStop() { }
#endregion
기존의 StageController에 상속받는 클래스에서 필요에 따라 구현하도록 초기화 함수(싱글/멀티 구분) 및 게임 시작 시점, 종료 시점에 호출될 함수를 가상함수로 선언하였습니다. 초기화 함수는 Awake()에서 싱글/멀티 여부에 따라 구분하여 호출하도록 작성했습니다.
enum State
{
NotBegin,
OnGame,
EndGame
}
State curState;
// 게임 시작 및 정지 시점 체크
void Update()
{
// 게임 시작 시점
if(curState == State.NotBegin && !GameManager.Instance.IsPaused)
{
curState = State.OnGame;
OnGameStart();
return;
}
// 게임 종료 시점
if (curState == State.OnGame && GameManager.Instance.IsPaused)
{
curState= State.EndGame;
OnGameStop();
return;
}
}
현재 게임 상태를 구분하기 위한 열거형 State와 열거형변수 curState를 선언하였습니다. Update()에서 게임 시작 시점(curState가 NotBegin인 상태에서 GameManager의 일시정지가 풀렸을 때)에 OnGameStart()를 호출하고, 게임 종료 시점(curState가 OnGame인 상태에서 GameManager에 일시정지가 걸렸을 때)에 OnGameStop()을 호출하도록 작성했습니다. 매 프레임마다 도는 Update()에 체크 로직을 작성하고 싶지 않았지만, 종료 시점 체크에 어려움이 있어 우선 기능부터 확인하기 위해 Update()에서 체크하도록 했습니다(추후 개선 필요).
다음은 StageController를 상속받는 Survive_02 씬 전용 클래스입니다.
public class Survive_02 : StageController
{
[Header("막대 회전")]
[SerializeField] RotatingObstacle lowerStick;
[SerializeField] RotatingObstacle upperStick;
[SerializeField] float acclerateInterval;
[SerializeField] float acclerateDegree;
[Header("발판 추락")]
[SerializeField] DelayFallPlatform[] delayFallPlatforms;
[SerializeField] float fallDownInterval;
int count;
protected override void Initialize_Multi()
{
count = delayFallPlatforms.Length;
}
// 스틱 회전 시작 및 가속 코루틴 호출
protected override void OnGameStart()
{
lowerStick.RotationStart();
upperStick.RotationStart();
StartCoroutine(FallDown());
StartCoroutine(AccelerateLowerStick());
}
// 스틱 회전 정지
protected override void OnGameStop()
{
lowerStick.RotationStop();
upperStick.RotationStop();
}
IEnumerator FallDown()
{
List<int> list = new List<int>(delayFallPlatforms.Length);
for(int i = 0; i < delayFallPlatforms.Length; i++)
list.Add(i);
// fallDownInterval 만큼 대기 후 떨어트릴 플랫폼 지정
// 최소 2개는 남겨두도록 설정
int randIdx;
while (count > 2)
{
yield return WfsManager.Instance.GetWaitForSeconds(fallDownInterval);
randIdx = Random.Range(0, list.Count);
delayFallPlatforms[list[randIdx]].StartFall();
list.RemoveAt(randIdx);
count--;
}
}
IEnumerator AccelerateLowerStick()
{
while(!GameManager.Instance.IsPaused)
{
yield return WfsManager.Instance.GetWaitForSeconds(acclerateInterval);
lowerStick.Accelerate(acclerateDegree);
}
}
}
우선 해당 맵은 멀티플레이 용으로만 사용할 예정이기에 Initialize_Multi()만 오버라이딩 했습니다. 게임 시작 시(OnGameStart) 맵 중앙의 막대들이 회전을 시작하도록 하고, 발판 추락용 FallDown() 코루틴과 하부 막대 가속용 코루틴 AccelerateLowerStick()을 호출하도록 했습니다. 게임 종료 시(OnGameStop)에는 정지 연출을 위해 막대의 회전을 멈추도록 했습니다.
발판 추락용 코루틴(FallDown)에서는 연동해놓은 DelayPlaform 배열과 같은 크기의 List를 선언한 뒤 0부터 차례대로 인덱스를 저장하게 했습니다. 루프를 돌며 fallDownInterval만큼 대기한 뒤, 리스트에서 랜덤한 값을 뽑아 해당 인덱스의 플랫폼을 추락시키도록 했습니다(StartFall() 호출). 이후 리스트에서 해당 인덱스 값을 제거하여 중복값을 뽑지 않도록 했습니다.
하부 회전 막대 가속용 코루틴(AccelerateLowerStick)에서는 일시정지 상태가 될 때까지(게임이 끝날때까지) 루프를 돌며 회전 막대의 가속용 함수를 호출하여 점점 빨라지도록 구현했습니다.
4. 추가 개선 사항
현재 생각하는 추가로 개선해야 할 사항들은 다음과 같습니다.
- StageController의 게임 시작 시점, 종료 시점 체크 로직 개선
- Update()에서 매 프레임마다 체크하는 것은 비효율적으로 보임
- 현재 GameManager 쪽에서 StageController를 참조할 방법이 없어 어려움 - 발판 추락 로직 개선
- 현재 로직에서는 발판이 두 편으로 나뉜 후, 한쪽 편의 발판만 전부 없어질 수 있음
=> 운으로 승패가 결정날 수 있음
- 발판이 두 편으로 나뉜 경우, 양쪽에서 발판 1개씩을 남기는 방향으로 개선 필요 - 회전 막대 충돌 개선
- 현재는 Rigidbody에 의해 자동 계산되는 충격량만 받음
- 아직 회전막대가 느린 단계에서는 그냥 부딪히면서 점프하는게 가능함
- 기존에 보류한 플레이어 넉백 시스템을 이용할지 고려 중
해당 내용들은 수정하는 대로 포스팅에 내용을 추가하도록 하겠습니다.
'포트폴리오' 카테고리의 다른 글
[Unity][Photon][포트폴리오] 씬 로딩 동기화하기 (0) | 2023.07.12 |
---|---|
[Unity][포트폴리오] 카메라 회전 (0) | 2023.06.22 |
[Unity][Graphics][포트폴리오] 기본 그래픽스 설정 (0) | 2023.06.13 |
[Unity][포트폴리오] 멀티플레이 관련 이슈 (0) | 2023.06.11 |
[Unity][포트폴리오] 진짜 혹은 가짜(True or False) 발판 (0) | 2023.06.10 |