Post

부드러운 2D 보스 이동 연구 및 구현

요구사항 및 구현 결과

요구사항은 간결하다

모든 움직임 및 움직임 전환이 부드러워야 한다.

구현 결과 gif

본문에서는 두 가지의 행동 패턴을 구현할 것이다.

  1. 플레이어를 중심으로 원 모양으로 회전하며 탄막 발사
  2. 플레이어에게 세 번 돌진

1번 패턴 : 플레이어 중심 원 궤도 운동

좌 : 특정 각도로 즉시 선회 (=관성 무시), 물리적으로 부자연스러움/ 우 : 완만하게 곡선을 그리며 원형 궤도에 진입

P는 플레이어, B는 보스, 초록색 원은 보스가 진입해야 하는 궤도임

좌측 이미지는 부드럽지 않은 (물리적으로 부자연스러운) 움직임을 표현한 것이고, 우측 이미지는 부드러운 움직임을 표현한 것이다.

핵심적인 차이는 선회 반경이다.

선회 반경 예시

급격하게 이동 방향을 바꿀수록, 큰 힘을 필요로 하며, 뚝 끊기는 느낌을 준다.

따라서 좌측 이미지와 같은 급격한 (불연속적인) 이동 방향 변경 없이, 우측 이미지처럼 부드럽게 원 궤도에 진입 할 수 있도록 구현할 것이다.

이를 위한 아이디어는 다음과 같다.

아이디어 개요

매 프레임마다 보스는 다음과 같은 연산을 수행한다.

  1. 플레이어를 중심으로 하는 원 궤도 C (초록색 원)
  2. 플레이어와 보스를 잇는 직선 L (보라색 선)
  3. C와 L의 교점 P1 (주황색 점)
  4. 플레이어를 기준으로 P1에서 θ 각 만큼 떨어진 C 위의 점 P2
  5. 보스는 P2를 향하는 벡터 V를 정규화하여 이동방향 벡터로 삼는다.

결과적으로 보스는 매 프레임마다 현재 위치에서 원 궤도 위의 다음 점 위치를 목표 방향으로 삼고 움직이므로, 부드럽게 원 궤도에 합류하여 궤도 운동을 하게 된다.

해당 논리 흐름을 Unity Coroutine으로 구현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
IEnumerator BehaviourTick(){
	while (true)
	{
		var playerPos = Player.player.transform.position;
	  // 처음에 제일 가까운 플레이어 원형지점 찾기 (플레이어 -> 보스 위치 직선과 플레이어 일정거리 반지름 원과의 교점)
	  Vector3 nearestCirclePos = playerPos + (transform.position - playerPos).normalized * 7.5f;
	
	  // 원 방정식 위에서 다음 이동 지점을 회전 행렬로 구하고자 함
	  float angleStep = 0.2f;
	  float cos = Mathf.Cos(angleStep);
	  float sin = Mathf.Sin(angleStep);
	
	  float dx = nearestCirclePos.x - playerPos.x;
	  float dy = nearestCirclePos.y - playerPos.y;
	
	  // 회전 행렬 적용
	  Vector3 nextStepCirclePos = new(cos * dx - sin * dy + playerPos.x,
	    sin * dx + cos * dy + playerPos.y);
	
	  rigid.velocity = (nextStepCirclePos - transform.position).normalized * moveSpeed;
	  yield return null;
	}
}

2번 패턴 : 플레이어에게 돌진

필자는 돌진 시 ‘약간 준비 자세를 취했다가 휙 빠르게 움직이는’ 느낌을 원했다.

이를 구현하기 위한 움직임 Ease 그래프의 개형은 다음과 같다.

그래프 개형

이미 구현 된 Ease 곡선중엔 해당 개형을 가진 곡선이 없어서, 기존에 공개된 Ease 그래프들의 수식을 바탕으로 구간을 나눠서 개형을 잡고, 수치(노가다)적인 방법으로 비교적 연속적이고 매끄러운 것처럼 보이는 그래프를 얻어냈다.

해당 그래프의 수식은 다음과 같다

\[a = 1.766,\qquad b = 1 - \frac{1}{a} \approx 0.4337486,\qquad c = -\frac{0.30322}{\ln(0.5a)} \approx 5.6111\\\] \[f(x)= \begin{cases} 14.4x^{3}-5.19x^{2}, & 0 \le x < 0.5,\\[4pt] 1-\bigl(1-a(x-b)\bigr)^{c}, & 0.5 \le x \le 1. \end{cases}\]

일단 식이 더럽고, f(0.5)의 좌극한 우극한 값이 소수점 다섯째자리에서부터 서로 다르다.

