Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

62 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🖥️ Ocean Bloom

❗ 유료 에셋 사용으로 에셋 부분은 삭제하였습니다. ❗

  • 식물학을 전공한 해군이 바다를 항해하면서 식물 도감을 모두 채울 수 있도록 도와주세요!
  • 게임 시작 시, 간단한 튜토리얼을 진행하고, [Tab]-조작법 으로 언제든지 조작법을 확인할 수 있습니다!
  • 각각의 섬에 숨어 있는 NPC를 찾아서 아이템을 구매하세요!
  • 혼자서는 힘들어요. 동료를 소환해서 함께 싸우세요!
  • 보물박스를 지나치지 마세요! 게임을 진행하는데 큰 도움이 됩니다!
  • 도감을 열어서 식물이 있는 섬과 식물 채집 난이도를 확인하세요!
  • 동료를 가지고 있으면 종료 시간에 따라 방치형 보상을 받을 수 있습니다!
  • 해적 보스 근처에 식물을 잘 찾아보세요!

📽️ 프로젝트 소개

  • 게임 이름 : Ocean Bloom
  • 플랫폼 : PC
  • 장르 : 3D 액션 어드벤쳐
  • 개발 기간 : 24.01.10 ~ 24.02.28

⚙️ Environment

  • Unity 2022.3.2
  • IDE : Visual Studio 2019, 2022, MonoDevelop
  • VCS : Git (GitHub Desktop)
  • Envrionment : PC only
  • Resolution : 1920 x 1080 FHD

👤 Collaborator - Team Intro

  • 팀장 나재민 - 플레이어 구현 / 총기 구현 / 저장 구현 / 로딩 씬 구현 / 오브젝트 풀 구현 / 적 구현
  • 팀원1 양인호 - 대포 구현 / 맵 디자인 / 미니맵 구현 / UI 구현

▶️ 게임 스크린샷


🔳 와이어 프레임

image

🧩 클라이언트 구조

GameManager

image

Player

image

Enemy

image

✏️ 구현 기능

1. 상태 패턴 구현

구현 이유

  • 다양한 상태를 가진 적과 동료 움직임 구현
  • 끊임없이 독립적으로 행동해야 함
  • 유연한 상태 관리로 필요에 따라 상태를 추가하거나 수정이 가능해야 함

구현 방법

  • IState 인터페이스 : 구체적인 상태 클래스로 연결할 수 있도록 설정
public interface IEnemyState
{
    void Handle(EnemyController controller);
}

  • Context 스크립트 : 클라이언트가 객체의 내부 상태를 변경할 수 있도록 요청하는 인터페이스를 정의
public void Transition()
{
    CurrentState.Handle(_enemyController);
}

public void Transition(IEnemyState state)
{
    CurrentState = state;
    CurrentState.Handle(_enemyController);
}

  • State 스크립트 : 각 State를 정의, State 변경 조건 설정
// Start문과 동일하게 사용
public void Handle(EnemyController enemyController)
{
    if (!_enemyController)
        _enemyController = enemyController;

    Debug.Log("Idle 상태 시작");   
    _idleTime = 3f;
    _time = 0;

    StartCoroutine(COUpdate());
}

// Update문과 동일하게 사용
IEnumerator COUpdate()
{
  while (true)
  {
  	// 각각의 상태 변환 조건 설정
      if (_enemyController.Distance <= 5f)
      {
          _enemyController.AttackStart();
          break;
      }          
      if (_time >= _idleTime)
      {
          _enemyController.WalkStart();
          break;
      }
    
      yield return null;
  }
}

2. ObjectPool 구현

구현 이유

  • 미리 생성한 총탄 프리팹을 파괴하지 않고, 재사용해서 최적화를 위해
  • 프리팹의 Instantiate, Destroy 함수 사용을 줄이기 위해

구현 방법

  • ObjectPoolManager로 ObjectPool들을 관리
  • Size만큼 미리 프리팹을 생성하고, 선입선출인 Queue 자료구조로 순차적으로 SetActive(true) 실행
public GameObject SpawnFromPool(string tag)
{
    if (!PoolDictionary.ContainsKey(tag))
        return null;

    GameObject obj = PoolDictionary[tag].Dequeue();
    PoolDictionary[tag].Enqueue(obj);

    return obj;
}

3. GameManger 구현


구현 이유

  • 각각 Manager들을 통합하여 접근 가능한 Manager가 필요

