Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(94일차) - [3D 게임] Enemy&Player Hit 구현 (옵저버 패턴 활용) / 비동기 씬 로드

by 독기품은토끼 2025. 10. 2.
✅ 오늘의 학습 목표
1. Enemy Attack 구현
2. Player 무기에 충돌 판정 기능 구현
3. 플레이어 Attack 구현 (옵저버 패턴 활용)
4. Hit 구현
5. 비동기 씬 로드

1. Enemy 공격 상태

1. Attack SMB

애니메이션에 attack을 배치해준다음

smb를 생성해준다.

using UnityEngine;

public class EnemySmbAttack : StateMachineBehaviour
{
    private EnemyController _enemyController;

    // OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        if (!_enemyController) _enemyController = animator.GetComponent<EnemyController>();
    }

    // OnStateExit is called when a transition ends and the state machine finishes evaluating this state
    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        _enemyController.SetState(Constants.EEnemyState.Chase);
    }
}

 

  • StateMachineBehaviour는 애니메이션 클립/스테이트의 생명주기에 붙는다.
  • OnStateEnter에서 한 번 EnemyController를 캐싱해두면, GetComponent를 매 프레임 하지 않아도 된다.

 

2. Chase → Attack 상태 전이

public class EnemyStateChase : EnemyState, ICharacterState
{
    private float _waitTime;
    public void Update()
    {
        var detectionTargetTransform = _enemyController.DetectionTargetInCircle();
        if (detectionTargetTransform)
        {
            // 공격
            if (!_navMeshAgent.pathPending &&
                _navMeshAgent.remainingDistance <= _navMeshAgent.stoppingDistance &&
                _waitTime > _enemyController.AttackWaitTime && DetectionTargetInSight(detectionTargetTransform.position))
            {
                _enemyController.SetState(EEnemyState.Attack);
            }
            else
            {
                _waitTime = 0f;
            }

            // 달리기 구현
        }
        else
        {
            _enemyController.SetState(EEnemyState.Idle);
        }

        _waitTime += Time.deltaTime;
    }
}
public class EnemyController : MonoBehaviour
{
    [Header("AI")]
    [SerializeField] private float attackWaitTime = 0f;

    public float AttackWaitTime => attackWaitTime;
}

 

  • DetectionTargetInCircle() : 시야/범위 탐지 결과(Transform) 반환
  • pathPending : 에이전트가 경로 계산 중이면 아직 멈출지 말지 판단 보류
  • remainingDistance <= stoppingDistance : 타깃에 충분히 근접했는가
  • _waitTime > AttackWaitTime : 공격 재사용 대기시간(딜레이) 도달 체크
  • DetectionTargetInSight(...) : 시야에 실제로 보이는가(레이캐스트/장애물 체크)
  • 조건이 모두 true면 Attack 상태로 전환

 

2. Player 공격

1. 충돌 판정

무기 판정의 로컬 위치/반경을 데이터로 정의해주려 한다.

public static class Constants
{
    // Player 공격 콜라이더
    [Serializable]
    public class WeaponTriggerZone
    {
        public Vector3 position;
        public float radius;
    }
}

 

Staff 프리팹의 MeleeController에 triggerZones를 여러개 등록한다.

각 존의 위치/반경을 조합하여 공격 궤적을 커버한다.

 

using System.Collections.Generic;
using UnityEngine;
using static Constants;

public class MeleeController : MonoBehaviour
{
    [SerializeField] private WeaponTriggerZone[] triggerZones;
    [SerializeField] private LayerMask targetLayerMask;

    private HashSet<Collider> _hitColliders;
    private Vector3[] _previousTriggerPositions;

    private void Awake()
    {
        _previousTriggerPositions = new Vector3[triggerZones.Length];
        _hitColliders = new HashSet<Collider>();
    }

    public void StartTrigger()
    {
        _hitColliders.Clear();
    }

    public void EndTrigger()
    {

    }

