3 minute read

작업 내용

HSM(계층적 상태 머신) 만들기

HSM을 정말 이론만 보고 내 마음대로 만들었다.
그러다보니 미리 짜여진 예시가 없어 작업을 하면서 하나씩 필요한 변수나 함수를 추가해야했다.
최종적인 구조는 다음과 같다 :
UserUnit.cs의 Awake() 함수 내부

// 루트
IdleState = new UserUnitIdleState(this); 

// 1층 브랜치
MovingAttackState = new UserUnitMovingAttackState(this);
TrackingState = new UserUnitTrackingState(this);
JustMoveState = new UserUnitJustMoveState(this);

// 리프 브렌치
AttackState = new UserUnitAttackState(this);
MoveState = new UserUnitMoveState(this);

// 각 스테이트 SuperState 세트 해주기 (leaf 스테이트는 super가 바뀔 수 있기에 안줌
MovingAttackState.SetSuperState(IdleState);
TrackingState.SetSuperState(IdleState);
JustMoveState.SetSuperState(IdleState);

// 각 스테이트 SubState 세트
IdleState.SetSubStates(new UserUnitBaseState[] { MovingAttackState, TrackingState, JustMoveState, AttackState });
MovingAttackState.SetSubStates(new UserUnitBaseState[] { MoveState, AttackState });
TrackingState.SetSubStates(new UserUnitBaseState[] { MoveState, AttackState });
JustMoveState.SetSubStates(new UserUnitBaseState[] { MoveState });
MoveState.SetSubStates(new UserUnitBaseState[] { AttackState } );

이러한 상태에서 하위 상태로 이동할 때 현재 상태에 하위 상태와 변화할 하위 상태의 상위 상태를 할당해준다.

/// <summary>
/// 새로운 SubState로 바꾸는 함수
/// 현재 State를 SuperState로 저장해줌
/// </summary>
/// <param name="state"></param>
public void ChangeToSubState(UserUnitBaseState state)
{
    if (currentState.SubStates.Contains(state))
    {
        //Debug.Log("ChangeToSubState" + currentState + " -> " + state);
        currentState.Exit();
        currentState.SetCurrentSubState(state);
        state.SetSuperState(currentState);
        currentState = state;
        currentState.Enter();
    }
    else
    {
        ChangeState(state);
    }
}
/// <summary>
/// 현재 State가 가진 SuperState로 바꿔주는 함수
/// </summary>
public void ChangeToSuperState()
{
    
    if (currentState.SuperState.IsOkeyToChange())
    {
        //Debug.Log("Change To SuperState : " + currentState + " -> " + currentState.SuperState);
        currentState.Exit();
        currentState = currentState.SuperState;
        currentState.Enter();
    }
    else
    {
        //Debug.Log("Change Back : " + currentState + " -> " + currentState);
        currentState.CameBackToThisState();
    }
}

BaseState도 새로운 함수들이 생겼는데 대표적으로 TrackingState를 보자면 :

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class UserUnitTrackingState : UserUnitBaseState
{
    #region Private Fields
    private Enemy targetEnemy;
    private float attackRange;
    #endregion

    #region Public Methods
    public UserUnitTrackingState(UserUnit userUnit) : base(userUnit)
    {
        
    }
    /// <summary>
    /// 타겟 적을 가져오고
    /// 공격 범위를 가져옴 (사거리를 고려해서 적이 멀어지면 추격해서 도착할 목표 위치 설정)
    /// </summary>
    public override void Enter()
    {
        base.Enter();
        userUnit.Action.IsAlert = false; // 주변에 적이 들어오면 공격
        userUnit.Action.IsOnTheMove = false; // 이동중이 아님
        userUnit.Action.IsTracking = true; // 적을 추격하는 중
        userUnit.Action.IsHold = false;
        agent.isStopped = false; // 에이전트 이동

        targetEnemy = userUnit.Action.TargetEnemy;
        attackRange = userUnit.Stat.AttackRange.TotalValule;

        if (IsTargetEnemyInvalid())
        {
            userUnit.StateMachine.ChangeToSuperState();
            return;
        }

        if (IsTargetEnemyOutOfRange())
        {
            SetNewDestination();
        }
    }

    public override void Exit()
    {
        
    }
    public override bool IsOkeyToChange()
    {
        if(targetEnemy == null)
        {
            return true;
        }
        if (currentSubState == userUnit.MoveState)
        {
            if (IsTargetEnemyOutOfRange())
            {
                userUnit.Action.TargetPosition = CalculateNewTargetPosition();
                return false;
            }
        }
        return true;
    }
    public override void EnemyInRange()
    {
        base.EnemyInRange();
        userUnit.StateMachine.ChangeToSubState(userUnit.AttackState);
    }
    #endregion

    #region Private Methods
    private bool IsTargetEnemyInvalid()
    {
        return targetEnemy == null || targetEnemy.Dead;
    }

    private bool IsTargetEnemyOutOfRange()
    {
        return Vector3.Distance(targetEnemy.transform.position, userUnit.transform.position) > attackRange;
    }

    private void SetNewDestination()
    {
        userUnit.Action.TargetPosition = CalculateNewTargetPosition();
        userUnit.StateMachine.ChangeToSubState(userUnit.MoveState);
    }

    private Vector3 CalculateNewTargetPosition()
    {
        Vector3 directionToEnemy = (targetEnemy.transform.position - userUnit.transform.position).normalized;
        return GridGenerator.Instance.Grid.WorldPositionInBound(targetEnemy.transform.position - directionToEnemy * (attackRange - 1.0f));
    }
    #endregion
}

이러한 방식으로 적이 범위에 들어오면 공격 상태로 전환하고, 적이 멀어지면 반대로 이동 상태로 변화한다.
문제는 이동하는 중에 상태가 전환되면서 중간에 유닛이 잠깐 멈추는 문제가 있었다.
이유로는 전환하는 동안 프레임이 1개 소모되거나 아니면 적이 범위에 들어와서 Move상태에서 상위인 Tracking상태로 전환이 되었는데 그 다음 프레임에서 정확하게 적이 1프레임 이동하면서 밖으로 나가버리면 다시 Move로 바뀌어야하기에 중간에 1 프레임을 아무것도 안하게 되어 유저가 보기에는 멈춘 것 처럼 보일 수도 있다.
그래서 현재로서는 유닛의 이동 거리를 공격 사거리보다 조금 앞쪽으로 이동하도록 하여 이를 해결하려 했지만 여전히 적의 이전 위치를 기반으로 이동하니 완벽히 해소되지는 못했다.
다른 방법으로는 유닛의 이동을 추격 중일 때 따로 상태를 줘서 적의 위치를 실시간으로 갱신해 이동하는 상태를 추가할 수도 있지만 아직까지는 이 현상이 눈에 띌 정도는 아니라고 판단되어 이 쯤 해두고 문제가 심하다고 판단될 경우에 추가해서 고치기로 했다.

마무리

생각보다 코드 구조를 고치는게 만만한 일이 아니었다.
다음에는 유닛이 이동, 공격, 홀드 상태일 때 UI에 표시되고, UI에 버튼으로도 이런 행동들을 할 수 있게 할 생각인데, 그러려다보니 기존에는 유저의 입력에 대한 콜백 함수에 직접적으로 유닛 조작 기능을 다 넣어놨었는데 이제는 UI도 같은 역할을 할 수 있으니 이 내부 내용을 따로 빼서 관리를 하도록 구조를 또 수정해야 할 일이 생겼다.