구현 방법

  • 어디서든 쉽게 접근이 가능해야 하므로 싱글톤 사용
  • GameManger은 Manager들을 관리하는 하나의 역할만 수행
public static GameManager I;

[field: SerializeField] public DataManager DataManager { get; private set; }
[field: SerializeField] public SoundManager SoundManager { get; private set; }

private void Awake()
{
    if (I == null) I = this;
    else Destroy(gameObject);
    
    Init();
}

private void Init()
{
    DataManager.Init();
    SoundManager.Init();
}

private void Release()
{
    DataManager.Release();
    SoundManager.Release();
}

4. 로딩 씬 구현

구현 이유

  • 씬이 전환 될 때, 다음 씬에서 사용될 리소스들을 읽어와서 게임을 위한 준비 작업 필요
  • 로딩 화면이 없다면 가만히 멈춘 화면이나 까만 화면만 보일 수 있음
  • 씬이 전환 될 때, 지루한 대기 시간을 이미지나 Tip으로 지루하지 않게 하기 위해

구현 방법

  • 씬을 불러오는 도중에 다른 작업이 가능 비동기 방식 씬 전환 구현
IEnumerator LoadScene()
{
    yield return null;
    AsyncOperation op = SceneManager.LoadSceneAsync(NextScene);
    op.allowSceneActivation = false;
    float timer = 0.0f;
    while (!op.isDone)
    {
        yield return null;
        timer += Time.deltaTime;
        if (op.progress < 0.9f)
        {
            _loadingBar.value = Mathf.Lerp(_loadingBar.value, op.progress, timer);
            if (_loadingBar.value >= op.progress)
            {
                timer = 0f;
            }
        }
        else
        {
            _loadingBar.value = Mathf.Lerp(_loadingBar.value, 1f, timer);
            if (_loadingBar.value == 1.0f)
            {
                op.allowSceneActivation = true;
                yield break;
            }
        }
    }
}
  • 리소스 로딩이 끝나기 전에 씬 로딩 되는 것을 막기 위해 allowSceneActivation을 false로 설정
  • allowSceneActivation을 false로 90% 로드 한 상태로 대기하고, true 변경 시, 남은 부분을 로드하고 씬 이동

5. 3D 사운드 구현

구현 이유

  • 유니티에서 제공하는 3D 사운드 기능을 사용하니 거리에 따른 소리 음량 크기 조절이 한번씩 제대로 적용이 안되는 현상 발생
  • 사운드가 깨지거나 이상한 소리로 변질되어 재생되는 현상 발생
  • 하나의 AudioSource에서 동시에 많은 수의 PlayOneShot 함수가 실행되면 리소스 및 처리 성능에 영향을 줄 수 있음

구현 방법

  • 3D 사운드 기능을 사용하지 않고, 거리에 따라 볼륨을 직접 조절하는 방식으로 직접 구현
  • 미리 생성한 몇 개의 AudioSource가 순차적으로 재생하도록 구현
public void StartSFX(string name, Vector3 position)
{
	_index = _index % _etcSFXAudioSource.Length;
	
	float distance = Vector3.Distance(position, GameManager.I.PlayerManager.Player.transform.position);
	float volume = 1f - (distance / _maxDistance);
	if (volume < 0) volume = 0f;
	_etcSFXAudioSource.volume = Mathf.Clamp01(volume);
	_etcSFXAudioSource.PlayOneShot(_sfx[name]);
	
	_index++;
}

6. Player 3단 콤보 공격 구현

구현 이유

  • Player의 연속 공격 구현

구현 방법

  • Sub-State Machine로 각각 애니메이션을 연결
public void OnAttackInput(InputAction.CallbackContext context)
{
    if (context.phase == InputActionPhase.Started)
    {
        _animator.SetTrigger("Attack");
    }
}

7. 튜토리얼 시스템 구현

구현 이유

  • 유저 피드백에서 게임 진행에 대한 정보가 부족하다는 피드백을 받음

구현 방법

  • Tutorial 빈 게임오브젝트에 Canvas 추가, Canvas의 자식으로 튜토리얼 UI 생성
  • 튜토리얼을 실행하는 콜라이더 범위 IsTrigger로 설정
  • 튜토리얼을 관리하는 스크립트와 튜토리얼 범위를 관리하는 스크립트 작성
public void TutorialActive(int num)
{
	_tutorials[num].SetActive(true);
	Time.timeScale = 0f;
	_isPause = true;
	IsTutorialActive[num] = true;
}

8. 방치형 보상 기능 구현

