✨프로젝트 설명
니케를 UnityEngine에서 3D로 재해석하여 구현해봤습니다.
구현한 부분이 많지는 않지만 원작과 비슷한 느낌을 내려고 노력해봤습니다.
캐릭터 모델링은 공식 홈페이지에서 배포하는 mmd모델을 사용하였습니다.
📹결과 영상
✨구현 포인트
📌 스크립터블 오브젝트 활용
니케, 에너미, 웨이브 데이터를 스크립트 오브젝트로 구성했습니다
1. 니케 데이터
고유 개체 구분을 위한 이름, 프리팹과 탄창, 속도, 데미지, 데미지 체크 방식 등을 설정합니다.
오딘 인스펙터의 `[ShowIf] `속성을 사용하여 데미지 체크 방식에따라 인스펙터에 노출되는 설정값이 다르도록 구현했습니다.
2. 에너미 데이터
에너미 데이터는 프리팹, hp 그리고 행동을 결정하는 쿨타임을 설정 변수로 구현했습니다
행동 결정 쿨타임이 지나면 Idle,Move,Jump,Shoot 중 1개의 행동을 선택합니다
3. 웨이브 데이터
적들의 등장 패턴을 조정하는 용도의 웨이브 데이터를 구현했습니다.
게임 시간이 얼마나 지나야 등장할 수 있는지 설정하는 `WaveStartTime`
스폰 시간 간격을 조정하는 `SpawnInterval`
그리고 등장할 적의 종류와 수를 설정할 수 있습니다.
이 웨이브 스폰이 시작됐는지와 스폰이 종료됐는지를 판단하는 bool 변수는 런타임에 사용합니다
📌 FSM패턴
니케와 에너미를 FSM패턴 기반으로 구현했습니다
`Enum`타입과 `Switch`문을 활용해 간단한 방식으로 구현했습니다
니케 FSM 코드 일부
public enum StateType { None = -1, Idle, Shooting, Reloading, Dead }
private void OnStateEnter(StateType state)
{
if (showStateTransitionLog)
Debug.LogFormat("StateEnter: {0}", state.ToString());
switch (state)
{
case StateType.None:
break;
case StateType.Idle:
break;
case StateType.Shooting:
fireRateTimer = myNikkeData.FireRatePerSecond / 60f;
anim.SetBool(IsShooting, true);
RotateTween(shootRot);
break;
case StateType.Reloading:
reloadCancelToken = new CancellationTokenSource();
Reload(reloadCancelToken);
if (secondHandIk != null)
{
secondHandIk.solver.SetIKPositionWeight(0f);
secondHandIk.solver.SetIKRotationWeight(0f);
}
if(IsFocused)
CrosshairController.Instance.Reload();
break;
case StateType.Dead:
break;
}
}
private void OnStateUpdate(StateType state)
{
switch (state)
{
case StateType.None:
break;
case StateType.Idle:
IdleUpdate();
break;
case StateType.Shooting:
ShootingUpdate();
break;
case StateType.Reloading:
ReloadingUpdate();
break;
case StateType.Dead:
break;
}
}
private void OnStateExit(StateType state)
{
if (showStateTransitionLog)
Debug.LogFormat("StateExit: {0}", state.ToString());
switch (state)
{
case StateType.None:
break;
case StateType.Idle:
break;
case StateType.Shooting:
anim.SetBool("isShooting", false);
RotateTween(idleRot);
break;
case StateType.Reloading:
reloadCancelToken.Dispose();
if (secondHandIk != null)
{
secondHandIk.solver.SetIKPositionWeight(1f);
secondHandIk.solver.SetIKRotationWeight(1f);
}
break;
case StateType.Dead:
break;
}
}
public void ChangeState(StateType state)
{
if (CurrentState == state)
return;
OnStateExit(CurrentState);
CurrentState = state;
OnStateEnter(CurrentState);
}
📌 추상 클래스와 상속
에너미 구현과 니케의 무기 구현에는 추상클래스와 상속을 활용했습니다
`EnemyBase` 클래스를 아래 코드와 같이 정의했고 구현될 에너미는 이 클래스를 상속받아 구현했습니다
using Sirenix.OdinInspector;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using DG.Tweening;
using UniRx;
using System;
using Oniboogie.Message;
[RequireComponent(typeof(Damageable))]
public abstract class EnemyBase : MonoBehaviour, Oniboogie.Message.IMessageReceiver
{
public enum StateType { None = -1, Idle, Moving, Shooting, Dead }
[Title("Base")]
[SerializeField]
[ReadOnly]
protected StateType currentState;
protected Damageable damageable;
protected EnemyData myEnemyData;
protected IDisposable decisionObservable; // 행동 결정 타이머 observable
protected EnemyHpBar hpBar;
public StateType CurrentState { get { return currentState; } }
public bool IsDead { get => damageable.IsDead; }
protected virtual void Awake()
{
damageable = GetComponent<Damageable>();
damageable.onDamageMessageReceivers.Add(this);
}
protected virtual void Update()
{
OnStateUpdate(currentState);
}
// 초기화
public virtual void Init(EnemyData enemyData)
{
myEnemyData = enemyData;
if (damageable == null)
damageable = GetComponent<Damageable>();
damageable.maxHitPoints = enemyData.HP;
damageable.ResetDamage();
hpBar = PoolManager.instance.Pop("EnemyHpBar").GetComponent<EnemyHpBar>();
hpBar.Init(this);
}
// 메세지를 받음. 현재는 피격에만 사용
public virtual void OnReceiveMessage(MessageType type, object sender, object msg)
{
if (type == MessageType.DEAD)
{
OnDead((Damageable.DamageMessage)msg);
hpBar.Push();
}
else
{
OnDamaged((Damageable.DamageMessage)msg);
hpBar.UpdateBar(damageable.CurrentHitPoints / damageable.maxHitPoints);
}
}
// 해당 좌표로 점프
public virtual void JumpTo(Vector3 pos, float duration = 0.5f)
{
transform.DOJump(pos, 10f, 1, duration);
}
// 피격
public abstract void OnDamaged(Damageable.DamageMessage msg);
// 사망
public virtual void OnDead(Damageable.DamageMessage msg)
{
WaveManager.Instance.OnEnemyDead(this);
if (decisionObservable != null)
{
decisionObservable.Dispose();
decisionObservable = null;
}
}
protected virtual void OnIdleEnter()
{
// 쿨타임이 다 될때마다 행동 결정 함수를 호출
// UniRx활용
decisionObservable =
Observable.Interval(TimeSpan.FromSeconds(myEnemyData.ActionDecisionTime))
.Subscribe(_ => ActionDecision());
}
protected virtual void OnIdleExit()
{
// 행동 결정 observable 해제
decisionObservable.Dispose();
decisionObservable = null;
}
/// <summary>
/// 행동 결정 쿨타임이 다되면 호출. 다음 행동을 결정함
/// </summary>
protected virtual void ActionDecision()
{
}
#region FSM
protected abstract void OnStateEnter(StateType state);
protected abstract void OnStateExit(StateType state);
protected abstract void OnStateUpdate(StateType state);
protected virtual void ChangeState(StateType state)
{
if (currentState == state)
return;
OnStateExit(currentState);
currentState = state;
OnStateEnter(state);
}
#endregion
}
니케의 무기 구현을 위해 `LauncherBase` 추상 클래스를 작성했고, 무기 특성에 따라 레이캐스트 무기(라이플), 투사체 무기(미사일 런쳐), 오버랩 스피어 무기(샷건) 3종을 구현했습니다
`LauncherBase`클래스는 아래와 같습니다
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public abstract class LauncherBase : MonoBehaviour
{
[SerializeField]
protected NikkeController myNikke; // 이 무기를 사용하는 주인 니케
[SerializeField]
protected LayerMask hitLayerMask;
[SerializeField]
protected float damage;
public virtual void Init(NikkeController myNikke, LayerMask mask)
{
this.myNikke = myNikke;
hitLayerMask = mask;
damage = myNikke.MyNikkeData.Damage;
}
/// <summary>
/// 발사 함수이지만 무기의 특성에따라 쏠 수 있는 상황이 다를 수 있음
/// </summary>
/// <returns>쏠 수 없는 상황이면 false</returns>
public abstract bool Launch();
// 현재 마우스 아래 오브젝트를 받아옴
protected virtual bool GetCurrentHoveringObject(out RaycastHit hit)
{
var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out hit, float.MaxValue, hitLayerMask))
{
return true;
}
return false;
}
}
📌 어드레서블 활용
실제 상용 프로젝트라면 `Instantiate`보다는 어드레서블을 통해 인스턴스를 생성할 것이기 때문에 니케 오브젝트 인스턴스를 만들땐 어드레서블을 활용했습니다
어드레서블을 사용하면 비동기로 진행되기 때문에 니케가 생성되고 준비가되기 전 실행되면 안되는 코드들을 막는 처리를 진행했습니다
관련된 일부 코드는 다음과 같습니다
NikkeManager.cs 일부
private void Update()
{
// 모든 니케가 생성되고 준비될때까지 return
if (!IsAllNikkesInitComplete)
return;
foreach (var nikke in allNikkes.Where(nikke => nikke != currentControllingNikke))
{
aiController.NikkeAiUpdate(nikke);
}
}
// 어드레서블 니케 프리팹 인스턴스 비동기 생성
private async void GenerateNikkes()
{
for(int i = 0; i < nikkeDatas.Count; i++)
{
var nd = nikkeDatas[i];
var nikkeObject = await Addressables.InstantiateAsync(nd.Prefab);
nikkeObject.transform.position =nikkePositions[i].position;
allNikkes.Add(nikkeObject.GetComponent<NikkeController>());
nikkeObject.SetActive(true);
}
}
📌 최적화를 위한 고민
최적화를 위해 고민한 사례 대표적 2가지로 오브젝트 풀링과 콜렉션 재활용을 했습니다
다음과 같이 `PoolManager`를 구현해서 자주 생성되고 파괴되는 오브젝트인 에너미, 이펙트, 투사체에 사용했습니다
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class PoolManager : MonoBehaviour
{
public static PoolManager instance;
public List<Poolable> poolableList;
[SerializeField]
private List<Poolable> currentActivePoolables;
private Dictionary<string, Stack<Poolable>> poolDictionary;
private void Awake()
{
if (instance == null)
instance = this;
poolDictionary = new Dictionary<string, Stack<Poolable>>();
foreach (var poolObj in poolableList.Where(poolObj => poolObj != null))
{
poolDictionary.Add(poolObj.id, new Stack<Poolable>());
for (int i = 0; i < poolObj.createCountInAwake; i++)
{
Poolable clone = Instantiate(poolObj.gameObject, transform).GetComponent<Poolable>();
clone.gameObject.SetActive(false);
poolDictionary[poolObj.id].Push(clone);
}
}
}
private void OnDestroy()
{
if (instance == this)
instance = null;
}
/* 프리팹 또는 같은 오브젝트를 파라미터로 받아서
* 풀러블 오브젝트를 찾아줌
*/
public Poolable Pop(Poolable poolObj)
{
// 풀에 존재한다면 찾음
if (poolDictionary.ContainsKey(poolObj.id))
{
Poolable returnObj = null;
while (poolDictionary[poolObj.id].Count > 0 && returnObj == null)
{
returnObj = poolDictionary[poolObj.id].Pop();
if (returnObj.isUsing)
{
returnObj = null;
continue;
}
returnObj.isUsing = true;
returnObj.onPop?.Invoke();
}
if (returnObj == null)
{
returnObj = Instantiate(poolObj.gameObject, transform).GetComponent<Poolable>();
returnObj.gameObject.SetActive(false);
returnObj.isUsing = true;
returnObj.onPop?.Invoke();
}
currentActivePoolables.Add(returnObj);
return returnObj;
}
// 풀에 없다면 풀을 만들고 새로생성해서 반환해줌
else
{
poolableList.Add(poolObj);
poolDictionary.Add(poolObj.id, new Stack<Poolable>());
Poolable clone = Instantiate(poolObj, transform).GetComponent<Poolable>();
clone.gameObject.SetActive(false);
clone.isUsing = true;
if (clone.isAutoPooling)
clone.ResetAutoPoolingTimer();
clone.onPop?.Invoke();
currentActivePoolables.Add(clone);
return clone;
}
}
public Poolable Pop(string id)
{
if (!poolDictionary.ContainsKey(id))
{
Debug.LogError("ID에 해당하는 풀링된 오브젝트 없음");
return null;
}
// 생성한 풀러블 오브젝트가 있다면 탐색
if (poolDictionary[id].Count > 0)
{
Poolable returnObj = null;
while (poolDictionary[id].Count > 0 && returnObj == null)
{
returnObj = poolDictionary[id].Pop();
if (returnObj.isUsing)
{
returnObj = null;
continue;
}
returnObj.isUsing = true;
returnObj.onPop?.Invoke();
}
// 미사용중인 풀러블 오브젝트가 없다면 새로생성
if (returnObj == null)
{
returnObj = Instantiate(poolDictionary[id].Peek().gameObject, transform).GetComponent<Poolable>();
returnObj.gameObject.SetActive(false);
returnObj.isUsing = true;
returnObj.onPop?.Invoke();
}
currentActivePoolables.Add(returnObj);
return returnObj;
}
// 생성한 풀러블 오브젝트가 없다면
else
{
// 프리팹 리스트에서 아이디로 프리팹을 찾아봄
Poolable prefab = poolableList.Find(p => p.id == id);
if (prefab == null)
{
Debug.LogError("ID에 해당하는 풀링된 오브젝트 없음");
return null;
}
// 프리팹을 찾았다면 생성
Poolable clone = Instantiate(prefab.gameObject, transform).GetComponent<Poolable>();
clone.gameObject.SetActive(false);
clone.isUsing = true;
if (clone.isAutoPooling)
clone.ResetAutoPoolingTimer();
clone.onPop?.Invoke();
currentActivePoolables.Add(clone);
return clone;
}
}
public void Push(Poolable poolObj)
{
if (!poolDictionary.ContainsKey(poolObj.id))
{
poolDictionary.Add(poolObj.id, new Stack<Poolable>());
poolableList.Add(poolObj);
}
poolObj.onPush?.Invoke();
poolObj.isUsing = false;
poolDictionary[poolObj.id].Push(poolObj);
poolObj.gameObject.SetActive(false);
if (currentActivePoolables.Contains(poolObj))
currentActivePoolables.Remove(poolObj);
}
public void PushAllActivePoolables()
{
if (currentActivePoolables.Count <= 0)
return;
List<Poolable> copyList = new List<Poolable>();
foreach (var p in currentActivePoolables)
copyList.Add(p);
foreach (var p in copyList)
Push(p);
currentActivePoolables.Clear();
}
}
또한 `Physics`의 `Raycast`와 `OverlapSphere`를 사용할 때 호출 마다 배열을 생성하지 않는 `NonAlloc`함수를 사용하여 메모리 최적화를 신경썼습니다
List<Damageable> hitDamageables = new List<Damageable>();
// 범위내에에 적이 있는지 체크
// 배열을 재활용하는 NonAlloc 사용
if(Physics.OverlapSphereNonAlloc(transform.position, radius, colls, hitMask) > 0)
{
foreach(var coll in colls)
{
if (!coll)
continue;
Damageable dam = coll.GetComponent<Damageable>();
if(dam)
{
hitDamageables.Add(dam);
}
}
myLauncher.OnHit(hitDamageables);
break;
}
✨ 여담
해당 프로젝트는 유료 에셋(오딘 인스펙터, Magica Cloth, 아트)를 사용했고, 니케 모델 임포트에 사용한 MMD4Mecanim플러그인 제작자의 요청에 따라 저장소를 공개할 수 없습니다
니케들의 스킬과 버스트, UI애니메이션 등을 넣어서 더 고도화를 해볼지는 아직 결정하지 못했지만 시간이 허용된다면 더 고도화를 해서 또 작성해보겠습니다
'Unity > Portfolio' 카테고리의 다른 글
[Unity] 3D 탑다운 무한 맵 (0) | 2024.11.09 |
---|---|
[Unity] 페르소나 5 메뉴 UI 구현 (1) | 2024.10.06 |
대구 게임 아카데미 프로젝트 3종 (0) | 2024.06.22 |
[Unity] 퍼즐게임 레벨에디터 구현 (0) | 2022.07.30 |