✅ 오늘의 학습 목표
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 애니메이션이 동작되도록 구현해준다
(씬 이동 중인데 에너미가 계속 공격하면 이상하잖아용 ^-^)

'Unity > 멋쟁이사자처럼 부트캠프' 카테고리의 다른 글
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(96일차) - [3D 게임] HP bar 구현 (2) (0) | 2025.10.16 |
|---|---|
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(95일차) - [3D 게임] Hp 구현 (1) / Git LFS (0) | 2025.10.15 |
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(93일차) - [3D 게임] 플레이어 공격 구현 & 에너미 상태 머신 구현 (0) | 2025.10.01 |
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(92일차) - [3D 게임] 플레이어 상태머신 확장 & 데카르트 좌표 (0) | 2025.09.30 |
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(91일차) - [3D 게임] 플레이어 상태 머신 설계 (0) | 2025.09.29 |