구현 이유

  • 마지막 저장 시간에 따른 보상 지급

구현 방법

  • DateTime.Now로 마지막 저장 시간을 구해서 PlayerPrefs로 로컬 저장
  • 저장한 시간과 현재 시간을 계산해서 경과 시간을 계산
  • 경과 시간에 따라 보상을 지급
if (PlayerPrefs.HasKey("LastTime"))
{
    _lastTime = DateTime.Parse(PlayerPrefs.GetString("LastTime"));
    _currentTime = DateTime.Now;
    _timeSpan = _currentTime - _lastTime;
    _rewardTime = _timeSpan.TotalSeconds;
}

💥 트러블 슈팅

1. Input System을 이용한 Player 이동 개선

Input 클래스로 Player 이동 구현

  • 간편하고 직관적으로 구현 가능
  • Update 문에서 매 프레임 실행하기 때문에 성능에 영향
private void FixedUpdate()
{
	float moveHorizontal = Input.GetAxis("Horizontal");
	float moveVertical = Input.GetAxis("Vertical");
	
	Vector3 movement = new Vector3(moveHorizontal, 0f, moveVertical);
	_rigidbody.AddForce(movement * speed);
}

Input System으로 개선

  • 입력 이벤트에 대한 바인딩 및 처리를 쉽게 구성
  • Update문에서 매 프레임 실행할 필요가 없음
  • 다양한 입력 장치를 지원
public void OnMoveInput(InputAction.CallbackContext context)
{
	if (context.phase == InputActionPhase.Performed)
	{
	    _curMovementInput = context.ReadValue<Vector2>();
	}
	else if (context.phase == InputActionPhase.Canceled)
	{
	    _curMovementInput = Vector2.zero;
	}
}

private void Move()
{
	Vector3 dir = transform.forward * _curMovementInput.y + transform.right * _curMovementInput.x;
	dir *= MoveSpeed;
	dir.y = _rigidbody.velocity.y;
	
	_rigidbody.velocity = dir;
}

결과

  • 복잡한 입력 시스템이나 다중 입력 조합을 유연하게 처리

2. Physics.Raycast를 이용한 총기 구현 개선

총알 프리팹을 생성해서 총기 구현

  • 실제와 같은 총알 속도, 탄도학 등 적용 가능
  • 실제와 유사하게 적용하는 것이 어려움
  • 적절한 메모리 관리 방법 필요
private void Fire()
{
	Instantiate(bullet, transform.position, Quaternion.identity);
}

Physics.Raycast로 개선

  • 총알 프리팹을 생성할 필요가 없음
  • 즉각적으로 대상의 정보를 읽어 올 수 있음
  • 별도의 메모리 관리 방법이 필요 없음
if (Physics.Raycast(Camera.main.transform.position, Camera.main.transform.forward, out _hitInfo, 50f))
{
	Debug.Log(_hitInfo.transform.name);
}

결과

  • 초당 프레임 개선 (63 FPS → 73 FPS)


3. ObjectPool을 이용한 총기 탄피 구현 개선

프리팹 생성, 파괴로 총기 탄피 구현

  • 간단하고 직관적으로 구현 가능
  • 반복적인 프리팹 생성, 삭제로 성능 저하 초래
  • 적절한 메모리 관리 방법 필요
private void Fire()
{
	Instantiate(_bulletEffectObj, transform.position, Quaternion.identity);
}

ObjectPool로 개선

  • 프리팹 생성, 파괴를 하지 않음
  • 객체를 미리 생성해서 재사용 → 메모리 최적화 가능
ObjectPoolManager
public void GunEffect(string poolName ,Vector3 startPosition, Quaternion rotation)
{
	_bulletEffectObj = ObjectPool.SpawnFromPool(poolName);
	
	_bulletEffectObj.transform.position = startPosition;
	_bulletEffectObj.transform.rotation = rotation;
	//RangedAttackController attackController = obj.GetComponent<RangedAttackController>();
	//attackController.InitializeAttack(direction, attackData, this);
	
	_bulletEffectObj.SetActive(true);
	StartCoroutine(COGunEffectInactive());
}

IEnumerator COGunEffectInactive()
{
	GameObject obj = _bulletEffectObj;
	
	yield return new WaitForSeconds(0.5f);
	obj.SetActive(false);
}
ObjectPool
public GameObject SpawnFromPool(string tag)
{
	if (!PoolDictionary.ContainsKey(tag))
	    return null;
	
	GameObject obj = PoolDictionary[tag].Dequeue();
	PoolDictionary[tag].Enqueue(obj);
	
	return obj;
}