    private void FixedUpdate()
    {
        for (int i = 0; i < triggerZones.Length; i++)
        {
            var worldPosition = GetTriggerWorldPosition(triggerZones[i].position);
            var direction = worldPosition - _previousTriggerPositions[i];
            Ray ray = new Ray(worldPosition, direction);
            RaycastHit[] hits = new RaycastHit[10];

            var hitCount = Physics.SphereCastNonAlloc(ray, triggerZones[i].radius, hits,
                direction.magnitude, targetLayerMask);

            for (int j = 0; j < hitCount; j++)
            {
                var hit = hits[j];
                if (!_hitColliders.Contains(hit.collider))
                {
                    _hitColliders.Add(hit.collider);
                }
            }
            _previousTriggerPositions[i] = worldPosition;
        }
    }

    private Vector3 GetTriggerWorldPosition(Vector3 position)
    {
        return transform.position + transform.TransformDirection(position);
    }
}

 

  • triggerZones
    • 로컬 좌표(position)와 반지름(radius)로 이루어진 “무기 기준 구체”들의 배열
    • 스태프 끝/중간/손잡이 근처에 각각 구 하나씩 두어 스윙 궤적을 빈틈없이 커버
  • targetLayerMask
    • “무엇을 때릴지” 필터. Player용 무기면 Enemy 레이어만, 적 무기면 Player 레이어만 체크하도록 설정
  • _hitColliders (HashSet<Collider>)
    • 중복 히트 방지 컬렉션
    • HashSet은 “값 자체가 키”라서 한 번 넣은 콜라이더는 같은 공격 윈도우에서 다시 안 들어감
  • _previousTriggerPositions
    • 각 트리거존의 이전 프레임 월드 좌표(길이 = triggerZones.Length)
    • 현재 프레임 좌표와의 차이로 “이동 벡터(dir)”를 계산 → SphereCast로 프레임 사이 충돌까지 놓치지 않음

 

[FixedUpdate()]

  • worldPosition
    • 로컬 중심(triggerZones[i].position)을 무기 기준 월드 좌표로 변환
    • 무기가 휘둘리면 본/부모의 회전·위치가 반영됨
  • direction = worldPosition - _previousTriggerPositions[i]
    • 이전 프레임 → 현재 프레임으로의 이동 벡터
    • 이 구간 동안 무기가 통과한 부피를 캐스팅해야 프레임 사이에 스친 충돌을 놓치지 않음
  • Ray ray = new Ray(worldPosition, direction);
    • 시작점: worldPosition(현재 프레임)
    • 방향: direction (보통 normalized로 넣는 습관이 좋지만 SphereCastNonAlloc은 내부에서 정규화한다. 단, 0벡터는 피해야 함)
  • RaycastHit[] hits = new RaycastHit[10];
    • 임시 버퍼. 고정 길이(10)면 넘치는 히트는 잘리므로, 필요 시 여유를 두거나 스태틱 캐시/풀링 고려
  • Physics.SphereCastNonAlloc(..., radius, hits, direction.magnitude, mask)
    • 구가 이동한 궤적(start → end, 반지름 radius)과 겹치는 콜라이더를 여러 개 찾아줌
    • NonAlloc 계열은 할당을 피함 → 고빈도 호출에 적합
    • direction.magnitude: 이동 거리만큼만 검사(안 움직였으면 0 → 아무것도 안 맞음)

 

[GetTriggerWorldPosition()]

  • TransformDirection(local)은 회전만 적용하는 함수
  • 여기에 transform.position을 더해서 월드 위치를 만든 것이다.
  • 부모 본(손목/무기 소켓)이 회전하면, 트리거존도 같이 회전해서 궤적이 일치한다

 

 

[기즈모 표시]

private void OnDrawGizmos()
{
    for (int i = 0; i < triggerZones.Length; i++)
    {
        var worldPosition = GetTriggerWorldPosition(triggerZones[i].position);
        var direction = worldPosition - _previousTriggerPositions[i];
        Gizmos.color = Color.green;
        Gizmos.DrawWireSphere(worldPosition, triggerZones[i].radius);
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(worldPosition + direction, triggerZones[i].radius);
    }
}

 

 

2. 무기 장착 - Bone 연결

using UnityEngine;

public class EllenPlayerController : PlayerController
{
    [SerializeField] private Transform weaponAttachTransform; // 무기를 장착할 오브젝트 위치

