개요

- 3개의 방이 있습니다.
- 각 방의 몬스터들을 해치우면 다음 방으로 갈 수 있는 문이 열립니다.
- 각 방의 몬스터들은 갈수록 강해집니다.(크기 Up, Hp Up, 공격력 Up)
구현 과정
기본적인 기믹들
- 우선 Player는 RigidBody를 가지고 있고 맵의 바닥과 벽들에는 Collider 컴포넌트들을 가지고 있습니다. 장애물을 설치하고 NavMesh를 이용해서 물리적인 충돌을 만듭니다.
- Player의 이동과 공격을 구현하고, Monster들의 현재 상태, 능력치를 설정합니다.
- Player의 공격을 구현할 때 인위적으로 총이 나가는 포지션 오브젝트를 만들고 Combat 스트립트에서 직렬화 해서 공격을 구현했습니다.
- 총알 관리를 큐(Queue)를 이용해서 메모리 사용을 줄이고, 총알은 오브젝트와 닿으면 삭제처리를 했습니다.
- 총알과 몬스터는 Prefabs로 관리합니다.
Player 이동, 공격 구현
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.SceneManagement;
public enum GameMode
{
LOBBY, SPACE_INVADER
}
public class CharacterMovement : MonoBehaviour
{
Transform _transform;
Animator _anim;
float _h, _v, _r;
[SerializeField] private float _moveSpeed = 5f;
[SerializeField] private float _turnSpeed = 60f;
[SerializeField] private GameMode _mode = GameMode.LOBBY;
void Start()
{
_transform = gameObject.GetComponent<Transform>();
_anim = gameObject.GetComponent<Animator>();
Cursor.lockState = CursorLockMode.Locked;
if (SceneManager.GetActiveScene().name == "Lobby")
{
_mode = GameMode.LOBBY;
}
else if(SceneManager.GetActiveScene().name == "SpaceInvader" ||
SceneManager.GetActiveScene().name == "Practice")
{
_mode = GameMode.SPACE_INVADER;
}
}
void Update()
{
switch(_mode)
{
case GameMode.LOBBY:
UpdateForLobby();
break;
case GameMode.SPACE_INVADER:
UpdateForSpaceInvader();
break;
}
}
void UpdateForLobby()
{
_r = Input.GetAxis("Mouse X");
_transform.Rotate(Vector3.up * _r * _turnSpeed * Time.deltaTime);
_h = Input.GetAxis("Horizontal");
_v = Input.GetAxis("Vertical");
if (_v != 0)
{
_transform.Translate(Vector3.forward * _v * _moveSpeed * Time.deltaTime);
}
if (_h != 0)
{
_transform.Translate(Vector3.right * _h * _moveSpeed * Time.deltaTime);
}
_anim.SetFloat("Forward", _v);
_anim.SetFloat("Side", _h);
}
void UpdateForSpaceInvader()
{
_h = Input.GetAxis("Horizontal");
if (_h != 0)
{
_transform.Translate(Vector3.right * _h * _moveSpeed * Time.deltaTime);
}
_anim.SetFloat("Side", _h);
}
}
// Combat.cs
/*
* 1. Vector의 개념과 연산 (합, 차, 스칼라 곱) 정리하기
* 2. 특정 위치에 가면 동작하는 기믹으로 발전
* 3. 자유 기믹 1종 추가
*/
using Unity.Cinemachine;
using UnityEngine;
public class Combat : MonoBehaviour
{
[SerializeField] private Transform _firePosition; // 생성 위치, 회전
[SerializeField] private float _fireInterval = 0.2f;
[SerializeField] private float _hp = 50f;
private bool _isAlive = true;
private float _fireCooldown;
private CinemachineImpulseSource _impulseSource;
public bool IsAlive { get => _isAlive; }
void Start()
{
_fireCooldown = _fireInterval;
_impulseSource = GetComponent<CinemachineImpulseSource>();
}
void Update()
{
if (_fireCooldown > 0)
{
_fireCooldown -= Time.deltaTime;
}
if (_fireCooldown > 0) return;
if (Input.GetAxis("Fire1") > 0f)
{
// 총알 생성
GameObject bullet = PoolManager.Instance.Get();
bullet.transform.position = _firePosition.position;
bullet.transform.rotation = _firePosition.rotation;
bullet.GetComponent<BulletController>().Fire();
// 플레이어 공격력으로 보정
//bullet.GetComponent<BulletController>().Init(30f);
_fireCooldown = _fireInterval;
if (_impulseSource != null)
{
Debug.Log("반동");
_impulseSource.GenerateImpulse();
}
}
}
public void TakeDamage(float damage)
{
if (_isAlive == false) return;
_hp -= damage;
if (_hp <= 0f)
{
_isAlive = false;
}
}
}
using UnityEditor.EditorTools;
using UnityEngine;
public class BulletController : MonoBehaviour
{
[SerializeField] private float _fireForce = 1000f;
Rigidbody _rb;
private float _damage = 10f;
public float Damage { get { return _damage; } private set { _damage = value; } }
private void Awake()
{
_rb = GetComponent<Rigidbody>();
}
private void OnCollisionEnter(Collision collision)
{
PoolManager.Instance.Return(gameObject);
}
public void Init(float damage = 10f)
{
_damage = damage;
}
public void Fire()
{
_rb.linearVelocity = Vector3.zero;
_rb.angularVelocity = Vector3.zero;
_rb.AddForce(
transform.forward * _fireForce, ForceMode.Acceleration);
}
}
Monster 상태
using System.Collections;
using UnityEngine;
using UnityEngine.AI;
public enum MonsterState
{
IDLE,
PATROL,
CHASE,
COMBAT,
DAMAGED,
DEAD
}
public class MonsterAI : MonoBehaviour
{
[SerializeField] private MonsterState _state = MonsterState.IDLE;
private Animator _anim;
private Collider _collider;
private NavMeshAgent _agent;
private Coroutine _currentCoroutine;
[Header("[Idle State]")]
[SerializeField] private float _idleTime = 0f;
[Header("[Patrol State]")]
[SerializeField] private GameObject _destObj;
[SerializeField] private Vector3 _patrolDest;
[SerializeField] private float _rotateSpeed = 120f;
[SerializeField] private float _patrolAccel = 8f;
[SerializeField] private float _patrolRange = 5f;
[SerializeField] private float _patrolMoveSpeed = 3f;
[SerializeField] private float _patrolAngularSpeed = 350f;
[Header("[Chase State]")]
[SerializeField] private float _chaseSpeed = 8f;
[SerializeField] private float _chaseAccel = 8f;
[SerializeField] private float _chaseStoppingDistance = 2f;
[SerializeField] private float _chaseRange = 8f;
[SerializeField] private float _chaseAngularSpeed = 3f;
private Vector3 _prevPosition;
private GameObject _target;
[Header("[Combat State]")]
[SerializeField] private float _attackRange = 3.5f;
[SerializeField] private float _damage = 5f;
[SerializeField] private float _attackInterval = 2f;
[SerializeField] private float _hp = 20f;
private float _pendingDamage = 0f;
private bool _isAlive = true;
public bool IsAlive { get { return _isAlive; } }
[Header("Damaged State")]
[SerializeField] private float _damageStunTime = 0.5f;
[Header("Dead State")]
[SerializeField] private float _deadTime = 2f;
private int _hashPatrol = Animator.StringToHash("Patrol");
private int _hashChase = Animator.StringToHash("Chase");
private int _hashCombat = Animator.StringToHash("Combat");
private int _hashDamaged = Animator.StringToHash("Damaged");
private int _hashDead = Animator.StringToHash("Dead");
private void Start()
{
_anim = GetComponent<Animator>();
_collider = GetComponent<Collider>();
_agent = GetComponent<NavMeshAgent>();
if (_agent != null)
{
_agent.updateRotation = true;
_agent.angularSpeed = _rotateSpeed;
}
ChangeState(_state);
}
private void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.CompareTag("Bullet"))
{
BulletController bullet = collision.gameObject.GetComponent<BulletController>();
TakeDamage(bullet.Damage);
}
}
private void ChangeState(MonsterState monState)
{
if (_currentCoroutine != null)
{
StopCoroutine(_currentCoroutine);
}
_state = monState;
ApplyAgentSetting(_state);
switch (_state)
{
case MonsterState.IDLE: _currentCoroutine = StartCoroutine(Co_Idle()); break;
case MonsterState.PATROL: _currentCoroutine = StartCoroutine(Co_Patrol()); break;
case MonsterState.CHASE: _currentCoroutine = StartCoroutine(Co_Chase()); break;
case MonsterState.COMBAT: _currentCoroutine = StartCoroutine(Co_Combat()); break;
case MonsterState.DAMAGED: _currentCoroutine = StartCoroutine(Co_Damaged()); break;
case MonsterState.DEAD: _currentCoroutine = StartCoroutine(Co_Dead()); break;
}
}
private IEnumerator Co_Idle()
{
SetAnimation();
if (_agent) _agent.isStopped = true;
float waitTime = Random.Range(2f, 4f);
_target = GameObject.FindGameObjectWithTag("Player");
float elapsed = 0f;
while (true)
{
if (IsFindTarget(_chaseRange))
{
ChangeState(MonsterState.CHASE);
yield break;
}
elapsed += Time.deltaTime;
if (elapsed >= waitTime)
{
ChangeState(MonsterState.PATROL);
yield break;
}
yield return null;
}
}
private IEnumerator Co_Patrol()
{
SetAnimation(hashPatrol: true);
_patrolDest = transform.position + new Vector3(
Random.Range(-_patrolRange, _patrolRange),
0f,
Random.Range(-_patrolRange, _patrolRange)
);
if (_destObj) _destObj.transform.position = _patrolDest;
_target = GameObject.FindGameObjectWithTag("Player");
if (_agent)
{
_agent.isStopped = false;
_agent.speed = _patrolMoveSpeed;
_agent.SetDestination(_patrolDest);
}
while (true)
{
if (IsFindTarget(_chaseRange))
{
ChangeState(MonsterState.CHASE);
yield break;
}
if (_agent && !_agent.pathPending && _agent.remainingDistance <= 0.2f)
{
ChangeState(MonsterState.IDLE);
yield break;
}
yield return null;
}
}
private IEnumerator Co_Chase()
{
SetAnimation(hashChase: true);
_prevPosition = transform.position;
_target = GameObject.FindGameObjectWithTag("Player");
if (_agent)
{
_agent.isStopped = false;
_agent.speed = _chaseSpeed;
}
while (true)
{
if (_target == null)
{
ChangeState(MonsterState.IDLE);
yield break;
}
float dist = Vector3.Distance(transform.position, _target.transform.position);
if (_agent) _agent.SetDestination(_target.transform.position);
if (dist <= _attackRange)
{
ChangeState(MonsterState.COMBAT);
yield break;
}
if (dist > _chaseRange)
{
ChangeState(MonsterState.IDLE);
yield break;
}
yield return null;
}
}
private IEnumerator Co_Combat()
{
SetAnimation(hashCombat: true);
_target = GameObject.FindGameObjectWithTag("Player");
if (_agent)
{
_agent.isStopped = true;
_agent.velocity = Vector3.zero;
}
float currentCooldown = _attackInterval;
while (true)
{
if (_target == null || _target.GetComponent<Combat>().IsAlive == false)
{
ChangeState(MonsterState.IDLE);
yield break;
}
Vector3 dirToTarget = (_target.transform.position - transform.position).normalized;
RotatetoDirection(dirToTarget);
currentCooldown -= Time.deltaTime;
if (currentCooldown <= 0)
{
float distTarget = Vector3.Distance(transform.position, _target.transform.position);
if (distTarget > _attackRange)
{
ChangeState(MonsterState.CHASE);
yield break;
}
_target.GetComponent<Combat>().TakeDamage(_damage);
currentCooldown = _attackInterval;
}
yield return null;
}
}
private IEnumerator Co_Damaged()
{
_anim.SetTrigger(_hashDamaged);
_hp -= _pendingDamage;
if (_agent)
{
_agent.isStopped = true;
_agent.velocity = Vector3.zero;
}
if (_hp <= 0)
{
_isAlive = false;
ChangeState(MonsterState.DEAD);
yield break;
}
yield return new WaitForSeconds(_damageStunTime);
if (IsFindTarget(_chaseRange))
{
if (IsFindTarget(_attackRange))
{
ChangeState(MonsterState.COMBAT);
}
else
{
ChangeState(MonsterState.CHASE);
}
}
else
{
ChangeState(MonsterState.IDLE);
}
}
private IEnumerator Co_Dead()
{
_anim.SetTrigger(_hashDead);
_collider.enabled = false;
if (_agent) _agent.enabled = false;
yield return new WaitForSeconds(_deadTime);
Destroy(gameObject);
}
private void RotatetoDirection(Vector3 dir)
{
if (dir == Vector3.zero) return;
Quaternion targetRot = Quaternion.LookRotation(dir);
transform.rotation = Quaternion.Slerp(
transform.rotation, targetRot, _rotateSpeed * Time.deltaTime);
}
private bool IsFindTarget(float range)
{
if (_target)
{
float distTarget = Vector3.Distance(transform.position, _target.transform.position);
if (distTarget < range)
{
return true;
}
}
return false;
}
public void TakeDamage(float damage)
{
_pendingDamage = damage;
ChangeState(MonsterState.DAMAGED);
}
public void SetAnimation(bool hashPatrol = false, bool hashChase = false, bool hashCombat = false)
{
_anim.SetBool(_hashPatrol, hashPatrol);
_anim.SetBool(_hashChase, hashChase);
_anim.SetBool(_hashCombat, hashCombat);
}
private void ApplyAgentSetting(MonsterState state)
{
if (_agent == null) return;
switch (state)
{
case MonsterState.IDLE:
_agent.isStopped = true;
_agent.velocity = Vector3.zero;
_agent.ResetPath();
break;
case MonsterState.PATROL:
_agent.isStopped = false;
_agent.speed = _patrolMoveSpeed;
_agent.acceleration = _patrolAccel;
_agent.angularSpeed = _patrolAngularSpeed;
break;
case MonsterState.CHASE:
_agent.isStopped = false;
_agent.speed = _chaseSpeed;
_agent.acceleration = _chaseAccel;
_agent.angularSpeed = _chaseAngularSpeed;
_agent.stoppingDistance = _chaseStoppingDistance;
break;
case MonsterState.COMBAT:
_agent.isStopped = true;
_agent.velocity = Vector3.zero;
_agent.ResetPath();
break;
case MonsterState.DAMAGED:
_agent.isStopped = true;
_agent.velocity = Vector3.zero;
_agent.ResetPath();
break;
case MonsterState.DEAD:
_agent.isStopped = true;
_agent.velocity = Vector3.zero;
_agent.ResetPath();
_agent.enabled = false;
break;
}
}
public void Initialize(float speed, float damage, float hp, Vector3 scale)
{
_chaseSpeed = speed;
_damage = damage;
_hp = hp;
transform.localScale = scale;
if (_agent != null) _agent.speed = _chaseSpeed;
}
}
GameManager
using System.Collections;
using System.Collections.Generic;
using Unity.Cinemachine;
using UnityEngine;
public class RoomManager : MonoBehaviour
{
[Header("오브젝트 직렬화")]
[SerializeField] private GameObject monsterPrefab;
[SerializeField] private Transform[] spawnPoints;
[SerializeField] private GameObject door;
[Header("몬스터 스탯 설정")]
[SerializeField] private int spawnCount = 1;
[SerializeField] private float moveSpeed = 8f;
[SerializeField] private float damage = 5f;
[SerializeField] private float hp = 20f;
[SerializeField] private Vector3 monsterScale = Vector3.one;
[Header("시네머신 카메라 연결")]
[SerializeField] private CinemachineCamera spawnCam;
[SerializeField] private CinemachineCamera doorCam;
private List<GameObject> _activeMonsters = new List<GameObject>();
private bool _isCleared = false;
private bool _isSpawned = false;
public void StartRoomEvent()
{
StartCoroutine(Co_RoomSequence());
}
private IEnumerator Co_RoomSequence()
{
if (spawnCam != null) spawnCam.Priority = 20;
yield return new WaitForSeconds(1.5f);
SpawnMonsters();
yield return new WaitForSeconds(1.0f);
if (spawnCam != null) spawnCam.Priority = 0;
yield return new WaitForSeconds(1.5f);
while (true)
{
if (CheckAllDead()) break;
yield return new WaitForSeconds(0.5f);
}
if (doorCam != null) doorCam.Priority = 20;
yield return new WaitForSeconds(1.5f);
OpenDoor();
yield return new WaitForSeconds(1.0f);
if (doorCam != null) doorCam.Priority = 0;
}
private void SpawnMonsters()
{
for (int i = 0; i < spawnCount; i++)
{
Transform spawnPos = spawnPoints[i % spawnPoints.Length];
GameObject newMonster = Instantiate(monsterPrefab, spawnPos.position, spawnPos.rotation);
MonsterAI ai = newMonster.GetComponent<MonsterAI>();
if (ai != null)
{
ai.Initialize(moveSpeed, damage, hp, monsterScale);
}
_activeMonsters.Add(newMonster);
}
_isSpawned = true;
}
private bool CheckAllDead()
{
if (!_isSpawned) return false;
foreach (GameObject monster in _activeMonsters)
{
if (monster != null) return false;
}
return true;
}
private void OpenDoor()
{
if (door != null) door.SetActive(false);
_isCleared = true;
}
}
네비게이션
- 각 방의 바닥에 NavMesh Surface 컴포넌트를 추가해서 장애물들을 자동으로 인식하고 오브젝트들의 충돌을 구현합니다.
- NavMesh: 캐릭터가 걸어 다닐 바닥과 갈 수 없는 장애물을 구분해 놓은 그물망.
- NavMesh 구현: 지형 설정을 하고 Bake를 합니다. 움직일 캐릭터에 Agent를 설정합니다.(NavMesh Agent)
문제 해결 방법
- 너무 많은 총알을 생성하고 파괴하다보니 메모리 할당과 해제과 많이 반복 되고 가비지 컬랙터가 작동해서 프레임 드랍이 발생할 수 밖에 없었습니다. PoolManager 스크립트를 만들어서 총알을 큐로 관리해서 해결했습니다. 이거는 다음 포스팅에서 설명