34,35일차) [Unity] 웨이브 맵 및 시네머신 연출 구현

엄기태·2026년 2월 20일

Unity

목록 보기
9/13

1. 구현 목표

이번 과제에서는 웨이브 방식의 맵 구성과 시네머신(Cinemachine)을 활용한 역동적인 연출, 그리고 스나이퍼 모드 시스템을 구현하는 것을 목표로 삼았다.

  1. 문 열림 시네머신 연출: 포탈(문)이 활성화될 때 전용 카메라 뷰로 전환되는 연출 구현
  2. 몬스터 스폰 시네머신 연출: 새로운 스테이지에서 몬스터가 등장할 때 이를 강조하는 연출 구현
  3. 스나이퍼 모드 구현:
  • CM_Sniper 카메라를 활용한 줌 모드 적용
  • 공격 시 카메라 흔들림(Noise)을 이용한 반동 구현
  • Shift 단축키를 이용한 모드 전환 기능

2. 구현 결과

🎥 결과물 영상 및 이미지


3. 주요 소스코드

🌀 Potal.cs

포탈의 활성화 상태에 따라 메테리얼을 교체하여 시각적 피드백을 제공한다.

using System.Collections;
using UnityEngine;

public class Potal : MonoBehaviour
{
	public GameManager _gameManager;
	[SerializeField] private Material _onMaterial;
	[SerializeField] private Material _offMaterial;
	private Renderer _renderer;

	private void Start()
	{
		_renderer = GetComponent<Renderer>();
		StartCoroutine(OnOffMaterial());
	}
	
	IEnumerator OnOffMaterial()
	{
		while (true)
		{
			if (_gameManager.IsAllDead)
			{
				_renderer.material = _onMaterial;
			}

			else if (!_gameManager.IsAllDead)
			{
				_renderer.material = _offMaterial;
			}

			yield return new WaitForSeconds(1f);
		}
	}
}

🎬 Director.cs

카메라 전환 및 시네머신 연출의 핵심 로직을 담당한다. 몬스터의 상태와 게임 진행 단계에 따라 Priority를 조절한다.

using System.Collections;

using Unity.Cinemachine;
using Unity.Mathematics;

using UnityEngine;

/*
 * 플레이어가 적을 죽인다 -> 카메라가 CM_Door로 전환 -> 문이 열린다.
 * -> CM_Player로 전환
 */

public class Director : MonoBehaviour
{
	private GameObject _player;
	[SerializeField] private GameObject _monster;
	[SerializeField] private CinemachineCamera _cmPlayer;
	[SerializeField] private CinemachineCamera _cmSniper;
	private CinemachineBasicMultiChannelPerlin _noise;
	public GameManager _gameManager;
	private bool _sniperMode = false;

	public bool SniperMode { get { return _sniperMode; } }
	void Start()
	{
		_noise = _cmSniper.GetComponent<CinemachineBasicMultiChannelPerlin>();
		_player = GameObject.FindWithTag("Player");

		StartCoroutine(ChecktoMonsterState());
		StartCoroutine(CheckGameStart());
	}
	IEnumerator ChecktoMonsterState()
	{
		while (true)
		{
			if (_monster == null)
			{
				_monster = GameObject.FindWithTag("Enemy");
				if (_monster == null)
				{
					yield return new WaitForSeconds(0.5f);
					continue;
				}
			}

			if (_gameManager.IsStart && _gameManager.MonsterCount <= 0 && _monster != null)
			{
				int idx = _gameManager.CurrentRoomIndex - 1;
				var currentRoom = _gameManager.Rooms[idx];

				_cmPlayer.Priority = 0;
				currentRoom._cmPotal.Priority = 10;

				yield return new WaitForSeconds(4f);

				_cmPlayer.Priority = 10;
				currentRoom._cmPotal.Priority = 0;

				_monster = null;

				yield return new WaitUntil(() => _gameManager.MonsterCount > 0);
			}
			yield return new WaitForSeconds(0.5f);
		}
	}