    private void Start()
    {
        // 무기 장착
        var staffObject = Resources.Load<GameObject>("Staff"); // 리소스 폴더에 있는 Staff 프리팹 갖고오기
        Instantiate(staffObject, weaponAttachTransform);
    }
}

 

  • Resources/Staff.prefab 경로 필수
  • weaponAttachTransform는 본 인스펙터 연결 필요

 

3. 무기 장착 - 옵저버 적용

무기 충돌 판정과 그 충돌 결과를 처리(데미지, 이펙트 등)를 한 스크립트에 다 몰아서 쓰게되면

나중에 플레이어/적/보스 등 여러 피격 처리자가 생길 때 코드가 엉킬 수 있다.

무기 교체(근접/원거리/광역) 시에도 충돌 감지 로직과 후속 처리를 동시에 고쳐야하므로 옵저버 패턴을 활용해주려 한다.

 

옵저버 패턴은 충돌 감지(발행자, Observable)와 결과 처리(구독자, Observer) 로 나눠 분리한다.

  • MeleeController(무기) = Observable: “누구를 맞췄는지” 이벤트를 발행만 함
  • EllenPlayerController(플레이어) = Observer: 발행된 “히트 대상”을 구독해서 후속 처리(데미지, 넉백…)를 수행

public interface IWeaponObservable<T>
{
    public void Subscribe(IWeaponObserver<T> observer); // 구독
    public void Unsubscribe(IWeaponObserver<T> observer); // 구독 해제
    public void Notify(T value); // 구독자에게 알림
}

using System;
public interface IWeaponObserver<T>
{
    public void OnNext(T value);
    public void OnCompleted();
    public void OnError(Exception error);
}

 

  • Observable (발행자): 이벤트의 “출처” → MeleeController
    • 누가 맞았는지(GameObject)를 Notify(value)로 발행
    • 구독 관리: Subscribe(observer), Unsubscribe(observer)
  • Observer (구독자): 이벤트를 “받아 처리하는 쪽” → EllenPlayerController
    • OnNext(value): 매번 히트 대상이 발행될 때 호출
    • OnCompleted(): “이제 더 이상 발행 안 함”을 알림 (예: 무기 파괴/공격 윈도우 종료 등을 표현)
    • OnError(error): 예외 알림(선택)

 

public class EllenPlayerController : PlayerController, IWeaponObserver<GameObject>
{
    private MeleeController _meleeController;

    private void Start()
    {
        _meleeController.Subscribe(this);
    }

    public void MeleeAttackStart()
    {
        _meleeController.StartTrigger();
    }

    public void MeleeAttackEnd()
    {
        _meleeController.EndTrigger();
    }

    public void OnNext(GameObject value)
    {
        // 무기가 충돌했을 때 충돌 대상 불러오기
        var enemyController = value.GetComponent<EnemyController>();
        if (enemyController)
        {
            // Enemy에게 데미지 가하기
            enemyController.SetHit(30, transform.forward); // 데미지, 방향
        }
    }

    public void OnCompleted()
    {
        // ex) 무기 내구도가 다 떨어졌을 때 구독을 취소하겠다.
        _meleeController.Unsubscribe(this);
    }
}
public class MeleeController : MonoBehaviour, IWeaponObservable<GameObject>
{
    private List<IWeaponObserver<GameObject>> _observers =
        new List<IWeaponObserver<GameObject>>();

    public void StartTrigger()
    {
        _hitColliders.Clear();
        for (int i = 0; i < triggerZones.Length; i++)
        {
            _previousTriggerPositions[i] = GetTriggerWorldPosition(triggerZones[i].position);
        }
    }

    public void EndTrigger()
    {
        foreach (var hitCollider in _hitColliders)
        {
            Notify(hitCollider.gameObject);
        }
    }

    public void Subscribe(IWeaponObserver<GameObject> observer)
    {
        if (!_observers.Contains(observer))
        {
            _observers.Add(observer);
        }
    }

    public void Unsubscribe(IWeaponObserver<GameObject> observer)
    {
        _observers.Remove(observer);
    }

