갤러그에서 가장 대단하고 세련됐다고 생각되는 부분은 적의 움직임이었다. 아름답게 곡선을 그리고 차례대로 배치되는 적들을 보면 갤러그가 오랫동안 사람들에게 사랑받은 이유가 무엇인지 알 수 있었다. 보통은 직선 운동이나, 키 입력을 받아서 이동하는 부분만 구현해서 곡선 운동을 어떻게 구현하는지 찾아보았다. 그와 관련해서 찾은 것이 베지에 곡선이었다.
여러 블로그를 참고했지만 바로 머리에 들어오지는 않았다.
유니티 - 베지어 곡선(Bezier Curve) | Rito15
유니티 - 베지어 곡선(Bezier Curve)
베지어 곡선 점과 점 사이의 선형 보간(Lerp, Linear interpolation)을 이용해 그려내는 곡선
rito15.github.io
내 기준 가장 설명이 깔끔했던 블로그는 여기다.
위 그림은 2차 베지어 곡선의 그림이다. 출발지와 목적지, 조절점 총 3가지의 Vector가 필요하다. 좌측 하단의 점을 점1, 상단 중앙의 점을 점2, 우측 하단의 점을 점3라고 하면, Vector2.Lerp 함수를 사용해서 점1과 점2의 선형 보간을 구하고, 점2와 점3의 선형 보간을 구한다. 이게 왼쪽/오른쪽 빨간색 직선상에서 움직이는 파란색 점이다.
시간을 누적해가며 다시 이 두 점의 선형 보간을 구하면 위와 같이 아름다운 곡선이 그려지는 원리였다.
조절점이 2개인 3차 베지어 곡선도 원리는 동일하다. 2차 베지어 곡선 2개가 중첩된 느낌이다.
[Unity] 베지어 곡선(Bezier Curves) 구현해보기 — Night's Devlog (tistory.com)
[Unity] 베지어 곡선(Bezier Curves) 구현해보기
게임을 개발하다보면 곡선을 그려야 할 때가 자주 있다. 곡선을 그리는 방법이 다양하게 있는데 그 중 베지어 곡선(Bezier Curves)에 대해 알아보자 1. 베지어 곡선이란 점과 점 사이의 선형 보간을
night-devlog.tistory.com
코드는 이 블로그에서 작성한 코드가 가장 직관적이라고 생각하여 참조하여 작성했다.
public IEnumerator DoBezierCurves(Vector2 startPosition, Vector2 controlPosition, Vector2 targetPosition)
{
float time = 0;
while(true)
{
time += Time.deltaTime / 10;
if(gameObject.GetComponent<RectTransform>().anchoredPosition == targetPosition)
{
yield break;
}
Vector2 position1 = Vector2.Lerp(startPosition, controlPosition, time);
Vector2 position2 = Vector2.Lerp(controlPosition, targetPosition, time);
gameObject.GetComponent<RectTransform>().anchoredPosition = Vector2.Lerp(position1, position2, time);
yield return null;
}
}
처음에는 2차 베지어 곡선을 구현했다. 그런데 움직임이 약간 단순해보여서 코드를 수정하여 3차 베지어 곡선으로 수정했다.
public IEnumerator DoBezierCurves2(Vector2 startPosition, Vector2 controlPosition1, Vector2 controlPosition2, Vector2 targetPosition, float speed)
{
float time = 0;
while (true)
{
time += Time.deltaTime / 10 * speed;
if (!gameObject.activeSelf) yield break;
if (gameObject.GetComponent<RectTransform>().anchoredPosition == targetPosition)
{
gameObject.GetComponent<RectTransform>().anchoredPosition = startPosition;
StartCoroutine(DoBezierCurves2(startPosition, controlPosition1, controlPosition2, targetPosition, speed));
yield break;
}
Vector2 position1 = Vector2.Lerp(startPosition, controlPosition1, time);
Vector2 position2 = Vector2.Lerp(controlPosition1, controlPosition2, time);
Vector2 position3 = Vector2.Lerp(controlPosition2, targetPosition, time);
Vector2 position4 = Vector2.Lerp(position1, position2, time);
Vector2 position5 = Vector2.Lerp(position2, position3, time);
gameObject.GetComponent<RectTransform>().anchoredPosition = Vector2.Lerp(position4, position5, time);
yield return null;
}
}
이 함수는 EnemyController.cs에 탑재하여 적이 끝까지 이동하면 초기 지점으로 이동하여 다시 똑같이 움직이도록 하였다.
// 기즈모
[SerializeField] Transform _target, _p1, _p2, _p3, _p4;
[SerializeField] public float _gizmoDetail;
List<Vector2> _gizmoPoints = new List<Vector2>();
private void OnDrawGizmos()
{
_gizmoPoints.Clear();
for (int i=0; i<_gizmoDetail; i++)
{
float t = (i / _gizmoDetail);
Vector2 p5 = Vector2.Lerp(_p1.position, _p2.position, t);
Vector2 p6 = Vector2.Lerp(_p2.position, _p3.position, t);
Vector2 p7 = Vector2.Lerp(_p3.position, _p4.position, t);
Vector2 p8 = Vector2.Lerp(p5, p6, t);
Vector2 p9 = Vector2.Lerp(p6, p7, t);
_gizmoPoints.Add(Vector2.Lerp(p8, p9, t));
}
for (int i=0; i<_gizmoPoints.Count - 1; i++)
{
Gizmos.DrawLine(_gizmoPoints[i], _gizmoPoints[i + 1]);
}
}
기즈모를 그리는 것에 관해서도 나와있었다. 베지어 곡선을 그리는 것과 비슷하게 코드를 작성하고, 그림을 그리면 되었다.
이렇게 그림을 그리고 _p1~_p4까지 transform을 움직이면 베지어 곡선이 어떤 형태로 그려지는지 볼 수 있다. 굉장히 편리한 기능이다.
// 적이 스폰되는 번호에 따라 위치와 동작 부여
private void SpawnBezierCurveEnemy(string enemyName, int spawnNum, float speed)
{
GameObject enemy;
if (spawnNum == 0)
{
enemy = SpawnEnemy(enemyName, new Vector2(-300, 600));
StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
new Vector2(-400, 0), new Vector2(500, 600), new Vector2(300, -700), speed));
}
else if (spawnNum == 1)
{
enemy = SpawnEnemy(enemyName, new Vector2(-150, 600));
StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
new Vector2(800, 0), new Vector2(-800, 0), new Vector2(150, -700), speed));
}
else if (spawnNum == 2)
{
enemy = SpawnEnemy(enemyName, new Vector2(150, 600));
StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
new Vector2(-800, 0), new Vector2(800, 0), new Vector2(-150, -700), speed));
}
else if (spawnNum == 3)
{
enemy = SpawnEnemy(enemyName, new Vector2(300, 600));
StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
new Vector2(400, 0), new Vector2(-500, 600), new Vector2(-300, -700), speed));
}
}
적을 소환하고 움직임을 주는 부분은 ShootingGameManager.cs에서 작동하도록 하였다. 적이 나타나는 위치를 고정하고, 양쪽이 대충으로 똑같으면 똑같은 지점을 지나서 조금 단순해보이는 느낌이 들어서 바깥쪽 두 개가 서로 대칭하게, 안쪽 두 개가 서로 대칭하게끔 구현하였다. 갤러그는 플레이어 위치에 따라 적이 움직이는데, 갤러그의 경우는 플레이어가 위 아래로는 움직이지 못하고 좌우로만 움직일 수 있기 때문에 적이 항상 아래로 사라지는데, 나는 위/아래 움직임도 추가했기 때문에 적이 오른쪽이나 왼쪽으로 사라질 가능성이 있는 문제가 생겼다. 적의 움직임을 이런식으로 제한하거나, 적이 플레이어를 추격하기 위해서는 위/아래 움직임을 제한할 필요가 있을 것 같다. 개인적으로는 위/아래 움직임이 막히면 조작이 불편한 느낌이 있고, 지금 적의 움직임도 나쁘지 않은 것 같아 아마 지금 이대로 갈 것 같다.
private IEnumerator NextPhase()
{
yield return new WaitForSeconds(3);
if (_phaseNum == 0)
{
for (int i = 0; i < 5; i++)
{
++_leftEnemy;
SpawnStraightEnemy("PinkEnemy", i);
}
}
else if (_phaseNum == 1)
{
for (int i = 0; i < 5; i++)
{
++_leftEnemy;
SpawnStraightEnemy("GreenEnemy", i);
}
}
else if (_phaseNum == 2)
{
for(int i=0; i<4; i++)
{
++_leftEnemy;
SpawnBezierCurveEnemy("YellowEnemy", i, YellowEnemySpeed);
}
}
else if (_phaseNum == 3)
{
for (int i = 0; i < 4; i++)
{
++_leftEnemy;
SpawnBezierCurveEnemy("PurpleEnemy", i, PurpleEnemySpeed);
}
}
else if (_phaseNum == 4)
{
}
_phaseNum++;
}
적 밸런스와 스테이지 구성도 약간 했다. 핑크색 적은 체력이 높은 대신에(3) 속도가 느리고, 초록색 적은 체력이 낮은 대신에(2) 속도가 빠르다(약 20%). 노란색과 보라색도 체력2/속도1, 체력1/속도2 로 설계했다.
지금은 노란색과 보라색에만 곡선 움직임을 적용했는데, 핑크색과 초록색 적에도 곡선 움직임을 적용하게 된다면 공격 딜레이로 밸런스를 맞추면 좋을 것 같다.
현재 스테이지 구성은 각 적의 체력이나 이동속도, 공격속도 등을 알 수 있게 해주는 4개의 초기 스테이지로 구성되어있다.
한 번에 다양한 적이 다양한 움직임으로 나타나다가 보스가 등장하고, 이후 적 소환을 반복하면 될 것 같다.
이외에는 최고 점수 갱신시 효과 추가, 적에게 피격당했을 때 애니메이션을 추가했다.
아! 그리고 버그를 해결하며 새로 알게 된 것들도 있다. Queue를 사용할 때와 Coroutine을 사용할 때 주의할 점이다.
Queue를 사용할 때, Enqueue를 두 번 하게되면, 동일한 개체가 2번 들어가서 의도한 것과 다른 결과가 나올 수 있다. 총알이 발사되고 일정시간 후, 적과 충돌했을때 Enqueue를 하게 되니 적과 충돌한 이후에 시간이 지나면 Enqueue가 한 번 더 호출되어 동일한 총알이 2번 들어가서 총알이 정상적으로 발사되지 않는 문제가 있었다. 큐를 사용할 때는 중복하여 Enqueue가 되지 않도록 조심해야겠다.
또, Coroutine에 대한 것도 추가로 알게 되었다. 어제는 Coroutine을 호출한 객체가 disabled되면 호출된 Coroutine이 존재하는 스크립트가 살아있어도 Coroutine이 종료되는 것을 알게 되었다. 그런데, Coroutine을 호출한 객체가 살아있으면, 호출된 Coroutine이 존재하는 스크립트의 GameObject가 disabled되어도 코루틴이 계속 실행되는 것이었다! 아마도 코루틴을 실행하기 위해 스크립트를 살려두는 것 같았다. 이 부분도 Coroutine을 사용할 때 조심해야겠다.
그림으로 표현하면 대충 이런 느낌일 것 같다.
오늘까지 구현한 부분은 다음과 같다!
오늘은 디테일이 조금 더해진 것 같다. 어제까지만 해도 적 이동이 너무 단조로워서 아쉬웠는데, 베지어 곡선을 활용하면서 움직임이 조금 다채로워진 것 같아서 마음에 든다. 처음에는 약간 개념을 이해하기 어려웠는데, 이해하고 나니 그렇게 어려운 것은 아니었다. 찾아보니 이곳저곳에서 종종 사용되는 개념인 것 같다. 새로운 것을 배운 것 같아 기쁘다.
슈팅 게임에서 남은 목표는 다음과 같다.
1. 스테이지 설계하기
2. 보스 스킬, 패턴, 체력바 등 구현
이제 개발 마무리 단계인 것 같다. 처음에는 슈팅 게임을 조금 단순하게 보고 있었는데, 직접 만들어보니 전혀 그렇지 않았다. 생각보다 신경쓸 것이 꽤나 많았다. 구현하면서 새로 배운 개념들도 있고 이것저것 디테일을 추가해가면서 완성도를 높이는 작업도 중요한 것 같다. 간단한 것이라도 끝까지 한 번 완성해보라는 이유를 알 것 같다. 부지런히 작업해서 이번 주 중으로 마무리를 하면 좋을 것 같다.