장애물 장판 결과물

 

점프 피하기 게임을 만들면서 화살 장애물을 떨어뜨리기 전에 바닥에 미리 경고 영역(장판)을 보여주는 연출이 필요했습니다.

플레이어 입장에서는 어디로 피해야 하는지 알 수 없으면 그냥 맞는 느낌이 들기 때문에, 화살이 떨어지기 전에 "여기 위험해" 라는 시각 신호를 줘야 했습니다.

그래서 이번에는 장판 Fill 연출을 담당하는 TrapArea와 이를 실제로 제어하는 Obstacle_Arrow를 나눠서 구현했습니다.


1. 전체 흐름

전체 구조는 단순합니다.

  1. Obstacle_Arrow.Setup() 이 호출되면 비동기로 연출 시작
  2. TrapArea.PlayFill() 이 duration 동안 장판을 0 → 1로 채움
  3. Fill이 완료되면 화살이 낙하 시작(StartFall())
  4. 화살이 플레이어와 충돌하면 게임 오버

텔레그래프 역할과 장애물 이동 역할을 TrapArea와 Obstacle_Arrow로 분리한 것이 핵심입니다.


2. TrapArea - 장판 Fill 연출

TrapArea는 바닥에 깔리는 경고 영역의 연출만 담당합니다. 이동이나 충돌 판정은 전혀 모르고, 오직 Fill 채우기와 초기화만 처리합니다.

// duration 동안 Fill을 0 -> 1 비율로 채우는 연출
// token이 취소되면 즉시 중단되며,
// 옵션에 따라 완료 후 전체 오브젝트를 비활성화함
public async UniTask PlayFill(float duration, CancellationToken token)
{
    // Fill 참조가 없으면 연출 불가
    if (fill == null)
    {
        Debug.LogWarning("[TrapArea] Fill reference is missing.");
        return;
    }

    // 연출 시작 전 오브젝트를 켜고 Fill을 0 상태로 초기화
    gameObject.SetActive(true);
    SetFill01(0f);

    // duration이 0 이하이면 즉시 100% 채운 상태로 처리
    if (duration <= 0f)
    {
        SetFill01(1f);

        if (deactivateOnComplete)
            gameObject.SetActive(false);

        return;
    }

    float t = 0f;

    // 지정 시간 동안 매 프레임 Fill 비율을 증가
    while (t < duration)
    {
        // 외부에서 취소 요청이 들어오면 즉시 예외 발생 후 종료
        token.ThrowIfCancellationRequested();

        // 시간 누적
        t += Time.deltaTime;

        // 현재 진행도를 0~1 범위로 변환
        float a = Mathf.Clamp01(t / duration);

        // 진행도에 맞게 Fill 크기 적용
        SetFill01(a);

        // 다음 프레임까지 대기
        await UniTask.Yield(PlayerLoopTiming.Update, token);
    }

    // 마지막 프레임 보정
    SetFill01(1f);

    // 연출 완료 후 텔레그래프 전체 비활성화
    // Border만 남아 보이는 문제를 방지
    if (deactivateOnComplete)
        gameObject.SetActive(false);
}

SetFill01()은 0~1 진행도를 실제 스케일로 변환해줍니다.

private void SetFill01(float a01)
{
    float s = Mathf.Lerp(0f, targetScaleXY, a01);
    Vector3 ls = fill.localScale;
    ls.x = s;
    ls.y = s;
    fill.localScale = ls;
}

Fill 오브젝트를 X, Y 스케일로 늘리는 방식이기 때문에, Fill 이미지는 원형 또는 평면 오브젝트여야 합니다. Z는 변경하지 않아 바닥에 붙어있는 형태를 유지합니다.

NOTE) deactivateOnComplete를 true로 설정해두면 연출이 끝난 뒤 TrapArea 전체 오브젝트가 비활성화됩니다. Border가 남아 보이는 문제를 방지하기 위한 처리입니다.


3. TrapArea - 리셋 처리

장애물이 풀링으로 재사용될 경우, 이전 연출 상태가 남아있으면 안됩니다. 그래서 OnDisable에서 반드시 리셋을 호출합니다.

// Fill 상태를 초기화하고 오브젝트를 숨김
// 풀링 재사용 시 이전 연출 상태가 남지 않도록 사용
public void ResetFillAndHide()
{
    if (fill != null)
        SetFill01(0f);

    gameObject.SetActive(false);
}

4. Obstacle_Arrow - 비동기 연출

Obstacle_Arrow는 화살이 떨어지기 전에 ShowArrowArea()를 비동기로 실행합니다.

private async UniTaskVoid ShowArrowArea(CancellationToken token)
{
    // 이번 화살의 낙하 속도를 랜덤으로 결정
    moveSpeed = Random.Range(randomMinSpeed, randomMaxSpeed);

    // 현재 속도가 최소~최대 범위 중 어느 정도인지 비율 계산
    float speedRatio = Mathf.InverseLerp(randomMinSpeed, randomMaxSpeed, moveSpeed);

    // 속도가 빠를수록 경고 시간은 짧아지도록 보간
    float t = Mathf.Lerp(maxTimeShowArea, minTimeShowArea, speedRatio);

    if (trapArea != null)
    {
        // 경고 영역 표시
        trapArea.gameObject.SetActive(true);

        // 지정 시간 동안 Fill 연출 재생
        await trapArea.PlayFill(t, token);
    }
    else
    {
        // TrapArea가 없으면 같은 시간만큼 대기
        await UniTask.Delay((int)(t * 1000f), cancellationToken: token);
    }

    // 대기 중 취소되었으면 중단
    if (token.IsCancellationRequested) return;

    // 경고가 끝날 때 게임오버 상태면 낙하하지 않음
    if (gameManager != null && gameManager.IsGameOver) return;

    // 실제 낙하 시작
    StartFall();
}