    public void Notify(GameObject value)
    {
        foreach (var observer in _observers)
        {
            observer.OnNext(value);
        }
    }
}

 

 

1. 게임 시작/장비 장착

  • EllenPlayerController.Start()에서 스태프 프리팹을 소켓에 Instantiate
  • meleeController = ...GetComponent<MeleeController>()
  • meleeController.Subscribe(this) ← 구독 등록

2. 공격 애니메이션 재생

  • 애니메이션 이벤트로 MeleeAttackStart() 호출 → StartTrigger()
  • 이때부터 MeleeController가 내부에서 히트 대상 수집

3. 공격 종료 프레임

  • 애니메이션 이벤트로 MeleeAttackEnd() 호출 → EndTrigger()
  • EndTrigger()에서 이번 윈도우에 모인 모든 충돌체에 대해 Notify(GameObject) 발행
  • → EllenPlayerController.OnNext(value)가 대상 수만큼 호출

4. 필요하면 종료

  • 무기 내구도 0, 무기 파괴 등 상황이면 OnCompleted()를 호출하고
  • 구독자는 Unsubscribe(this)로 관계 정리

 

3. Enemy hit (넉백 추가)

public class EnemyController : MonoBehaviour
{
    // 공격 받았을 때 실행되는 함수
    public void SetHit(int damage, Vector3 attackDirection)
    {
        SetState(EEnemyState.Hit);
        StartCoroutine(Knockback(attackDirection));
    }

    // 넉백 구현
    private IEnumerator Knockback(Vector3 direction)
    {
        Vector3 knockbackDirection = direction; // 방향
        float knockbackDistance = 1f; // 거리
        float knockbackDuration = 0.2f; // 유지시간
        float elapsed = 0f; // Duration 검사용

        Vector3 startPosition = transform.position;
        Vector3 targetPosition = startPosition + knockbackDirection * knockbackDistance;
        targetPosition.y = transform.position.y; // y축 영향은 없도록 (위로 튀는 현상 방지)

        while (elapsed < knockbackDuration)
        {
            Vector3 lerpPosition = Vector3.Lerp(startPosition, targetPosition, elapsed / knockbackDuration);
            lerpPosition.y = startPosition.y;
            transform.position = lerpPosition;
            elapsed += Time.deltaTime;
            yield return null;
        }

        transform.position = targetPosition;
    }
}

 

[SetHit()]

  • SetState(EEnemyState.Hit) : 적의 현재 상태를 피격 상태(Hit) 로 전환
  • StartCoroutine(Knockback(attackDirection)) : 코루틴을 통해 넉백 효과를 부드럽게 실행

[Knockback]

  • knockbackDirection : 넉백의 방향. 플레이어 → 적 방향 벡터를 사용하면 적이 뒤로 밀리는 효과를 낼 수 있음
  • knockbackDistance : 얼마나 멀리 밀려날지
  • knockbackDuration : 밀리는 데 걸리는 시간. 짧을수록 “툭” 치는 느낌, 길수록 미끄러지듯 밀림
  • elapsed : 시간 경과를 누적해, Lerp의 비율을 계산하는 데 사용
  • 넉백 방향에 Y축이 포함되어 있더라도 공중으로 튀는 현상을 막기 위해 y값은 고정해주었다.

[Lerp]

Vector3.Lerp(a, b, t)는 a에서 b로 t(0~1) 비율만큼 부드럽게 이동시킨다.

 


 

using UnityEngine;

public class EnemySmbAttack : StateMachineBehaviour
{
    private EnemyController _enemyController;

    // OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        if (!_enemyController) _enemyController = animator.GetComponent<EnemyController>();
    }

    // OnStateExit is called when a transition ends and the state machine finishes evaluating this state
    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        _enemyController.SetState(Constants.EEnemyState.Chase);
    }
}
using UnityEngine;
using UnityEngine.AI;
using static Constants;

public class EnemyStateHit : EnemyState, ICharacterState
{
    public EnemyStateHit(EnemyController enemyController, Animator animator, NavMeshAgent navMeshAgent)
        : base(enemyController, animator, navMeshAgent) { }

    public void Enter()
    {
        _navMeshAgent.isStopped = true;
        _animator.SetTrigger(EnemyAniParamHit);
    }
}

 

 

