[내일배움캠프] 유니티
들어가기 앞서
오랜만에 써보는 TIL 이번엔 방치형 게임에 필요한 자동사냥하는 플레이어 AI 구현전에 구조를 비교분석해서 체택한 구조로 진행하도록 하겠다
R&D_ AI 구조 체택
FSM vs HFSM vs BT
개발
● 기존에 있던 방식들의 비교를 하여 프로젝트 규모와 AI를 갖는 주체의 역할크기를 고려해서 체택하기 위해 비교를 진행함
구분 | FSM | HFSM (Hierarchical FSM) | BT (Behavior Tree) |
동작 개요 | 상태 전이 그래프 | 상·하위 상태 중첩 | 트리 평가(Selector/Sequence) |
장점 | 단순, 디버그 쉬움 | 복잡도 분할, 전이 폭발 완화 | 구성/재사용↑, 데코레이터/병렬 표현 |
단점 | 전이 폭발 | 설계/전이 관리 부담 | 트리 비대화, 튜닝 번거로움 |
표현력 | 낮음 | 중 | 높음(조건·병렬·가드) |
확장성 | 낮음 | 중 | 중~높음 |
유지보수 | 쉬움 | 보통 | 보통~어려움(트리 커지면) |
런타임 비용 | 낮음 | 낮음 | 중(평가 비용) |
데이터 주도화 | 제한적 | 제한적 | 쉬움(BT 노드/트리) |
적합 사례 | 소형 AI, 간단 자동사냥 | 중형 AI, 패턴 분할 | 반응 다양, 다단계 절차/조건 |
리스크 | 상태/전이 난립 | 상하위 전이 복잡 | 우선순위/빈도 튜닝 지옥 |
전형적 사용 | 감지→이동→공격 | 페이즈·서브머신 | 상시 재평가·가드·병렬 센서 |
추천하는 방식
● 하이브리드(Utility + HFSM/BT) |
> 기존 문제: 방치형/보스전은 “상황 적응(거리/체력/페이즈)”과 “연출 절차(텔레그래프→시전→리커버리)”가 동시에 필요함. - 선택 로직(무엇을 실행할지)은 Utility가 가중치/쿨다운/상황으로 깔끔히 튜닝 가능. - 실행 절차(어떻게 실행할지)는 HFSM/BT가 캐스팅·무적·인터럽트·병렬 연출을 안전하게 제어. > 하이브리드 핵심 구성 예시 - Phase Manager: HP/시간/기믹으로 페이즈 전환, 허용 스킬/가중치 테이블 교체 - Ability SO: 사거리/쿨다운/가중치 곡선/텔레그래프/리커버리 데이터화 - Utility Scoring: 거리·각도·LoS·쿨다운·페이즈 보정으로 점수 계산 - Executor(HFSM/BT): 캐스팅 루틴(텔레그래프→시전→리커버리), 인터럽트/슈퍼아머 처리 - 저주파 판단: 0.2~0.5초 간격으로 재평가(LOD) > 결과: 선택은 수치로, 실행은 절차로 분리 → 확장성, 튜닝성, 안정성 모두 확보. |
BT vs 하이브리드
구분 | 순수 BT | Utility + BT |
선택 로직 | 트리 순서/가드 | 점수 기반(동적 우선순위) |
튜닝 난이도 | 높음(노드 순서/가드 수정) | 낮음(가중치·쿨다운·곡선) |
상황 적응 | 제한적(분기/가드) | 우수(연속 점수, 페이즈 가중치) |
패턴 다양성 | 반복 경향 고정 | 가중치 갱신으로 다양화 |
데이터 드리븐 | 트리 구조 중심 | 트리 + SO 테이블(능력치) |
우선순위/빈도 제어 | 트리 순서 의존 | 점수/쿨다운으로 명시적 |
예측 가능성 | 트리 설계에 좌우 | 스코어가치가 명료 |
구현 난이도 | 중 | 중~높음(유틸리티 레이어 추가) |
성능 | 중(평가만) | 중(평가 + 스코어) → 저주파로 OK |
권장 사례 | 반응 다양하지만 빈도 튜닝 적음 | 보스/페이즈, 튜닝·다양성 중요 |
● 선택시 고려사항 |
> (소형) AI/간단 자동사냥: FSM 또는 HFSM(살짝만 계층화) > (중형) AI/다양한 반응: BT > 보스/페이즈·패턴 튜닝/방치형: Utility + HFSM/BT (페이즈/가중치/쿨다운/연출 분리) |
>
선택
● 본인은 기간이 짦고 플레이어가 크게 여러 기능들을 가지고 있는것이 아닌 추적-공격 정도의 사이클이 돌만한 ai여서 소형 ai로 FSM를 선택하여 구조를 작성 해보도록 하려고 한다
프로젝트 구현 단계
GameManager 구조 개선
● 기존에 쓰던 GameManager에서 다른 매니저들을 등록시키는 코드가 매니저가 많아질수록 그 줄이 길어저서 오히려 코드상에 가독성을 떨어트리고 있기애 자동화 코드를 넣어서 간소화 작업을 진행하고자 하였다
>기존 코드
private void InitManagers()
{
GameObject resourceObj = new GameObject("ResourceManager");
resourceObj.transform.SetParent(transform, false);
resourceManager = resourceObj.AddComponent<ResourceManager>();
GameObject poolObj = new GameObject("PoolManager");
poolObj.transform.SetParent(transform, false);
poolManager = poolObj.AddComponent<PoolManager>();
GameObject sceneObj = new GameObject("SceneManager");
sceneObj.transform.SetParent(transform, false);
sceneManager = sceneObj.AddComponent<SceneManager>();
GameObject uiObj = new GameObject("UIManager");
uiObj.transform.SetParent(transform, false);
uiManager = uiObj.AddComponent<UIManager>();
GameObject playerObj = new GameObject("PlayerManager");
playerObj.transform.SetParent(transform, false);
playerManager = playerObj.AddComponent<PlayerManager>();
GameObject eventObj = new GameObject("EventManager");
eventObj.transform.SetParent(transform, false);
eventManager = eventObj.AddComponent<EventManager>();
GameObject scoreObj = new GameObject("ScoreManager");
scoreObj.transform.SetParent(transform, false);
scoreManager = scoreObj.AddComponent<ScoreManager>();
GameObject achievenmentObj = new GameObject("AchievenmentManager");
achievenmentObj.transform.SetParent(transform, false);
achievenmentManager = achievenmentObj.AddComponent<AchievenmentManager>();
GameObject soundObj = new GameObject("SoundManager");
soundObj.transform.SetParent(transform, false);
soundManager = soundObj.AddComponent<SoundManager>();
}
>변경 코드
private void InitManagers()
{
resourceManager = CreateChildManager<ResourceManager>("ResourceManager");
poolManager = CreateChildManager<PoolManager>("PoolManager");
sceneManager = CreateChildManager<SceneManager>("SceneManager");
uiManager = CreateChildManager<UIManager>("UIManager");
eventManager = CreateChildManager<EventManager>("EventManager");
soundManager = CreateChildManager<SoundManager>("SoundManager");
characterManager = CreateChildManager<CharacterManager>("CharacterManager");
stateMachineManager = CreateChildManager<StateMachineManager>("StateMachineManager");
}
private T CreateChildManager<T>(string goName) where T : Component
{
var go = new GameObject(goName);
go.transform.SetParent(transform, false);
return go.AddComponent<T>();
}
● where T : Component를 사용한 이유 |
호출시 반드시 Component를 기입하도록 제약을 두기 위해서 이기때문 제약이 없으면 다른것을 호출할수도 있고 기입이 없는 상태로 작성하여 에러가 발생되는 일을 줄이기 위해서임 이후 한가지 타입으로만 호출 하게 할경우 Component 에서 MonoBehaviour로 변경해서 사용 가능함 |
● 시사점 |
기존에 사용하던 방식은 매니저가 많아지면 매니저 하나에 3줄을 써야한다 그래서 길게 스크롤 해야하고 같은 작업을 반복해서 반복적인 작업을 해주는 CreateChildManager 메서드는 제작하여 진행함 결과적으로 코드가 이전보다 깔끔해지고 가독성적으로 개선된 느낌이 있음 |
Player FSM 제작
● 탐색상태, 추적상태, 공격상태 3가지 정도 상태가 필요함
> 상태 인터페이스
public interface IState
{
public void OnEnter();
public void OnExit();
public void OnUpdate();
}
> 상태 매니저
● 상태 메서드 호출용도
public class StateMachineManager : MonoBehaviour
{
protected IState currentState;
public void ChangeState(IState next)
{
if (currentState == next) return;
currentState?.OnExit();
currentState = next;
currentState?.OnEnter();
}
public void Update()
{
currentState?.OnUpdate();
}
}
> 플레이어 상태머신
public class PlayerStateMachine : MonoBehaviour
{
StateMachineManager fsm = GameManager.StateMachine;
Idle idle;
Chase chase;
Attack attack;
private void Awake()
{
fsm.ChangeState(idle);
}
// ====== 상태들 ======
class Idle : IState
{
public void OnEnter()
{
}
public void OnExit()
{
}
public void OnUpdate()
{
}
}
class Chase : IState
{
public void OnEnter()
{
}
public void OnExit()
{
}
public void OnUpdate()
{
}
}
class Attack : IState
{
public void OnEnter()
{
}
public void OnExit()
{
}
public void OnUpdate()
{
}
}
}
● 현재 상태 |
- Idle : 탐색 로직 추가예정 - Chase : 추적 로직 추가예정 - Attack : 공격 로직 추가예정 우선 틀부터 만들어두고 안에 이전에 하던 탐색 로직, 추적, 공격등 로직만 넣어서 상태변경만 이뤄주면 된다 |
플레이어 스탯(데이터 추가)
● 플레이어 스탯 정보 추가 구조 필요
> Serializable 데이터 추가
public enum StatType
{
None,
HP,
MP,
EXP,
Speed,
ATK,
DEF,
DEX,
Count,// 갯수 확인용 항상 마지막에 위치
}
[System.Serializable]
public class PlayerDataSO
{
[Header("Key (스탯이름)")]
public string key;
[Header("종류")]
public StatType statType;
[Header("초기 값 입력")]
public float value;
}
> ScriptableObject로 데이터와 바인딩 및 추가
[CreateAssetMenu(fileName = "PlayerData", menuName = "Player/PlayerData")]
public class PlayerData : ScriptableObject
{
public List<PlayerDataSO> stats = new();
public bool TryGet(StatType type, out float value)
{
value = 0f;
if (type == StatType.None) return false;
for (int i = 0; i < stats.Count; i++)
{
var data = stats[i];
if (data != null && data.statType == type)
{
value = data.value; return true;
}
}
return false;
}
}
● TryGet 메서드 |
ScriptableObject에 등록한 데이터를 찾기위한 탐색, 읽기용 메서드 |
> PlayerStat클래스
public class PlayerStat : MonoBehaviour
{
[SerializeField] private PlayerData data;
private float[] cur = new float[(int)StatType.Count];
private void Awake()
{
if (!data)
data = GameManager.Resource.Load<PlayerData>("Data/PlayerData");
ResetAllFromSO();
}
public void ZeroAll()
{
System.Array.Clear(cur, 0, cur.Length); // 전부 0으로
}
public void ResetAllFromSO()
{
ZeroAll();
if (!data) // 데이터가 없으면 0으로 유지
{
Debug.LogWarning("[PlayerStat] PlayerData가 없습니다. 기본값(0) 유지", this);
return;
}
for (int i = 1; i < (int)StatType.Count; i++)
{
var type = (StatType)i;
if (data.TryGet(type, out var value))
cur[i] = Mathf.Max(0f, value); // 음수 방지
}
}
public float Get(StatType type) => cur[(int)type];
public void Set(StatType type, float value) => cur[(int)type] = Mathf.Max(0f, value);
public void Add(StatType type, float value) => Set(type, Get(type) + value);
public void ApplyDamage(float value)
{
float final = Mathf.Max(1f, value - Get(StatType.DEF));
Set(StatType.HP, Get(StatType.HP) - final);
}
}
● 시사점 |
데이터랑 연동하는 과정에서 플레이어 스탯 데이터 클래스 구조를 설계하는게 시간을 많이 잡아먹었다 처음엔 switch문으로 변수 탐색하고 코드가 길어서 배열로 만들어서 간소화 시키고 하는 과정이 꽤나 오래걸렸다 >Mathf.Max를 쓰는 이유는 음수가 되는것을 방지하려고 0이하로는 수가 떨어지지 않게 하기 위함임 |
마치며.
의외의 곳에서 시간을 많이 잡혀서 머리도 쥐난것 마냥 아파온다 이젠 실재적인 움직임 관련 로직만 남았으니 뭔가 다행? 인듯 하다
'유니티개발 TIL' 카테고리의 다른 글
11주차 1일 TIL_유니티 실전 프로젝트(CommandManager, 턴제시스템 구상) (0) | 2025.09.09 |
---|---|
11주차 1일 TIL_유니티 실전 프로젝트(프로젝트 구상) (0) | 2025.09.08 |
6주차 4일 TIL_유니티 입문(3D프로젝트_new InputSystem의 고찰) (6) | 2025.08.07 |
6주차 1일 TIL_유니티 입문(팀프로젝트: UI매니저와 보상&업그레이드 시스템) (9) | 2025.08.04 |
5주차 2일 TIL_유니티 입문(유사 2D메타버스 만들기_플래피버드, UI작업) (6) | 2025.07.29 |