	IEnumerator CheckGameStart()
	{
		while (true)
		{
			if (_gameManager.IsStart && _gameManager.MonsterCount > 0)
			{
				int idx = _gameManager.CurrentRoomIndex - 1;
				var currentRoom = _gameManager.Rooms[idx];

				_cmPlayer.Priority = 0;
				currentRoom._cmMonster.Priority = 10;

				yield return new WaitForSeconds(4f);

				_cmPlayer.Priority = 10;
				currentRoom._cmMonster.Priority = 0;

				yield return new WaitUntil(() => _gameManager.MonsterCount <= 0);
			}
			yield return new WaitForSeconds(0.5f);
		}
	}
	public IEnumerator SniperNoise()
	{
		SetNoise(2f, 0.5f);
		yield return new WaitForSeconds(0.5f);
		SetNoise(0.4f, 0.2f);
		yield break;
	}

	public void CheckSniperMode()
	{
		if (_sniperMode == false)
		{
			_cmPlayer.Priority = 0;
			_cmSniper.Priority = 9;
			_sniperMode = true;
			return;
		}
		if (_sniperMode == true)
		{
			_cmPlayer.Priority = 9;
			_cmSniper.Priority = 0;
			_sniperMode = false;
			return;
		}
	}

	public void SetNoise(float amplitude, float frequency)
	{
		_noise.AmplitudeGain = amplitude;
		_noise.FrequencyGain = frequency;
	}
}

⚔️ PlayerCombat.cs (일부)

스나이퍼 모드 전환 및 사격 반동 연출을 호출한다.

[SerializeField] private float _sniperInterval = 2.5f;
public Director _director;

void Update()
{
	if (_life <= 0) return;

	if (_fireCooldown > 0)
	{
		_fireCooldown -= Time.deltaTime;
	}

	if (_fireCooldown > 0) return;

	if (Input.GetAxis("Fire1") > 0f)
	{
		GameObject bullet = Instantiate(_prefabBullet, _firePosition.position, _firePosition.rotation);
		
		_fireCooldown = _fireInterval;
		if (_director.SniperMode)
		{
			_director.StartCoroutine(_director.SniperNoise());
			_fireCooldown = _sniperInterval;
		}
	}

	if (Input.GetKeyDown(KeyCode.LeftShift))
	{
		_director.CheckSniperMode();
	}

}

4. 구현 과정

  1. 포탈 연출: 기존 문 대신 포탈로 기획을 변경하였다. 포탈의 열림과 닫힘을 직관적으로 보여주기 위해 메테리얼 변화를 주었으며, 시네머신 연출을 통해 이를 강조했다.
  2. 스테이지별 관리: RoomData 클래스를 만들어 각 방마다 스폰 위치, 포탈 카메라, 몬스터 카메라 등을 관리하여 스테이지마다 개별적인 연출이 가능하도록 설계했다.
  3. 시점 제어: Director 클래스에서 코루틴과 while문을 활용해 MonsterCount를 실시간 체크하고, 상황에 맞게 Priority를 조절하여 카메라를 전환했다.
  4. 스나이퍼 시스템: Lens 값을 낮춘 전용 카메라를 생성하고, 사격 시 CinemachineBasicMultiChannelPerlin의 수치를 조절하여 화면 흔들림 효과(반동)를 구현했다.

5. 문제 해결 방법

  • 프리팹 카메라 이슈: 원래 몬스터 프리팹 내부에 카메라를 넣어 이동을 추적하려 했으나, 프리팹 구조상 외부 컴포넌트를 할당하는 데 어려움이 있었다. 이를 해결하기 위해 카메라를 월드에 미리 배치하고 필요한 위치에 할당하는 방식으로 변경했다.
  • 연출 반복 실행 문제: 게임 시작 직후 몬스터가 없는 짧은 찰나에 연출이 무한 반복되는 현상이 있었다. 몬스터가 null일 때 예외 처리를 추가하고, WaitUntil을 사용하여 조건이 충족될 때까지 대기하도록 로직을 보완했다.

6. 개선 계획

  • MonsterCount 동기화 문제: 간헐적으로 MonsterCount 수치가 부정확하여 연출이 비정상적으로 작동하는 버그가 존재한다. 여러 번 재시작하면 해결되곤 하지만, 근본적인 원인을 파악하기 위해 카운트가 증감하는 로직의 시점을 면밀히 분석할 계획이다.

profile
코딩 학습 공간

0개의 댓글