4. 플레이어 hit

플레이어가 hit 당했을 때

방향에 맞게 hit 애니메이션이 동작되도록 Blend 애니메이션을 적용해주었다.

using UnityEngine;

public class PlayerSmbHit : StateMachineBehaviour
{
    private PlayerController _playerController;

    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        if (_playerController == null) _playerController = animator.GetComponent<PlayerController>();
    }

    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        _playerController.SetState(Constants.EPlayerState.Idle);
    }
}
using UnityEngine;
using UnityEngine.InputSystem;
using static Constants;

public class PlayerStateHit : PlayerState, ICharacterState
{
    public PlayerStateHit(PlayerController playerController, Animator animator, PlayerInput playerInput)
        : base(playerController, animator, playerInput) { }

    public void Enter()
    {
        _animator.SetTrigger(PlayerAniParamHit);   
    }
}
// PlayerController 스크립트
public class PlayerController : MonoBehaviour
{
    // Hit
    public void SetHit(int damage, Vector3 attackDirection)
    {
        SetState(EPlayerState.Hit);
        _animator.SetFloat(PlayerAniParamHitX, attackDirection.x);
        _animator.SetFloat(PlayerAniParamHitZ, attackDirection.z);
    }
}

 

Enemy에 hit 로직 적용해준 거랑 동일하기 때문에 스크립트 설명은 생략하겠다.

 

 

Enemy에도 동일하게 트리거존 만들어주기

 

using System;
using UnityEngine;

public class ChomperEnemyController : EnemyController, IWeaponObserver<GameObject>
{
    private MeleeController _meleeController;
    
    private void Start()
    {
        _meleeController = GetComponent<MeleeController>();
        _meleeController.Subscribe(this);
    }

    public void PlayStep() { }
    public void Grunt() { }

    public void AttackBegin()
    {
        _meleeController.StartTrigger();
    }

    public void AttackEnd()
    {
        _meleeController.EndTrigger();
    }

    public void OnNext(GameObject value)
    {
        var playerController = value.GetComponent<PlayerController>();
        if (playerController)
        {
            playerController.SetHit(10, -transform.forward);
        }
    }

    public void OnCompleted()
    {
        _meleeController.Unsubscribe(this);
    }
}

 

ChomperController 스크립트도 meleeController를 구독하여

플레이어와 충돌이 발생하면 데미지 등 처리를 해주도록 구현

 

 class MeleeController : MonoBehaviour, IWeaponObservable<GameObject>
{
    private bool _isTriggering;

    private void Awake()
    {
        // 추가
        _isTriggering = false;
    }

    public void StartTrigger()
    {
        // 추가
        _isTriggering = true;
    }

    public void EndTrigger()
    {
        // 추가
        _isTriggering = false;
    }

    // 충돌 감지
    private void FixedUpdate()
    {
        if (!_isTriggering) return;
       
        // ...
    }
}

 

FixedUpdate를 매 프레임마다 호출하여 충돌 여부를 파악하고 있기 때문에

성능 보완을 위해 bool 값으로 체크

 

 

5. 비동기 씬 로드

 

로딩 패널을 만들어준 후 프리팹화

 

using System;
using UnityEngine;
using DG.Tweening;
using UnityEngine.UI;

[RequireComponent(typeof(CanvasGroup))]
public class LoadingPanelController : MonoBehaviour
{
    [SerializeField] private Image gaugeImage;

    private CanvasGroup _canvasGroup;

    private void Awake()
    {
        _canvasGroup = GetComponent<CanvasGroup>();
        _canvasGroup.alpha = 0f;
        SetProgress(0f);
    }

    public void SetProgress(float progress)
    {
        gaugeImage.fillAmount = progress;
    }

    public void Show(Action onComplete)
    {
        _canvasGroup.DOFade(1f, 0.2f).OnComplete(() => onComplete?.Invoke());
        SetProgress(0f);
    }

    public void Hide(Action onComplete)
    {
        SetProgress(1f);
        _canvasGroup.DOFade(0f, 0.2f).OnComplete(() => onComplete?.Invoke());
    }
}

 