image

결과

  • 초당 프레임 개선 (50 FPS → 76 FPS)


4. 상태 패턴을 이용한 적과 동료 구현

문제 상황

  • 적과 동료의 독립적인 움직임을 구현하기 위한 방법이 필요

해결 방안

조건문과 스위치문 사용
  • 간단하고 직관적으로 구현 가능
  • 행동이 많다면 코드가 복잡해짐
상태 패턴
  • 새로운 상태 추가가 쉬움
  • 확장성이 용이

의견 결정

상태 패턴으로 구현
  • 특정 조건에 따라 각각 다른 행동을 할 수 있음
  • 특정 행동을 추가해도 유지 관리가 용이

5. Physics.OverlapSphere를 이용한 Targetting 구현

문제 상황

  • 동료와 적의 단체 전투 요소를 위해 Targetting 방법이 필요

해결 방안

BoxCollider로 IsTrigger 범위 설정
  • 간단하게 구현 가능
Physics.OverlapSphere를 사용
  • 특정 범위 내의 적이나 동료 판별 가능
  • 코루틴 함수로 일정 시간 반복해서 사용해야 함
private void Targetting()
{
	int layerMask = (1 << _layerMask);	// Layer 설정
	_targets = Physics.OverlapSphere(transform.position, 50f, layerMask);
}

의견 결정

Physics.OverlapSphere로 구현
  • BoxCollider 사용 시, 총기 구현에서 사용한 Physics.Raycast가 BoxCollider를 먼저 인식해서 적을 인식할 수 없음
  • 범위 내에서 가장 가까운 적이나 동료를 지정 가능

6. PlayerPrefs를 이용한 데이터 저장 기능 구현

문제 상황

  • Player의 정보와 배의 위치를 저장할 수 있는 간단한 저장 방법 필요

해결 방안

PlayerPrefs 사용
  • 유니티에서 제공하는 기능으로 직관적으로 간단하게 사용 가능
public void DataSave()
{
	// Player 정보 저장
	PlayerPrefs.SetFloat("SaveHp", _playerConditions.Health.CurValue);
	PlayerPrefs.SetInt("SaveCurrentBullet", _playerController.GunController.CurrentGun.CurrentBulletCount);
	PlayerPrefs.SetInt("SaveCoin", _playerController.CurrentCoin);
}

public void DataLoad()
{
	// Player 정보 불러오기
	_playerConditions.Health.CurValue = PlayerPrefs.GetFloat("SaveHp");
	_playerController.GunController.CurrentGun.CurrentBulletCount = PlayerPrefs.GetInt("SaveCurrentBullet");
	_playerController.CurrentCoin = PlayerPrefs.GetInt("SaveCoin") ;
}
직렬화 및 파일 저장 사용
  • 안전하고 속도가 매우 빠름
데이터베이스 사용
  • 대규모 데이터를 저장하고 관리에 적합

의견 결정

PlayerPrefs로 구현
  • 간단한 정보만 저장하면 되기 때문에 로컬 저장이 맞다고 판단
  • 간단하게 사용할 수 있기 때문에 단순한 게임 진행도는 PlayerPrefs로 충분히 구현 가능

👩‍👦‍👦 유저 테스트

  • 유저 테스트 기간 : 24.02.21 ~ 24.02.28



📋 프로젝트 회고

잘한 점

  • 유저 테스트를 통해, 피드백을 받고 수정 작업 진행
  • ObjectPool을 통해, 최적화 진행
  • 상태 패턴 구현

한계

  • 전체적인 최적화 진행 부족
  • 배, 파도 유료 에셋의 사용법을 제대로 숙지 못함
  • 방치형 보상 기능의 로컬 시간 저장
  • 게임의 목적성, 컨텐츠 부족으로 흥미가 떨어짐
  • PlayerPrefs를 이용한 저장의 보완이 필요

소감

유료 에셋 사용, 최적화 진행, 유저 피드백 경험 등 처음으로 시도한 것들이 많아서 의미가 깊은 프로젝트였습니다. 최적화 진행에서는 완벽하다고 볼 수는 없지만, 다음 프로젝트에서 더욱 잘할 수 있을 거라는 자신감을 가질 수 있었습니다. 다른 조들에 비해서 인원 수가 많이 부족했지만, 그만큼 정말 많은 것들을 배울 수 있었습니다.

About

내일배움캠프 최종 팀 프로젝트 23조

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages