- 식물학을 전공한 해군이 바다를 항해하면서 식물 도감을 모두 채울 수 있도록 도와주세요!
- 게임 시작 시, 간단한 튜토리얼을 진행하고, [Tab]-조작법 으로 언제든지 조작법을 확인할 수 있습니다!
- 각각의 섬에 숨어 있는 NPC를 찾아서 아이템을 구매하세요!
- 혼자서는 힘들어요. 동료를 소환해서 함께 싸우세요!
- 보물박스를 지나치지 마세요! 게임을 진행하는데 큰 도움이 됩니다!
- 도감을 열어서 식물이 있는 섬과 식물 채집 난이도를 확인하세요!
- 동료를 가지고 있으면 종료 시간에 따라 방치형 보상을 받을 수 있습니다!
- 해적 보스 근처에 식물을 잘 찾아보세요!
- 게임 이름 : Ocean Bloom
- 플랫폼 : PC
- 장르 : 3D 액션 어드벤쳐
- 개발 기간 : 24.01.10 ~ 24.02.28
Unity 2022.3.2- IDE : Visual Studio 2019, 2022, MonoDevelop
- VCS : Git (GitHub Desktop)
- Envrionment : PC
only - Resolution : 1920 x 1080
FHD
- 팀장
나재민- 플레이어 구현 / 총기 구현 / 저장 구현 / 로딩 씬 구현 / 오브젝트 풀 구현 / 적 구현 - 팀원1
양인호- 대포 구현 / 맵 디자인 / 미니맵 구현 / UI 구현
- 다양한 상태를 가진 적과 동료 움직임 구현
- 끊임없이 독립적으로 행동해야 함
- 유연한 상태 관리로 필요에 따라 상태를 추가하거나 수정이 가능해야 함
- 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;
}
}
- 미리 생성한 총탄 프리팹을 파괴하지 않고, 재사용해서 최적화를 위해
- 프리팹의 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;
}- 각각 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();
}
- 씬이 전환 될 때, 다음 씬에서 사용될 리소스들을 읽어와서 게임을 위한 준비 작업 필요
- 로딩 화면이 없다면 가만히 멈춘 화면이나 까만 화면만 보일 수 있음
- 씬이 전환 될 때, 지루한 대기 시간을 이미지나 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 변경 시, 남은 부분을 로드하고 씬 이동
- 유니티에서 제공하는 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++;
}
- Player의 연속 공격 구현
- Sub-State Machine로 각각 애니메이션을 연결
public void OnAttackInput(InputAction.CallbackContext context)
{
if (context.phase == InputActionPhase.Started)
{
_animator.SetTrigger("Attack");
}
}
- 유저 피드백에서 게임 진행에 대한 정보가 부족하다는 피드백을 받음
- Tutorial 빈 게임오브젝트에 Canvas 추가, Canvas의 자식으로 튜토리얼 UI 생성
- 튜토리얼을 실행하는 콜라이더 범위 IsTrigger로 설정
- 튜토리얼을 관리하는 스크립트와 튜토리얼 범위를 관리하는 스크립트 작성
public void TutorialActive(int num)
{
_tutorials[num].SetActive(true);
Time.timeScale = 0f;
_isPause = true;
IsTutorialActive[num] = true;
}
- 마지막 저장 시간에 따른 보상 지급
- DateTime.Now로 마지막 저장 시간을 구해서 PlayerPrefs로 로컬 저장
- 저장한 시간과 현재 시간을 계산해서 경과 시간을 계산
- 경과 시간에 따라 보상을 지급
if (PlayerPrefs.HasKey("LastTime"))
{
_lastTime = DateTime.Parse(PlayerPrefs.GetString("LastTime"));
_currentTime = DateTime.Now;
_timeSpan = _currentTime - _lastTime;
_rewardTime = _timeSpan.TotalSeconds;
}
- 간편하고 직관적으로 구현 가능
- 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);
}- 입력 이벤트에 대한 바인딩 및 처리를 쉽게 구성
- 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;
}- 복잡한 입력 시스템이나 다중 입력 조합을 유연하게 처리
- 실제와 같은 총알 속도, 탄도학 등 적용 가능
- 실제와 유사하게 적용하는 것이 어려움
- 적절한 메모리 관리 방법 필요
private void Fire()
{
Instantiate(bullet, transform.position, Quaternion.identity);
}- 총알 프리팹을 생성할 필요가 없음
- 즉각적으로 대상의 정보를 읽어 올 수 있음
- 별도의 메모리 관리 방법이 필요 없음
if (Physics.Raycast(Camera.main.transform.position, Camera.main.transform.forward, out _hitInfo, 50f))
{
Debug.Log(_hitInfo.transform.name);
}- 초당 프레임 개선 (63 FPS → 73 FPS)
- 간단하고 직관적으로 구현 가능
- 반복적인 프리팹 생성, 삭제로 성능 저하 초래
- 적절한 메모리 관리 방법 필요
private void Fire()
{
Instantiate(_bulletEffectObj, transform.position, Quaternion.identity);
}- 프리팹 생성, 파괴를 하지 않음
- 객체를 미리 생성해서 재사용 → 메모리 최적화 가능
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);
}public GameObject SpawnFromPool(string tag)
{
if (!PoolDictionary.ContainsKey(tag))
return null;
GameObject obj = PoolDictionary[tag].Dequeue();
PoolDictionary[tag].Enqueue(obj);
return obj;
}- 초당 프레임 개선 (50 FPS → 76 FPS)
- 적과 동료의 독립적인 움직임을 구현하기 위한 방법이 필요
- 간단하고 직관적으로 구현 가능
- 행동이 많다면 코드가 복잡해짐
- 새로운 상태 추가가 쉬움
- 확장성이 용이
- 특정 조건에 따라 각각 다른 행동을 할 수 있음
- 특정 행동을 추가해도 유지 관리가 용이
- 동료와 적의 단체 전투 요소를 위해 Targetting 방법이 필요
- 간단하게 구현 가능
- 특정 범위 내의 적이나 동료 판별 가능
- 코루틴 함수로 일정 시간 반복해서 사용해야 함
private void Targetting()
{
int layerMask = (1 << _layerMask); // Layer 설정
_targets = Physics.OverlapSphere(transform.position, 50f, layerMask);
}- BoxCollider 사용 시, 총기 구현에서 사용한 Physics.Raycast가 BoxCollider를 먼저 인식해서 적을 인식할 수 없음
- 범위 내에서 가장 가까운 적이나 동료를 지정 가능
- Player의 정보와 배의 위치를 저장할 수 있는 간단한 저장 방법 필요
- 유니티에서 제공하는 기능으로 직관적으로 간단하게 사용 가능
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로 충분히 구현 가능
- 유저 테스트 기간 : 24.02.21 ~ 24.02.28
- 유저 테스트를 통해, 피드백을 받고 수정 작업 진행
- ObjectPool을 통해, 최적화 진행
- 상태 패턴 구현
- 전체적인 최적화 진행 부족
- 배, 파도 유료 에셋의 사용법을 제대로 숙지 못함
- 방치형 보상 기능의 로컬 시간 저장
- 게임의 목적성, 컨텐츠 부족으로 흥미가 떨어짐
- PlayerPrefs를 이용한 저장의 보완이 필요
유료 에셋 사용, 최적화 진행, 유저 피드백 경험 등 처음으로 시도한 것들이 많아서 의미가 깊은 프로젝트였습니다. 최적화 진행에서는 완벽하다고 볼 수는 없지만, 다음 프로젝트에서 더욱 잘할 수 있을 거라는 자신감을 가질 수 있었습니다. 다른 조들에 비해서 인원 수가 많이 부족했지만, 그만큼 정말 많은 것들을 배울 수 있었습니다.