DOTween을 활용하여 페이드 인/아웃을 구현해주었다.

  • SetProgress(float) : fillAmount(0~1)로 진행률 반영
  • Show(Action onComplete) : 0.2초에 걸쳐 페이드 인 → 완료 콜백 호출
  • Hide(Action onComplete) : 게이지를 1로 채운 뒤 0.2초에 걸쳐 페이드 아웃 → 완료 콜백

 

// Constants 스크립트
public enum ESceneName
{
    Main, Stage01, Stage02
}
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using static Constants;

public class GameManager : Singleton<GameManager>
{
    [SerializeField] private GameObject playerPrefab;

    public EGameState GameState { get; private set; }

    private GameObject _player;
    private Canvas _canvas;
    private bool _isCursorLock;

    // 커서 고정 기능
    public void SetCursorLock()
    {
        Cursor.visible = _isCursorLock;
        Cursor.lockState = _isCursorLock ? CursorLockMode.None : CursorLockMode.Locked;
        _isCursorLock = !_isCursorLock;
    }

    public void SetGameState(EGameState state)
    {
        GameState = state;
    }

    public void LoadScene(ESceneName sceneName)
    {
        StartCoroutine(LoadSceneAsync(sceneName));
    }

    private IEnumerator LoadSceneAsync(ESceneName sceneName)
    {
        GameState = EGameState.Pause;

        // 로딩 화면 띄우기
        var loadingPanelPrefab = Resources.Load<GameObject>("Loading Panel");
        var loadingPanelObject = Instantiate(loadingPanelPrefab, _canvas.transform);
        var loadingPanelController = loadingPanelObject.GetComponent<LoadingPanelController>();

        // 로딩 창 표시
        bool showDone = false;
        loadingPanelController.Show(() => showDone = true);
        yield return new WaitUntil(() => showDone);

        // 씬 로드 진행
        AsyncOperation asyncOperation = SceneManager.LoadSceneAsync(sceneName.ToString());
        asyncOperation.allowSceneActivation = false; // 로딩이 끝나면 바로 씬 이동하는 걸 꺼둠

        // 게이지 차는 거 표현
        while (asyncOperation.progress < 0.9f)
        {
            loadingPanelController.SetProgress(asyncOperation.progress);
            yield return null;
        }
        loadingPanelController.SetProgress(1f);
        asyncOperation.allowSceneActivation = true;

        bool hideDone = false;
        loadingPanelController.Hide(() => hideDone = true);
        yield return new WaitUntil(() => hideDone);

        Destroy(loadingPanelObject);
    }

    protected override void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        _canvas = GetCanvas();

        switch (scene.name)
        {
            case "Main":
                if (_player)
                {
                    Destroy(_player);
                    _player = null;
                }
                break;
            case "Stage01":
            case "Stage02":
                var spawnPoint = GameObject.FindGameObjectWithTag("SpawnPoint").transform;
                if (_player)
                {
                    _player.SetActive(true);
                    _player.transform.position = spawnPoint.position;
                    _player.transform.rotation = spawnPoint.rotation;
                }
                else
                {
                    _player = Instantiate(playerPrefab, spawnPoint.position, spawnPoint.rotation);
                    DontDestroyOnLoad(_player);
                }
                break;
        }

        GameState = EGameState.Play;
    }

    protected override void OnSceneUnloaded(Scene scene)
    {
        _canvas = null;
        _player.SetActive(false);
    }

    // 캔버스가 존재하지 않을 때 처리
    private Canvas GetCanvas()
    {
        var canvasObject = GameObject.FindGameObjectWithTag("Canvas");
        Canvas result = null; // 리턴용 캔버스

        if (!canvasObject)
        {
            canvasObject = new GameObject("Canvas");
            canvasObject.AddComponent<Canvas>();
            canvasObject.AddComponent<CanvasScaler>();
            canvasObject.AddComponent<GraphicRaycaster>();

            result = canvasObject.GetComponent<Canvas>();
            result.renderMode = RenderMode.ScreenSpaceOverlay;
            result.tag = "Canvas";
        }
        else
        {
            result = canvasObject.GetComponent<Canvas>();
        }

        return result;
    }
}

 