비록 오차가 있지만 이게 내 최선이었다.

이제 매 프레임에서 속도를 얻기 위해, 시간-위치 그래프를 미분하여 시간-속도 그래프를 얻어낸다.

\[f'(x)= \begin{cases} 43.2x^{2}-10.38x , & 0 \le x < 0.5,\\[4pt] ac(1-a(x-b)\bigr)^{c-1}, & 0.5 \le x \le 1. \end{cases}\]

f’(x)의 개형은 다음과 같다

미분 그래프 개형

이제 이 돌진 시간-속도 수식을 코드로 구현하는 일만 남았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
IEnumerator ChargePattern()
{
    // 3번 돌진함

    // EaseIn(x <= 0.5) + EaseOutQuart (x > 0.5) 돌진
    // 이후 플레이어 y 보다 내 y가 작다면 위쪽으로 살짝 반원, 아니라면 아래쪽으로 살짝 반원 이동 (0.5초) => 돌진 반복

    // EaseIn + OutQuart
    // 14.4x^3 - 5.19x^2 (x <= 0.5)
    // 1 - (1 - a(x-b))^c (x >= 1, a = 1.766, b = 1 - 1/a, c = -0.30322/log(0.5a))

    for(int i = 0; i < 3; i++)
    {
        // 세번 돌진

        // 플레이어 근처 (y축이 비슷한 위치)로 근접
        var playerPos = Player.player.transform.position;
        // 1. 보스 → 플레이어 방향 벡터
        Vector2 direction = transform.position - playerPos;

        // 2. 각도 (라디안 → 도)
        float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;

        // 3. 가장 가까운 유효 각도 찾기
        float[] allowedAngles = { -10f, 10f, 170f, 190f }; // 허용 구간 경계값
        float closest = allowedAngles
            .Select(a => Mathf.Repeat(a - angle + 180f, 360f) - 180f)  // 거리 계산
            .OrderBy(a => Mathf.Abs(a))
            .First() + angle;

        // 최종 타겟 각도 (정규화)
        float targetAngle = Mathf.Repeat(closest, 360f);

        // 4. 각도로부터 원 위의 점 계산
        float radius = 10f; // 원하는 반지름
        Vector3 offset = new Vector3(
            Mathf.Cos(targetAngle * Mathf.Deg2Rad),
            Mathf.Sin(targetAngle * Mathf.Deg2Rad)
        ) * radius;

        while ((Player.player.transform.position + offset - transform.position).sqrMagnitude > moveSpeed)
        {
            // 유효 각도로 이동
            rigid.velocity = (Player.player.transform.position + offset - transform.position).normalized * moveSpeed;
            yield return null;
        }


        // 잠시동안 상대(relative) 위치에서 머문다
        for (int j = 0; j < 18; j++)
        {
            rigid.velocity = (Player.player.transform.position + offset - transform.position).normalized * moveSpeed;
            yield return null;
        }

        Vector3 chargeDirection = (Player.player.transform.position - transform.position).normalized * offset.magnitude;

        // 돌진
        float speed = 0f;
        for (float tick = 0; tick < 1f; tick += Time.deltaTime)
        {
            if(tick < 0.5f)
            {
                speed = 43.2f * tick * tick - 10.38f * tick;
            }
            else
            {
                speed = 9.9092f * Mathf.Pow(1 - 1.766f * (tick - 0.4337f), -0.30322f / Mathf.Log(0.5f * 1.766f) - 1f);
            }

            rigid.velocity = chargeDirection * speed;
            yield return null;
        }
    }
}

코드에 뭐가 덕지덕지 붙어있는데, 단순 돌진 코드만 있는게 아니라서 그렇다. 먼저 플레이어 기준으로 ‘특정 각도’로 이동한 후에 돌진하게끔 구현했다.

이는 플레이 화면 비율 때문이다.

가로 모드를 기반으로, 플레이어 기준 ‘좌우’ 위치에서 돌진하는 경우, 돌진 준비 모션이 정상적으로 보이지만 플레이어 기준 ‘상하’ 위치에서 돌진하는 경우 화면 밖에서 돌진 준비 모션을 진행하게 되어 플레이어가 보스 돌진에 대응하기가 어렵다는 문제점이 있었다.

따라서 돌진 시작 전에, 특정 유효 각도 (좌우 20도)에 제한을 걸어서, 해당 각도 밖에서 돌진 패턴이 시작 되는 경우, 유효 각도 내로 먼저 이동한 후에 돌진하도록 코드를 추가하였다.

This post is licensed under CC BY 4.0 by the author.

Trending Tags