핵심은 속도와 장판 표시 시간을 반비례로 설정한 부분입니다.

  • 빠른 화살 → 장판 시간 짧음 → 더 어렵고 긴장감 있음
  • 느린 화살 → 장판 시간 길음 → 피할 여유가 생김

Mathf.InverseLerp로 현재 속도가 min~max 범위에서 얼마나 빠른지 0~1 비율을 구하고, Mathf.Lerp로 그 비율을 장판 시간에 반영합니다.


5. Obstacle_Arrow - 낙하 처리

장판 연출이 끝나면 StartFall()로 실제 낙하를 시작합니다.

private void StartFall()
    {
        if (rb == null) return;

        // 실제 낙하 시작 시 물리 연산 활성화
        rb.isKinematic = false;

        // 아래 방향으로 낙하 속도 적용
        rb.linearVelocity = Vector3.down * moveSpeed;

        // Y축 회전 각속도 적용
        rb.angularVelocity = Vector3.up * (rotationSpeed * Mathf.Deg2Rad);
    }

낙하 전까지는 rb.isKinematic = true 상태로 물리 영향을 받지 않습니다. StartFall()에서 kinematic을 해제하고 아래 방향 속도와 Y축 회전을 동시에 줘서 화살이 빙글빙글 돌면서 떨어지게 됩니다.


6. CancellationToken으로 안전하게 중단

비동기 연출 중 화살이 비활성화되거나 게임이 종료되는 경우, 연출을 즉시 중단해야 합니다.

public void Setup(Vector3 defaultDir, Transform playerTransform)
{
    if (gameManager == null) gameManager = GameManager.Instance;

    // 이미 게임오버 상태면 동작하지 않음
    if (gameManager.IsGameOver) return;

    // 이전 비동기 작업이 남아 있으면 취소 후 정리
    cts?.Cancel();
    cts?.Dispose();
    cts = new CancellationTokenSource();

    // 낙하 전 이동 상태 초기화
    StopMotion();

    // 경고 영역 표시 후 낙하 시작
    ShowArrowArea(cts.Token).Forget();
}

private void OnDisable()
{
    // 비활성화 시 진행 중인 비동기 작업 취소
    cts?.Cancel();
    cts?.Dispose();
    cts = null;

    // 물리 이동 상태 초기화
    StopMotion();

    // 경고 영역도 초기화 후 숨김 처리
    if (trapArea != null)
        trapArea.ResetFillAndHide();
}

Setup()이 다시 호출되거나 OnDisable()이 실행되면 이전 CTS를 취소하고 새로 만듭니다. PlayFill() 내부에서는 매 프레임 token.ThrowIfCancellationRequested()를 호출하기 때문에 취소 시 즉시 UniTask가 종료됩니다.

NOTE) cts?.Cancel() 후 cts?.Dispose()를 반드시 호출해야 합니다. Cancel만 하고 Dispose를 누락하면 메모리 누수가 발생할 수 있습니다.


7. 왜 TrapArea를 별도 컴포넌트로 분리했는가

처음에는 Obstacle_Arrow 안에서 Fill 연출을 직접 처리해도 됐습니다.

하지만 그렇게 하면 아래 문제가 생깁니다.

  • 다른 장애물(예: 폭탄, 레이저)에도 같은 장판 연출이 필요할 때 코드를 복붙해야 함
  • Fill 오브젝트 참조와 연출 로직이 장애물 코드 안에 섞여 가독성이 나빠짐

TrapArea를 별도로 분리하면 장판 연출 로직은 한 곳에만 존재하고, 어떤 장애물이든 TrapArea 참조만 가지면 동일한 연출을 재사용할 수 있습니다.


마무리

이번에 구현한 장판 텔레그래프 시스템의 핵심을 정리하면 다음과 같습니다.

  1. TrapArea - Fill 채우기 연출만 담당. 풀링 재사용을 위한 ResetFillAndHide() 포함
  2. Obstacle_Arrow - 속도에 따라 장판 시간을 조절하고, 텔레그래프 완료 후 낙하 실행
  3. CancellationToken - 비동기 연출 도중 오브젝트 비활성화나 게임 오버가 발생해도 안전하게 중단
  4. 속도 ↔ 장판 시간 반비례 - InverseLerp + Lerp 조합으로 빠른 화살일수록 피할 시간을 적게 줌

플레이어 입장에서 "분명히 어디서 나오는지 보여줬는데 못 피했다"는 느낌이 들도록 만드는 것이 텔레그래프 시스템의 목표입니다. 경고는 충분히 주되, 반응 시간은 난이도에 맞게 조절하는 것이 포인트입니다.