[GameManager]

  • 게임 상태 관리: EGameState(Play/Pause 등) 보관/전환
  • 커서 락 토글: SetCursorLock()으로 잠금/표시 전환
  • 씬 로딩: LoadScene(ESceneName) → LoadSceneAsync 코루틴
  • 로딩 UI 연동: Resources/Loading Panel 프리팹 인스턴스 후 LoadingPanelController로 표시/숨김
  • 플레이어 수명/스폰: 메인/스테이지에 따라 생성/위치 갱신, DontDestroyOnLoad로 유지
  • 캔버스 보장: 태그 "Canvas" 탐색, 없으면 런타임 생성(Canvas/Scaler/Raycaster 추가)

[비동기 로딩 포인트]

  • SceneManager.LoadSceneAsync(...); 후 allowSceneActivation = false로 90% 구간(0.9f) 까지 로딩
  • → UI로 진행률 표시
  • 100% 연출한 뒤 allowSceneActivation = true로 실제 전환
  • 전환 직후 Hide로 페이드 아웃 → 로딩 패널 파괴(정리 깔끔)

[씬별 분기]

  • "Main" : 기존 플레이어가 있으면 파괴(메인허브 개념)
  • "Stage01" / "Stage02" :
    • 태그 "SpawnPoint"를 찾아 위치/회전 적용 (씬에 배치 해줄 것)
    • 플레이어 없으면 Instantiate + DontDestroyOnLoad
    • 이미 있으면 활성화 후 위치/회전만 갱신

[Canvas 확보 로직]

  • 태그 "Canvas"를 먼저 검색
  • → 없으면 새 GameObject 생성 + Canvas, CanvasScaler, GraphicRaycaster를 붙이고 태그도 세팅 → 반환
  • 로딩 패널을 붙일 부모로 활용

 

6. 씬 이동 시각화

 

우선 Door가 될만한 오브젝트를 배치해준 후 Collider 컴포넌트를 추가 + Is Trigger 체크해준다.

 

using System;
using System.Collections;
using UnityEngine;
using static Constants;

public class DoorController : MonoBehaviour
{
    [SerializeField] private ESceneName sceneName;
    [SerializeField] private GameObject door;
    [SerializeField] private float openDuration;

    private bool _isOpen;

    private void OnTriggerEnter(Collider other)
    {
        // 문 열기
        if (other.CompareTag("Player"))
        {
            StartCoroutine(OpenDoor());
        }
    }

    private IEnumerator OpenDoor()
    {
        float duration = openDuration;
        float distance = 3f;
        Vector3 startPosition = door.transform.position;
        Vector3 endPosition = startPosition + Vector3.up * distance;
        float elapsedTime = 0f;

        while (elapsedTime < duration)
        {
            elapsedTime += Time.deltaTime;
            float t = elapsedTime / duration;
            door.transform.position = Vector3.Lerp(startPosition, endPosition, t);
            yield return null;
        }
        door.transform.position = endPosition;

        GameManager.Instance.LoadScene(sceneName);
    }
}

 

박스 콜라이더에 Player 태그를 가진 오브젝트가 진입히면 OpenDoor 코루틴을 실행시킨다.

 

[문 애니메이션]

  • 시작/끝 위치 계산
    • 현재 위치(startPosition)에서 위로 3m(Vector3.up * distance) 올린 지점을 endPosition으로 설정
  • Lerp 애니메이션
    • elapsedTime을 openDuration 동안 누적하면서
    • Vector3.Lerp(start, end, t)로 위치를 부드럽게 변화시킴
  • 씬 전환
    • 문이 완전히 열린 후, GameManager.Instance.LoadScene(sceneName) 호출로 지정된 씬을 로드

 

// PlayerController, EnemyController 스크립트 Update문에 추가
private void Update()
{
    if (GameManager.Instance.GameState == EGameState.Pause)
    {
        SetState(EPlayerState.Idle);
    }
}

 

씬을 이동할 때에는 플레이어, 에너미 모두 Idle 애니메이션이 동작되도록 구현해준다

(씬 이동 중인데 에너미가 계속 공격하면 이상하잖아용 ^-^)